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

@@ -262,6 +262,19 @@ function SortableItemRow({
);
}
function loadOfferDraft(): {
form?: Record<string, unknown>;
items?: unknown[];
sections?: unknown[];
} | null {
try {
const raw = localStorage.getItem("boha_offer_draft");
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export default function OfferDetail() {
const { id } = useParams();
const isEdit = Boolean(id);
@@ -293,9 +306,39 @@ export default function OfferDetail() {
const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [form, setForm] = useState<OfferForm>(emptyForm);
const [items, setItems] = useState<OfferItem[]>(() => [emptyItem()]);
const [sections, setSections] = useState<ScopeSection[]>([]);
const [form, setForm] = useState<OfferForm>(() => {
const draft = loadOfferDraft();
if (draft?.form) {
return {
...emptyForm,
project_code:
(draft.form.project_code as string) || emptyForm.project_code,
customer_name:
(draft.form.customer_name as string) || emptyForm.customer_name,
created_at: (draft.form.created_at as string) || emptyForm.created_at,
valid_until:
(draft.form.valid_until as string) || emptyForm.valid_until,
currency: (draft.form.currency as string) || emptyForm.currency,
customer_id:
(draft.form.customer_id as number | null) ?? emptyForm.customer_id,
};
}
return emptyForm;
});
const [items, setItems] = useState<OfferItem[]>(() => {
const draft = loadOfferDraft();
if (Array.isArray(draft?.items) && draft.items.length > 0) {
return draft.items as OfferItem[];
}
return [emptyItem()];
});
const [sections, setSections] = useState<ScopeSection[]>(() => {
const draft = loadOfferDraft();
if (Array.isArray(draft?.sections) && draft.sections.length > 0) {
return draft.sections as ScopeSection[];
}
return [];
});
const [scopeTemplates, setScopeTemplates] = useState<
Array<{
id: number;
@@ -338,7 +381,6 @@ export default function OfferDetail() {
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const unlockAbortRef = useRef<AbortController | null>(null);
const initialSnapshotRef = useRef<string | null>(null);
const hasSetInitialSnapshot = useRef(false);
useModalLock(showOrderModal);
@@ -352,27 +394,26 @@ export default function OfferDetail() {
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(() => {});
}, []);
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 isInvalidated = offerStatus === "invalidated";
const isLockedByOther = !!lockedBy;
const isExpiredNotInvalidated =
@@ -390,7 +431,7 @@ export default function OfferDetail() {
const result = await response.json();
if (result.success) {
const d = result.data;
setForm({
const formData = {
quotation_number: d.quotation_number || "",
project_code: d.project_code || "",
customer_id: d.customer_id || null,
@@ -406,24 +447,28 @@ export default function OfferDetail() {
exchange_rate: d.exchange_rate || "",
scope_title: d.scope_title || "",
scope_description: d.scope_description || "",
};
setForm(formData);
const mappedItems = d.items?.length
? d.items.map((it: any) => ({
...it,
_key: `item-${++itemKeyCounter.current}`,
}))
: [emptyItem()];
setItems(mappedItems);
const mappedSections = d.sections?.length
? d.sections.map((s: any) => ({
title: s.title || "",
title_cz: s.title_cz || "",
content: s.content || "",
}))
: [];
setSections(mappedSections);
initialSnapshotRef.current = JSON.stringify({
form: formData,
items: mappedItems,
sections: mappedSections,
});
setItems(
d.items?.length
? d.items.map((it: any) => ({
...it,
_key: `item-${++itemKeyCounter.current}`,
}))
: [emptyItem()],
);
setSections(
d.sections?.length
? d.sections.map((s: any) => ({
title: s.title || "",
title_cz: s.title_cz || "",
content: s.content || "",
}))
: [],
);
setOfferStatus(d.status || "");
setOrderInfo(d.order || null);
setLockedBy(d.locked_by || null);
@@ -477,16 +522,10 @@ export default function OfferDetail() {
if (isEdit) fetchDetail();
}, [isEdit, fetchDetail]);
useEffect(() => {
if (loading) {
hasSetInitialSnapshot.current = false;
return;
}
if (!hasSetInitialSnapshot.current) {
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
hasSetInitialSnapshot.current = true;
}
}, [loading, form, items, sections]);
// Capture initial snapshot after loading completes (create mode)
if (!loading && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
}
const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false;
@@ -564,39 +603,6 @@ export default function OfferDetail() {
fetchNextNumber();
}, [isEdit]);
// Restore draft from localStorage on mount (create mode only)
const draftRestoredRef = useRef(false);
useEffect(() => {
if (isEdit || draftRestoredRef.current) return;
draftRestoredRef.current = true;
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return;
const draft = JSON.parse(raw);
if (draft && draft.form) {
setForm((prev) => ({
...prev,
project_code: draft.form.project_code || prev.project_code,
customer_name: draft.form.customer_name || prev.customer_name,
created_at: draft.form.created_at || prev.created_at,
valid_until: draft.form.valid_until || prev.valid_until,
currency: draft.form.currency || prev.currency,
}));
if (draft.form.customer_id) {
setForm((prev) => ({ ...prev, customer_id: draft.form.customer_id }));
}
}
if (draft && Array.isArray(draft.items) && draft.items.length > 0) {
setItems(draft.items);
}
if (draft && Array.isArray(draft.sections) && draft.sections.length > 0) {
setSections(draft.sections);
}
} catch {
/* ignore corrupt data */
}
}, [isEdit]);
// Auto-save draft to localStorage (create mode only)
const draftPayload = JSON.stringify({ form, items, sections });
const debouncedDraft = useDebounce(draftPayload, 1500);
@@ -722,7 +728,6 @@ export default function OfferDetail() {
items,
sections,
});
hasSetInitialSnapshot.current = true;
}
} else {
alert.error(result.error || "Nepodařilo se uložit nabídku");