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,41 +538,68 @@ 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,
customer_id: null, // lazily, so the autosave effect can't clobber it before it's seeded — and so
customer_name: "", // editing an existing invoice never reads the create draft (isEdit short-
order_id: fromOrderId ? Number(fromOrderId) : null, // circuits). `restoredFromDraft` lets the create-hydration effect below skip
issue_date: todayLocalStr(), // the default-bank / fromOrder overrides so the user's draft wins.
due_date: addDaysLocalStr(14), const restoredFromDraft = useRef(false);
tax_date: todayLocalStr(),
currency: "CZK", const [form, setForm] = useState<InvoiceForm>(() => {
apply_vat: 1, const defaults: InvoiceForm = {
vat_rate: 21, customer_id: null,
payment_method: "Příkazem", customer_name: "",
constant_symbol: "0308", order_id: fromOrderId ? Number(fromOrderId) : null,
issued_by: user?.fullName || "", issue_date: todayLocalStr(),
billing_text: "", due_date: addDaysLocalStr(14),
notes: "", tax_date: todayLocalStr(),
language: "cs", currency: "CZK",
bank_account_id: "", apply_vat: 1,
bank_name: "", vat_rate: 21,
bank_swift: "", payment_method: "Příkazem",
bank_iban: "", constant_symbol: "0308",
bank_account: "", 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<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();
_key: "inv-1", if (Array.isArray(draft?.items) && draft.items.length > 0) {
description: "", // Re-key restored items: their persisted _key values are stale and the
item_description: "", // key counter restarts each load, so reusing them would collide with
quantity: 1, // newly-added rows (duplicate React key / dnd-kit id).
unit: "ks", return (draft.items as InvoiceItem[]).map((it) => ({
unit_price: 0, ...it,
vat_rate: 21, _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<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,54 +799,61 @@ 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);
} }
// Set default bank account // When a draft was restored, the user's saved form/items WIN: skip the
const defaultAcc = bankAccounts.find((a) => a.is_default); // default-bank seed and the fromOrder prefill so we don't clobber the
if (defaultAcc) { // restored values (mirrors OfferDetail, which never re-applies create
setForm((prev) => ({ // defaults over a restored draft). The no-draft path is unchanged.
...prev, if (!restoredFromDraft.current) {
bank_account_id: defaultAcc.id, // Set default bank account
bank_name: defaultAcc.bank_name || "", const defaultAcc = bankAccounts.find((a) => a.is_default);
bank_swift: defaultAcc.bic || "", if (defaultAcc) {
bank_iban: defaultAcc.iban || "", setForm((prev) => ({
bank_account: defaultAcc.account_number || "", ...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 // Pre-fill from order
if (fromOrderId && orderDataQuery.data) { if (fromOrderId && orderDataQuery.data) {
const order = orderDataQuery.data; const order = orderDataQuery.data;
const vatRate = const vatRate =
Number(order.vat_rate) || (companySettings?.default_vat_rate ?? 21); Number(order.vat_rate) || (companySettings?.default_vat_rate ?? 21);
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
customer_id: order.customer_id as number, customer_id: order.customer_id as number,
customer_name: (order.customer_name as string) || "", customer_name: (order.customer_name as string) || "",
order_id: order.id as number, order_id: order.id as number,
currency: currency:
(order.currency as string) || (order.currency as string) ||
companySettings?.default_currency || companySettings?.default_currency ||
"CZK", "CZK",
apply_vat: Number(order.apply_vat) || 0, apply_vat: Number(order.apply_vat) || 0,
vat_rate: vatRate, vat_rate: vatRate,
})); }));
const orderItems = order.items as Record<string, unknown>[] | undefined; const orderItems = order.items as Record<string, unknown>[] | undefined;
if (orderItems && orderItems.length > 0) { if (orderItems && orderItems.length > 0) {
setItems( setItems(
orderItems.map((item) => ({ orderItems.map((item) => ({
_key: `inv-${++keyCounterRef.current}`, _key: `inv-${++keyCounterRef.current}`,
description: (item.description as string) || "", description: (item.description as string) || "",
item_description: (item.item_description as string) || "", item_description: (item.item_description as string) || "",
quantity: Number(item.quantity) || 1, quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || "", unit: (item.unit as string) || "",
unit_price: Number(item.unit_price) || 0, unit_price: Number(item.unit_price) || 0,
vat_rate: vatRate, vat_rate: vatRate,
})), })),
); );
}
} }
} }