feat(drafts): remove localStorage draft banner/hook; add Koncept status + filter (drafts are DB rows)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -46,8 +46,6 @@ import {
|
||||
} from "@dnd-kit/modifiers";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import apiFetch from "../utils/api";
|
||||
import useDocumentDraft, { readDraft } from "../hooks/useDocumentDraft";
|
||||
import { DRAFT_KEYS } from "../lib/draftKeys";
|
||||
import { companySettingsOptions } from "../lib/queries/settings";
|
||||
import { invoiceDetailOptions } from "../lib/queries/invoices";
|
||||
import { offerCustomersOptions } from "../lib/queries/offers";
|
||||
@@ -199,27 +197,6 @@ 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,
|
||||
@@ -539,68 +516,41 @@ export default function InvoiceDetail() {
|
||||
const fromOrderId =
|
||||
!isEdit && rawOrderId && /^\d+$/.test(rawOrderId) ? rawOrderId : null;
|
||||
|
||||
// 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 [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: "",
|
||||
}));
|
||||
|
||||
const [dueDays, setDueDays] = useState(14);
|
||||
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 [items, setItems] = useState<InvoiceItem[]>(() => [
|
||||
{
|
||||
_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);
|
||||
@@ -632,16 +582,6 @@ export default function InvoiceDetail() {
|
||||
label: `${v}%`,
|
||||
}));
|
||||
|
||||
// Autosave the create-form draft (record-scoped to recordId: null) so the
|
||||
// Invoices list can surface a "you have a draft" banner. Edit mode never
|
||||
// autosaves (enabled: !isEdit), matching the Offers behavior.
|
||||
const draftData = useMemo(() => ({ form, items }), [form, items]);
|
||||
const { clear: clearDraft } = useDocumentDraft(DRAFT_KEYS.invoice, {
|
||||
recordId: null,
|
||||
enabled: !isEdit,
|
||||
data: draftData,
|
||||
});
|
||||
|
||||
// ─── TanStack Query ───
|
||||
|
||||
const customersQuery = useQuery(offerCustomersOptions());
|
||||
@@ -797,61 +737,54 @@ export default function InvoiceDetail() {
|
||||
return;
|
||||
if (fromOrderId && orderDataQuery.isLoading) return;
|
||||
|
||||
// Set invoice number (NOT part of the draft — always set from the server's
|
||||
// next-number, even when a draft was restored).
|
||||
// Set invoice number from the server's next-number.
|
||||
if (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
|
||||
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 || "",
|
||||
}));
|
||||
}
|
||||
// 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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1044,7 +977,6 @@ export default function InvoiceDetail() {
|
||||
if (isEdit) payload.invoice_number = invoiceNumber;
|
||||
|
||||
const data = await saveMutation.mutateAsync(payload);
|
||||
if (!isEdit) clearDraft();
|
||||
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
|
||||
initialSnapshotRef.current = JSON.stringify({ form, items });
|
||||
if (!isEdit) {
|
||||
|
||||
Reference in New Issue
Block a user