fix: useEffect anti-patterns, attendance permissions, and received-invoices schema mismatch

- Remove ref-mirror useEffect in AuthContext (cachedUserRef already written at mutation sites)
- Replace useEffect slide direction in ReceivedInvoices with render-time computation
- Fix Login.tsx useEffect dependency array (mount-only alert should have [] deps)
- Move "project created" alert to navigation source in ProjectCreate, remove useEffect in ProjectDetail
- Move companySettings defaults into fetch callbacks in InvoiceDetail and OfferDetail
- Replace due_date useEffect with useMemo in InvoiceDetail
- Capture initial snapshots from API data instead of useEffect in InvoiceDetail, OfferDetail, OrderDetail
- Replace localStorage draft useEffect with lazy useState initializer in OfferDetail
- Fix attendance dropdown to filter by attendance.record permission only
- Fix clock-out 404 on update-address (remove departure_time filter for departure action)
- Fix received-invoices stats endpoint referencing non-existent is_deleted and amount_czk columns

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-04-28 10:28:15 +02:00
parent d7c7fbad88
commit 3481b97d47
11 changed files with 173 additions and 198 deletions

View File

@@ -384,7 +384,6 @@ export default function InvoiceDetail() {
const [loading, setLoading] = useState(true);
const [invoiceNumber, setInvoiceNumber] = useState("");
const initialSnapshotRef = useRef<string | null>(null);
const hasSetInitialSnapshot = useRef(false);
const [customers, setCustomers] = useState<Customer[]>([]);
const [customerSearch, setCustomerSearch] = useState("");
@@ -401,7 +400,22 @@ export default function InvoiceDetail() {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanySettings(d.data);
if (d.success) {
setCompanySettings(d.data);
if (!isEdit) {
setForm((prev) => ({
...prev,
currency:
prev.currency === "CZK"
? d.data.default_currency || "CZK"
: prev.currency,
vat_rate:
prev.vat_rate === 21
? (d.data.default_vat_rate ?? 21)
: prev.vat_rate,
}));
}
}
})
.catch(() => {});
}, []);
@@ -413,22 +427,6 @@ export default function InvoiceDetail() {
label: `${v}%`,
}));
useEffect(() => {
if (companySettings && !isEdit) {
setForm((prev) => ({
...prev,
currency:
prev.currency === "CZK"
? companySettings.default_currency || "CZK"
: prev.currency,
vat_rate:
prev.vat_rate === 21
? (companySettings.default_vat_rate ?? 21)
: prev.vat_rate,
}));
}
}, [companySettings, isEdit]);
const DRAFT_KEY = "boha_invoice_draft";
const clearDraft = useCallback(() => {
@@ -604,7 +602,7 @@ export default function InvoiceDetail() {
}
// Populate form state from existing invoice
setForm({
const formData = {
customer_id: inv.customer_id || null,
customer_name: inv.customer_name || "",
order_id: inv.order_id || null,
@@ -631,7 +629,8 @@ export default function InvoiceDetail() {
bank_swift: inv.bank_swift || "",
bank_iban: inv.bank_iban || "",
bank_account: inv.bank_account || "",
});
};
setForm(formData);
// Calculate dueDays from existing dates
if (inv.issue_date && inv.due_date) {
@@ -644,19 +643,27 @@ export default function InvoiceDetail() {
}
// Populate items from existing invoice
if (inv.items?.length > 0) {
setItems(
inv.items.map((item: Record<string, unknown>) => ({
_key: `inv-${++keyCounterRef.current}`,
id: item.id as number | undefined,
description: (item.description as string) || "",
quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || "",
unit_price: Number(item.unit_price) || 0,
vat_rate: Number(item.vat_rate) || Number(inv.vat_rate) || 21,
})),
);
const mappedItems =
inv.items?.length > 0
? inv.items.map((item: Record<string, unknown>) => ({
_key: `inv-${++keyCounterRef.current}`,
id: item.id as number | undefined,
description: (item.description as string) || "",
quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || "",
unit_price: Number(item.unit_price) || 0,
vat_rate: Number(item.vat_rate) || Number(inv.vat_rate) || 21,
}))
: [];
if (mappedItems.length > 0) {
setItems(mappedItems);
}
// Capture initial snapshot for dirty-checking
initialSnapshotRef.current = JSON.stringify({
form: formData,
items: mappedItems,
});
} else {
alert.error(result.error || "Nepodařilo se načíst fakturu");
navigate("/invoices");
@@ -673,16 +680,12 @@ export default function InvoiceDetail() {
if (isEdit) fetchDetail();
}, [isEdit, fetchDetail]);
useEffect(() => {
if (loading) {
hasSetInitialSnapshot.current = false;
return;
}
if (!hasSetInitialSnapshot.current) {
initialSnapshotRef.current = JSON.stringify({ form, items });
hasSetInitialSnapshot.current = true;
}
}, [loading, form, items]);
// Capture initial snapshot for dirty-checking once data finishes loading.
// Edit mode: captured inside fetchDetail from raw API data.
// Create mode: captured on first stable render after loading completes.
if (!loading && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items });
}
const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false;
@@ -699,12 +702,11 @@ export default function InvoiceDetail() {
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
// ─── Due date calculation from issue date + days ───
useEffect(() => {
if (!form.issue_date) return;
const computedDueDate = useMemo(() => {
if (!form.issue_date) return "";
const d = new Date(form.issue_date);
d.setDate(d.getDate() + dueDays);
setForm((prev) => ({ ...prev, due_date: d.toISOString().split("T")[0] }));
return d.toISOString().split("T")[0];
}, [form.issue_date, dueDays]);
// ─── Create mode: customer filtering ───
@@ -831,6 +833,7 @@ export default function InvoiceDetail() {
try {
const payload: any = {
...form,
due_date: computedDueDate || form.due_date,
items: items
.filter((i) => i.description.trim())
.map((item, i) => ({
@@ -862,7 +865,6 @@ export default function InvoiceDetail() {
(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena"),
);
initialSnapshotRef.current = JSON.stringify({ form, items });
hasSetInitialSnapshot.current = true;
if (isEdit) {
fetchDetail();
} else {
@@ -1601,10 +1603,10 @@ export default function InvoiceDetail() {
</option>
))}
</select>
{form.due_date && (
{computedDueDate && (
<span className="text-tertiary text-xs mt-1">
Splatnost:{" "}
{new Date(form.due_date).toLocaleDateString("cs-CZ")}
{new Date(computedDueDate).toLocaleDateString("cs-CZ")}
</span>
)}
</FormField>