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