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";
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<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
function SortableInvoiceRow({
item,
@@ -517,41 +538,68 @@ export default function InvoiceDetail() {
const fromOrderId =
!isEdit && rawOrderId && /^\d+$/.test(rawOrderId) ? rawOrderId : null;
const [form, setForm] = useState<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: "",
// 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_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<InvoiceForm>) };
}
return defaults;
});
const [dueDays, setDueDays] = useState(14);
const [items, setItems] = useState<InvoiceItem[]>([
{
_key: "inv-1",
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
vat_rate: 21,
},
]);
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",
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
vat_rate: 21,
},
];
});
const [errors, setErrors] = useState<Record<string, string>>({});
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<string, unknown>[] | 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<string, unknown>[] | 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,
})),
);
}
}
}