fix(ui): wire invoice draft restore (read-back) so the banner actually restores and autosave can't destroy the draft

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 16:32:20 +02:00
parent c4668357aa
commit 39565767ef

View File

@@ -45,7 +45,7 @@ import {
} from "@dnd-kit/modifiers"; } from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import useDocumentDraft from "../hooks/useDocumentDraft"; import useDocumentDraft, { readDraft } from "../hooks/useDocumentDraft";
import { DRAFT_KEYS } from "../lib/draftKeys"; import { DRAFT_KEYS } from "../lib/draftKeys";
import { companySettingsOptions } from "../lib/queries/settings"; import { companySettingsOptions } from "../lib/queries/settings";
import { invoiceDetailOptions } from "../lib/queries/invoices"; import { invoiceDetailOptions } from "../lib/queries/invoices";
@@ -192,6 +192,27 @@ interface InvoiceForm {
bank_account: string; bank_account: string;
} }
interface InvoiceDraftData {
form?: Record<string, unknown>;
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<InvoiceDraftData>(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 // Sortable row for create mode
function SortableInvoiceRow({ function SortableInvoiceRow({
item, item,
@@ -517,7 +538,15 @@ export default function InvoiceDetail() {
const fromOrderId = const fromOrderId =
!isEdit && rawOrderId && /^\d+$/.test(rawOrderId) ? rawOrderId : null; !isEdit && rawOrderId && /^\d+$/.test(rawOrderId) ? rawOrderId : null;
const [form, setForm] = useState<InvoiceForm>({ // 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<InvoiceForm>(() => {
const defaults: InvoiceForm = {
customer_id: null, customer_id: null,
customer_name: "", customer_name: "",
order_id: fromOrderId ? Number(fromOrderId) : null, order_id: fromOrderId ? Number(fromOrderId) : null,
@@ -538,10 +567,28 @@ export default function InvoiceDetail() {
bank_swift: "", bank_swift: "",
bank_iban: "", bank_iban: "",
bank_account: "", bank_account: "",
};
const draft = isEdit ? null : loadInvoiceDraft();
if (draft?.form) {
restoredFromDraft.current = true;
return { ...defaults, ...(draft.form as Partial<InvoiceForm>) };
}
return defaults;
}); });
const [dueDays, setDueDays] = useState(14); const [dueDays, setDueDays] = useState(14);
const [items, setItems] = useState<InvoiceItem[]>([ const [items, setItems] = useState<InvoiceItem[]>(() => {
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", _key: "inv-1",
description: "", description: "",
@@ -551,7 +598,8 @@ export default function InvoiceDetail() {
unit_price: 0, unit_price: 0,
vat_rate: 21, vat_rate: 21,
}, },
]); ];
});
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [dataReady, setDataReady] = useState(false); const [dataReady, setDataReady] = useState(false);
@@ -751,11 +799,17 @@ export default function InvoiceDetail() {
return; return;
if (fromOrderId && orderDataQuery.isLoading) 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) { if (nextNumberQuery.data) {
setInvoiceNumber(nextNumberQuery.data); setInvoiceNumber(nextNumberQuery.data);
} }
// 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 // Set default bank account
const defaultAcc = bankAccounts.find((a) => a.is_default); const defaultAcc = bankAccounts.find((a) => a.is_default);
if (defaultAcc) { if (defaultAcc) {
@@ -801,6 +855,7 @@ export default function InvoiceDetail() {
); );
} }
} }
}
setDataReady(true); setDataReady(true);
}, [ }, [