diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx index 7355f5c..0c7adfa 100644 --- a/src/admin/pages/InvoiceDetail.tsx +++ b/src/admin/pages/InvoiceDetail.tsx @@ -45,7 +45,7 @@ import { } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import apiFetch from "../utils/api"; -import useDocumentDraft from "../hooks/useDocumentDraft"; +import useDocumentDraft, { readDraft } from "../hooks/useDocumentDraft"; import { DRAFT_KEYS } from "../lib/draftKeys"; import { companySettingsOptions } from "../lib/queries/settings"; import { invoiceDetailOptions } from "../lib/queries/invoices"; @@ -192,6 +192,27 @@ interface InvoiceForm { bank_account: string; } +interface InvoiceDraftData { + form?: Record; + items?: unknown[]; +} + +/** + * Read the create-form draft (recordId === null). Returns its `data` payload or + * null. Record-scoped: a draft stored for a specific record id is ignored here, + * and we only ever write/read the "new invoice" draft (recordId: null), so a + * create draft can never leak into editing an existing invoice. Mirrors + * OfferDetail.loadOfferDraft + the Invoices-list banner's validity check + * (recordId === null, data.form present, Array.isArray(data.items)). + */ +function loadInvoiceDraft(): InvoiceDraftData | null { + const env = readDraft(DRAFT_KEYS.invoice); + if (!env || env.recordId !== null) return null; + const data = env.data; + if (!data || !data.form || !Array.isArray(data.items)) return null; + return data; +} + // Sortable row for create mode function SortableInvoiceRow({ item, @@ -517,41 +538,68 @@ export default function InvoiceDetail() { const fromOrderId = !isEdit && rawOrderId && /^\d+$/.test(rawOrderId) ? rawOrderId : null; - const [form, setForm] = useState({ - customer_id: null, - customer_name: "", - order_id: fromOrderId ? Number(fromOrderId) : null, - issue_date: todayLocalStr(), - due_date: addDaysLocalStr(14), - tax_date: todayLocalStr(), - currency: "CZK", - apply_vat: 1, - vat_rate: 21, - payment_method: "Příkazem", - constant_symbol: "0308", - issued_by: user?.fullName || "", - billing_text: "", - notes: "", - language: "cs", - bank_account_id: "", - bank_name: "", - bank_swift: "", - bank_iban: "", - bank_account: "", + // Restore the unsaved "new invoice" draft (create mode only). Read once, + // lazily, so the autosave effect can't clobber it before it's seeded — and so + // editing an existing invoice never reads the create draft (isEdit short- + // circuits). `restoredFromDraft` lets the create-hydration effect below skip + // the default-bank / fromOrder overrides so the user's draft wins. + const restoredFromDraft = useRef(false); + + const [form, setForm] = useState(() => { + const defaults: InvoiceForm = { + customer_id: null, + customer_name: "", + order_id: fromOrderId ? Number(fromOrderId) : null, + issue_date: todayLocalStr(), + due_date: addDaysLocalStr(14), + tax_date: todayLocalStr(), + currency: "CZK", + apply_vat: 1, + vat_rate: 21, + payment_method: "Příkazem", + constant_symbol: "0308", + issued_by: user?.fullName || "", + billing_text: "", + notes: "", + language: "cs", + bank_account_id: "", + bank_name: "", + bank_swift: "", + bank_iban: "", + bank_account: "", + }; + const draft = isEdit ? null : loadInvoiceDraft(); + if (draft?.form) { + restoredFromDraft.current = true; + return { ...defaults, ...(draft.form as Partial) }; + } + return defaults; }); const [dueDays, setDueDays] = useState(14); - const [items, setItems] = useState([ - { - _key: "inv-1", - description: "", - item_description: "", - quantity: 1, - unit: "ks", - unit_price: 0, - vat_rate: 21, - }, - ]); + const [items, setItems] = useState(() => { + const draft = isEdit ? null : loadInvoiceDraft(); + if (Array.isArray(draft?.items) && draft.items.length > 0) { + // Re-key restored items: their persisted _key values are stale and the + // key counter restarts each load, so reusing them would collide with + // newly-added rows (duplicate React key / dnd-kit id). + return (draft.items as InvoiceItem[]).map((it) => ({ + ...it, + _key: `inv-${++keyCounterRef.current}`, + })); + } + return [ + { + _key: "inv-1", + description: "", + item_description: "", + quantity: 1, + unit: "ks", + unit_price: 0, + vat_rate: 21, + }, + ]; + }); const [errors, setErrors] = useState>({}); const [saving, setSaving] = useState(false); const [dataReady, setDataReady] = useState(false); @@ -751,54 +799,61 @@ export default function InvoiceDetail() { return; if (fromOrderId && orderDataQuery.isLoading) return; - // Set invoice number + // Set invoice number (NOT part of the draft — always set from the server's + // next-number, even when a draft was restored). if (nextNumberQuery.data) { setInvoiceNumber(nextNumberQuery.data); } - // Set default bank account - const defaultAcc = bankAccounts.find((a) => a.is_default); - if (defaultAcc) { - setForm((prev) => ({ - ...prev, - bank_account_id: defaultAcc.id, - bank_name: defaultAcc.bank_name || "", - bank_swift: defaultAcc.bic || "", - bank_iban: defaultAcc.iban || "", - bank_account: defaultAcc.account_number || "", - })); - } + // When a draft was restored, the user's saved form/items WIN: skip the + // default-bank seed and the fromOrder prefill so we don't clobber the + // restored values (mirrors OfferDetail, which never re-applies create + // defaults over a restored draft). The no-draft path is unchanged. + if (!restoredFromDraft.current) { + // Set default bank account + const defaultAcc = bankAccounts.find((a) => a.is_default); + if (defaultAcc) { + setForm((prev) => ({ + ...prev, + bank_account_id: defaultAcc.id, + bank_name: defaultAcc.bank_name || "", + bank_swift: defaultAcc.bic || "", + bank_iban: defaultAcc.iban || "", + bank_account: defaultAcc.account_number || "", + })); + } - // Pre-fill from order - if (fromOrderId && orderDataQuery.data) { - const order = orderDataQuery.data; - const vatRate = - Number(order.vat_rate) || (companySettings?.default_vat_rate ?? 21); - setForm((prev) => ({ - ...prev, - customer_id: order.customer_id as number, - customer_name: (order.customer_name as string) || "", - order_id: order.id as number, - currency: - (order.currency as string) || - companySettings?.default_currency || - "CZK", - apply_vat: Number(order.apply_vat) || 0, - vat_rate: vatRate, - })); - const orderItems = order.items as Record[] | undefined; - if (orderItems && orderItems.length > 0) { - setItems( - orderItems.map((item) => ({ - _key: `inv-${++keyCounterRef.current}`, - description: (item.description as string) || "", - item_description: (item.item_description as string) || "", - quantity: Number(item.quantity) || 1, - unit: (item.unit as string) || "", - unit_price: Number(item.unit_price) || 0, - vat_rate: vatRate, - })), - ); + // Pre-fill from order + if (fromOrderId && orderDataQuery.data) { + const order = orderDataQuery.data; + const vatRate = + Number(order.vat_rate) || (companySettings?.default_vat_rate ?? 21); + setForm((prev) => ({ + ...prev, + customer_id: order.customer_id as number, + customer_name: (order.customer_name as string) || "", + order_id: order.id as number, + currency: + (order.currency as string) || + companySettings?.default_currency || + "CZK", + apply_vat: Number(order.apply_vat) || 0, + vat_rate: vatRate, + })); + const orderItems = order.items as Record[] | undefined; + if (orderItems && orderItems.length > 0) { + setItems( + orderItems.map((item) => ({ + _key: `inv-${++keyCounterRef.current}`, + description: (item.description as string) || "", + item_description: (item.item_description as string) || "", + quantity: Number(item.quantity) || 1, + unit: (item.unit as string) || "", + unit_price: Number(item.unit_price) || 0, + vat_rate: vatRate, + })), + ); + } } }