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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user