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:
@@ -1,105 +0,0 @@
|
|||||||
import { useEffect, useCallback } from "react";
|
|
||||||
import useDebounce from "./useDebounce";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record-scoped localStorage draft envelope.
|
|
||||||
*
|
|
||||||
* - `recordId === null` → the draft belongs to the "new document" form.
|
|
||||||
* - `recordId === N` → the draft belongs to editing record N.
|
|
||||||
*
|
|
||||||
* Scoping the draft to its record kills cross-record leakage: a create-form
|
|
||||||
* draft can never be restored over an existing record (and vice-versa) because
|
|
||||||
* the consumer compares `recordId` before restoring.
|
|
||||||
*/
|
|
||||||
export interface DraftEnvelope<T> {
|
|
||||||
recordId: number | null;
|
|
||||||
savedAt: number;
|
|
||||||
data: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read (and validate) a draft envelope from localStorage. Returns `null` when
|
|
||||||
* absent, unparseable, or the wrong shape. Availability-safe (guarded). The
|
|
||||||
* caller is responsible for record-scoping (compare `recordId` itself) so the
|
|
||||||
* list/banner can decide whether the stored draft applies to the "new" form.
|
|
||||||
*/
|
|
||||||
export function readDraft<T>(key: string): DraftEnvelope<T> | null {
|
|
||||||
try {
|
|
||||||
if (typeof localStorage === "undefined") return null;
|
|
||||||
const raw = localStorage.getItem(key);
|
|
||||||
if (!raw) return null;
|
|
||||||
const parsed = JSON.parse(raw) as Partial<DraftEnvelope<T>>;
|
|
||||||
if (
|
|
||||||
parsed &&
|
|
||||||
typeof parsed === "object" &&
|
|
||||||
"data" in parsed &&
|
|
||||||
"recordId" in parsed
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
recordId: parsed.recordId ?? null,
|
|
||||||
savedAt: typeof parsed.savedAt === "number" ? parsed.savedAt : 0,
|
|
||||||
data: parsed.data as T,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to read draft "${key}":`, e);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove a draft envelope from localStorage. Availability-safe. */
|
|
||||||
export function clearDraft(key: string): void {
|
|
||||||
try {
|
|
||||||
if (typeof localStorage === "undefined") return;
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to clear draft "${key}":`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseDocumentDraftOptions<T> {
|
|
||||||
/** `null` = the "new" form; a number = editing that record. */
|
|
||||||
recordId: number | null;
|
|
||||||
/** Only autosave while ready/dirty-eligible (e.g. create mode, data loaded). */
|
|
||||||
enabled: boolean;
|
|
||||||
/** Current form data to persist (debounced before writing). */
|
|
||||||
data: T;
|
|
||||||
/** Debounce window in ms before a write. Defaults to Offers' proven 1500ms. */
|
|
||||||
debounceMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generalizes the proven Offers autosave: debounced write of `data` to
|
|
||||||
* `localStorage[key]` (wrapped in a {@link DraftEnvelope} stamped with
|
|
||||||
* `recordId` + `savedAt`) while `enabled`. The form supplies the write side;
|
|
||||||
* the list/banner uses {@link readDraft} for the read side.
|
|
||||||
*/
|
|
||||||
export default function useDocumentDraft<T>(
|
|
||||||
key: string,
|
|
||||||
{ recordId, enabled, data, debounceMs = 1500 }: UseDocumentDraftOptions<T>,
|
|
||||||
): { clear: () => void } {
|
|
||||||
const payload = JSON.stringify(data);
|
|
||||||
const debouncedPayload = useDebounce(payload, debounceMs);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
try {
|
|
||||||
if (typeof localStorage === "undefined") return;
|
|
||||||
const envelope: DraftEnvelope<T> = {
|
|
||||||
recordId,
|
|
||||||
savedAt: Date.now(),
|
|
||||||
data: JSON.parse(debouncedPayload) as T,
|
|
||||||
};
|
|
||||||
localStorage.setItem(key, JSON.stringify(envelope));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to save draft "${key}":`, e);
|
|
||||||
}
|
|
||||||
// `recordId` is stable for a given mount; intentionally tracking the
|
|
||||||
// debounced payload + enabled flag only (mirrors Offers' original deps).
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [debouncedPayload, enabled]);
|
|
||||||
|
|
||||||
const clear = useCallback(() => clearDraft(key), [key]);
|
|
||||||
|
|
||||||
return { clear };
|
|
||||||
}
|
|
||||||
@@ -31,6 +31,7 @@ export interface StatusMeta {
|
|||||||
* stored value rendering consistently (warning, matching the expired row wash).
|
* stored value rendering consistently (warning, matching the expired row wash).
|
||||||
*/
|
*/
|
||||||
export const OFFER_STATUS: Record<string, StatusMeta> = {
|
export const OFFER_STATUS: Record<string, StatusMeta> = {
|
||||||
|
draft: { label: "Koncept", color: "default" },
|
||||||
active: { label: "Aktivní", color: "info" },
|
active: { label: "Aktivní", color: "info" },
|
||||||
ordered: { label: "Objednaná", color: "success" },
|
ordered: { label: "Objednaná", color: "success" },
|
||||||
invalidated: { label: "Zneplatněná", color: "error" },
|
invalidated: { label: "Zneplatněná", color: "error" },
|
||||||
@@ -62,6 +63,7 @@ export const ISSUED_ORDER_STATUS: Record<string, StatusMeta> = {
|
|||||||
* = warning, NOT info) — this fixes the list↔detail color drift.
|
* = warning, NOT info) — this fixes the list↔detail color drift.
|
||||||
*/
|
*/
|
||||||
export const INVOICE_STATUS: Record<string, StatusMeta> = {
|
export const INVOICE_STATUS: Record<string, StatusMeta> = {
|
||||||
|
draft: { label: "Koncept", color: "default" },
|
||||||
issued: { label: "Vystavena", color: "warning" },
|
issued: { label: "Vystavena", color: "warning" },
|
||||||
overdue: { label: "Po splatnosti", color: "error" },
|
overdue: { label: "Po splatnosti", color: "error" },
|
||||||
paid: { label: "Zaplacena", color: "success" },
|
paid: { label: "Zaplacena", color: "success" },
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Single source of truth for the localStorage keys used by the document-draft
|
|
||||||
* autosave feature. Both the form (write side) and the list/banner (read side)
|
|
||||||
* MUST import the key from here — duplicating the string literal in two files
|
|
||||||
* means changing one silently breaks the other (the banner stops matching).
|
|
||||||
*/
|
|
||||||
export const DRAFT_KEYS = {
|
|
||||||
offer: "boha_offer_draft",
|
|
||||||
invoice: "boha_invoice_draft",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type DraftKey = (typeof DRAFT_KEYS)[keyof typeof DRAFT_KEYS];
|
|
||||||
@@ -46,8 +46,6 @@ 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, { readDraft } from "../hooks/useDocumentDraft";
|
|
||||||
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";
|
||||||
import { offerCustomersOptions } from "../lib/queries/offers";
|
import { offerCustomersOptions } from "../lib/queries/offers";
|
||||||
@@ -199,27 +197,6 @@ 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,
|
||||||
@@ -539,68 +516,41 @@ export default function InvoiceDetail() {
|
|||||||
const fromOrderId =
|
const fromOrderId =
|
||||||
!isEdit && rawOrderId && /^\d+$/.test(rawOrderId) ? rawOrderId : null;
|
!isEdit && rawOrderId && /^\d+$/.test(rawOrderId) ? rawOrderId : null;
|
||||||
|
|
||||||
// Restore the unsaved "new invoice" draft (create mode only). Read once,
|
const [form, setForm] = useState<InvoiceForm>(() => ({
|
||||||
// lazily, so the autosave effect can't clobber it before it's seeded — and so
|
customer_id: null,
|
||||||
// editing an existing invoice never reads the create draft (isEdit short-
|
customer_name: "",
|
||||||
// circuits). `restoredFromDraft` lets the create-hydration effect below skip
|
order_id: fromOrderId ? Number(fromOrderId) : null,
|
||||||
// the default-bank / fromOrder overrides so the user's draft wins.
|
issue_date: todayLocalStr(),
|
||||||
const restoredFromDraft = useRef(false);
|
due_date: addDaysLocalStr(14),
|
||||||
|
tax_date: todayLocalStr(),
|
||||||
const [form, setForm] = useState<InvoiceForm>(() => {
|
currency: "CZK",
|
||||||
const defaults: InvoiceForm = {
|
apply_vat: 1,
|
||||||
customer_id: null,
|
vat_rate: 21,
|
||||||
customer_name: "",
|
payment_method: "Příkazem",
|
||||||
order_id: fromOrderId ? Number(fromOrderId) : null,
|
constant_symbol: "0308",
|
||||||
issue_date: todayLocalStr(),
|
issued_by: user?.fullName || "",
|
||||||
due_date: addDaysLocalStr(14),
|
billing_text: "",
|
||||||
tax_date: todayLocalStr(),
|
notes: "",
|
||||||
currency: "CZK",
|
language: "cs",
|
||||||
apply_vat: 1,
|
bank_account_id: "",
|
||||||
vat_rate: 21,
|
bank_name: "",
|
||||||
payment_method: "Příkazem",
|
bank_swift: "",
|
||||||
constant_symbol: "0308",
|
bank_iban: "",
|
||||||
issued_by: user?.fullName || "",
|
bank_account: "",
|
||||||
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();
|
{
|
||||||
if (Array.isArray(draft?.items) && draft.items.length > 0) {
|
_key: "inv-1",
|
||||||
// Re-key restored items: their persisted _key values are stale and the
|
description: "",
|
||||||
// key counter restarts each load, so reusing them would collide with
|
item_description: "",
|
||||||
// newly-added rows (duplicate React key / dnd-kit id).
|
quantity: 1,
|
||||||
return (draft.items as InvoiceItem[]).map((it) => ({
|
unit: "ks",
|
||||||
...it,
|
unit_price: 0,
|
||||||
_key: `inv-${++keyCounterRef.current}`,
|
vat_rate: 21,
|
||||||
}));
|
},
|
||||||
}
|
]);
|
||||||
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);
|
||||||
@@ -632,16 +582,6 @@ export default function InvoiceDetail() {
|
|||||||
label: `${v}%`,
|
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 ───
|
// ─── TanStack Query ───
|
||||||
|
|
||||||
const customersQuery = useQuery(offerCustomersOptions());
|
const customersQuery = useQuery(offerCustomersOptions());
|
||||||
@@ -797,61 +737,54 @@ export default function InvoiceDetail() {
|
|||||||
return;
|
return;
|
||||||
if (fromOrderId && orderDataQuery.isLoading) return;
|
if (fromOrderId && orderDataQuery.isLoading) return;
|
||||||
|
|
||||||
// Set invoice number (NOT part of the draft — always set from the server's
|
// Set invoice number from the server's next-number.
|
||||||
// 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
|
// Set default bank account
|
||||||
// default-bank seed and the fromOrder prefill so we don't clobber the
|
const defaultAcc = bankAccounts.find((a) => a.is_default);
|
||||||
// restored values (mirrors OfferDetail, which never re-applies create
|
if (defaultAcc) {
|
||||||
// defaults over a restored draft). The no-draft path is unchanged.
|
setForm((prev) => ({
|
||||||
if (!restoredFromDraft.current) {
|
...prev,
|
||||||
// Set default bank account
|
bank_account_id: defaultAcc.id,
|
||||||
const defaultAcc = bankAccounts.find((a) => a.is_default);
|
bank_name: defaultAcc.bank_name || "",
|
||||||
if (defaultAcc) {
|
bank_swift: defaultAcc.bic || "",
|
||||||
setForm((prev) => ({
|
bank_iban: defaultAcc.iban || "",
|
||||||
...prev,
|
bank_account: defaultAcc.account_number || "",
|
||||||
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,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,7 +977,6 @@ export default function InvoiceDetail() {
|
|||||||
if (isEdit) payload.invoice_number = invoiceNumber;
|
if (isEdit) payload.invoice_number = invoiceNumber;
|
||||||
|
|
||||||
const data = await saveMutation.mutateAsync(payload);
|
const data = await saveMutation.mutateAsync(payload);
|
||||||
if (!isEdit) clearDraft();
|
|
||||||
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
|
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
|
||||||
initialSnapshotRef.current = JSON.stringify({ form, items });
|
initialSnapshotRef.current = JSON.stringify({ form, items });
|
||||||
if (!isEdit) {
|
if (!isEdit) {
|
||||||
|
|||||||
@@ -25,12 +25,6 @@ import {
|
|||||||
type Invoice,
|
type Invoice,
|
||||||
type CurrencyAmount,
|
type CurrencyAmount,
|
||||||
} from "../lib/queries/invoices";
|
} from "../lib/queries/invoices";
|
||||||
import {
|
|
||||||
readDraft,
|
|
||||||
clearDraft,
|
|
||||||
type DraftEnvelope,
|
|
||||||
} from "../hooks/useDocumentDraft";
|
|
||||||
import { DRAFT_KEYS } from "../lib/draftKeys";
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -40,7 +34,6 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
StatusChip,
|
StatusChip,
|
||||||
StatCard,
|
StatCard,
|
||||||
Alert,
|
|
||||||
EmptyState,
|
EmptyState,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
FilterBar,
|
FilterBar,
|
||||||
@@ -55,6 +48,7 @@ import {
|
|||||||
INVOICE_STATUS,
|
INVOICE_STATUS,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
statusColor,
|
statusColor,
|
||||||
|
statusOptions,
|
||||||
} from "../lib/documentStatus";
|
} from "../lib/documentStatus";
|
||||||
|
|
||||||
const ReceivedInvoices = lazy(() => import("./ReceivedInvoices"));
|
const ReceivedInvoices = lazy(() => import("./ReceivedInvoices"));
|
||||||
@@ -96,17 +90,12 @@ function formatCzkWithDetail(
|
|||||||
return { value: formatMultiCurrency(amounts), detail: null };
|
return { value: formatMultiCurrency(amounts), detail: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_FILTERS = [
|
// Filter tabs derive from INVOICE_STATUS so `draft` ("Koncept") flows in
|
||||||
{ value: "", label: "Vše" },
|
// automatically; the leading "Všechny" tab is the all-statuses filter.
|
||||||
{ value: "issued", label: "Vystavené" },
|
const STATUS_FILTERS = statusOptions(INVOICE_STATUS, {
|
||||||
{ value: "paid", label: "Zaplacené" },
|
value: "",
|
||||||
{ value: "overdue", label: "Po splatnosti" },
|
label: "Všechny",
|
||||||
];
|
});
|
||||||
|
|
||||||
interface DraftData {
|
|
||||||
form: Record<string, unknown>;
|
|
||||||
items: Record<string, unknown>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const PlusIcon = (
|
const PlusIcon = (
|
||||||
<svg
|
<svg
|
||||||
@@ -278,24 +267,6 @@ export default function Invoices() {
|
|||||||
}>({ show: false, invoice: null });
|
}>({ show: false, invoice: null });
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
|
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
|
||||||
// Only the "new invoice" draft (recordId === null) is surfaced here; a draft
|
|
||||||
// scoped to a specific record id is never shown on the list (no leakage).
|
|
||||||
const [draft, setDraft] = useState<DraftEnvelope<DraftData> | null>(() => {
|
|
||||||
const env = readDraft<DraftData>(DRAFT_KEYS.invoice);
|
|
||||||
if (
|
|
||||||
env &&
|
|
||||||
env.recordId === null &&
|
|
||||||
env.data?.form &&
|
|
||||||
Array.isArray(env.data.items)
|
|
||||||
)
|
|
||||||
return env;
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const discardDraft = () => {
|
|
||||||
clearDraft(DRAFT_KEYS.invoice);
|
|
||||||
setDraft(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const {
|
const {
|
||||||
@@ -763,77 +734,6 @@ export default function Invoices() {
|
|||||||
</Box>
|
</Box>
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
{draft && !search && !statusFilter && (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Alert severity="info" onClose={discardDraft}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Typography component="span" sx={{ fontWeight: 600 }}>
|
|
||||||
Koncept
|
|
||||||
</Typography>
|
|
||||||
{draft.savedAt && (
|
|
||||||
<Typography
|
|
||||||
component="span"
|
|
||||||
sx={{ ml: 1, opacity: 0.8, fontSize: "0.875rem" }}
|
|
||||||
>
|
|
||||||
·{" "}
|
|
||||||
{new Date(draft.savedAt).toLocaleTimeString("cs-CZ", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{ color: "text.secondary", mt: 0.25 }}
|
|
||||||
>
|
|
||||||
{(draft.data.form.customer_name as string) || "—"} ·{" "}
|
|
||||||
{draft.data.form.issue_date
|
|
||||||
? formatDate(draft.data.form.issue_date as string)
|
|
||||||
: "—"}
|
|
||||||
{" — "}
|
|
||||||
{draft.data.form.due_date
|
|
||||||
? formatDate(draft.data.form.due_date as string)
|
|
||||||
: "—"}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
gap: 1,
|
|
||||||
flexWrap: "wrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
component={RouterLink}
|
|
||||||
to="/invoices/new"
|
|
||||||
size="small"
|
|
||||||
startIcon={EditIcon}
|
|
||||||
>
|
|
||||||
Pokračovat v konceptu
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
color="inherit"
|
|
||||||
onClick={discardDraft}
|
|
||||||
>
|
|
||||||
Zahodit koncept
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Alert>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card sx={{ opacity: loading ? 0.6 : 1, transition: "opacity .2s" }}>
|
<Card sx={{ opacity: loading ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||||
<DataTable<Invoice>
|
<DataTable<Invoice>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|||||||
@@ -57,8 +57,6 @@ import {
|
|||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import RichEditor from "../components/RichEditor";
|
import RichEditor from "../components/RichEditor";
|
||||||
import useDocumentDraft, { readDraft } from "../hooks/useDocumentDraft";
|
|
||||||
import { DRAFT_KEYS } from "../lib/draftKeys";
|
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { formatCurrency, todayLocalStr } from "../utils/formatters";
|
import { formatCurrency, todayLocalStr } from "../utils/formatters";
|
||||||
import { companySettingsOptions } from "../lib/queries/settings";
|
import { companySettingsOptions } from "../lib/queries/settings";
|
||||||
@@ -497,24 +495,6 @@ function SortableItemRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OfferDraftData {
|
|
||||||
form?: Record<string, unknown>;
|
|
||||||
items?: unknown[];
|
|
||||||
sections?: 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 offer" draft (mirrors the original
|
|
||||||
* create-only semantic), so a create draft can't leak into editing a record.
|
|
||||||
*/
|
|
||||||
function loadOfferDraft(): OfferDraftData | null {
|
|
||||||
const env = readDraft<OfferDraftData>(DRAFT_KEYS.offer);
|
|
||||||
if (!env || env.recordId !== null) return null;
|
|
||||||
return env.data ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OfferDetail() {
|
export default function OfferDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const isEdit = Boolean(id);
|
const isEdit = Boolean(id);
|
||||||
@@ -566,51 +546,9 @@ export default function OfferDetail() {
|
|||||||
const loading = isEdit && offerQuery.isLoading;
|
const loading = isEdit && offerQuery.isLoading;
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
||||||
const [form, setForm] = useState<OfferForm>(() => {
|
const [form, setForm] = useState<OfferForm>(emptyForm);
|
||||||
// The draft is the unsaved-NEW-offer buffer — never restore it over an
|
const [items, setItems] = useState<OfferItem[]>(() => [emptyItem()]);
|
||||||
// existing offer in edit mode (it would also leak its stale item keys).
|
const [sections, setSections] = useState<ScopeSection[]>([]);
|
||||||
const draft = isEdit ? null : loadOfferDraft();
|
|
||||||
if (draft?.form) {
|
|
||||||
return {
|
|
||||||
quotation_number:
|
|
||||||
(draft.form.quotation_number as string) || emptyForm.quotation_number,
|
|
||||||
project_code:
|
|
||||||
(draft.form.project_code as string) || emptyForm.project_code,
|
|
||||||
customer_id:
|
|
||||||
(draft.form.customer_id as number | null) ?? emptyForm.customer_id,
|
|
||||||
customer_name:
|
|
||||||
(draft.form.customer_name as string) || emptyForm.customer_name,
|
|
||||||
created_at: (draft.form.created_at as string) || emptyForm.created_at,
|
|
||||||
valid_until:
|
|
||||||
(draft.form.valid_until as string) || emptyForm.valid_until,
|
|
||||||
currency: (draft.form.currency as string) || emptyForm.currency,
|
|
||||||
language: (draft.form.language as string) || emptyForm.language,
|
|
||||||
vat_rate: (draft.form.vat_rate as number) ?? emptyForm.vat_rate,
|
|
||||||
apply_vat: (draft.form.apply_vat as boolean) ?? emptyForm.apply_vat,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return emptyForm;
|
|
||||||
});
|
|
||||||
const [items, setItems] = useState<OfferItem[]>(() => {
|
|
||||||
const draft = isEdit ? null : loadOfferDraft();
|
|
||||||
if (Array.isArray(draft?.items) && draft.items.length > 0) {
|
|
||||||
// Re-key restored items: their persisted _key values are stale and the
|
|
||||||
// counter restarts at 0 each load, so reusing them would collide with
|
|
||||||
// newly-added rows (duplicate React key / dnd-kit id → the warning).
|
|
||||||
return (draft.items as OfferItem[]).map((it) => ({
|
|
||||||
...it,
|
|
||||||
_key: `item-${++itemKeyCounter.current}`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return [emptyItem()];
|
|
||||||
});
|
|
||||||
const [sections, setSections] = useState<ScopeSection[]>(() => {
|
|
||||||
const draft = isEdit ? null : loadOfferDraft();
|
|
||||||
if (Array.isArray(draft?.sections) && draft.sections.length > 0) {
|
|
||||||
return draft.sections as ScopeSection[];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
const [orderInfo, setOrderInfo] = useState<OfferOrderInfo | null>(null);
|
const [orderInfo, setOrderInfo] = useState<OfferOrderInfo | null>(null);
|
||||||
const [offerStatus, setOfferStatus] = useState<string>("");
|
const [offerStatus, setOfferStatus] = useState<string>("");
|
||||||
|
|
||||||
@@ -808,31 +746,6 @@ export default function OfferDetail() {
|
|||||||
return () => window.removeEventListener("beforeunload", handler);
|
return () => window.removeEventListener("beforeunload", handler);
|
||||||
}, [isDirty]);
|
}, [isDirty]);
|
||||||
|
|
||||||
// Auto-save draft to localStorage (create mode only), record-scoped to the
|
|
||||||
// "new offer" form (recordId: null) via the shared useDocumentDraft hook.
|
|
||||||
const draftData = useMemo<OfferDraftData>(
|
|
||||||
() => ({
|
|
||||||
form: {
|
|
||||||
project_code: form.project_code ?? "",
|
|
||||||
customer_id: form.customer_id ?? null,
|
|
||||||
customer_name: form.customer_name ?? "",
|
|
||||||
created_at: form.created_at ?? "",
|
|
||||||
valid_until: form.valid_until ?? "",
|
|
||||||
currency: form.currency ?? "CZK",
|
|
||||||
language: form.language ?? "EN",
|
|
||||||
vat_rate: form.vat_rate ?? 21,
|
|
||||||
apply_vat: form.apply_vat ?? false,
|
|
||||||
},
|
|
||||||
items,
|
|
||||||
sections,
|
|
||||||
}),
|
|
||||||
[form, items, sections],
|
|
||||||
);
|
|
||||||
const { clear: clearOfferDraft } = useDocumentDraft<OfferDraftData>(
|
|
||||||
DRAFT_KEYS.offer,
|
|
||||||
{ recordId: null, enabled: !isEdit, data: draftData },
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateForm = (field: keyof OfferForm, value: unknown) => {
|
const updateForm = (field: keyof OfferForm, value: unknown) => {
|
||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||||
@@ -918,7 +831,6 @@ export default function OfferDetail() {
|
|||||||
result.message ||
|
result.message ||
|
||||||
(isEdit ? "Nabídka byla aktualizována" : "Nabídka byla vytvořena"),
|
(isEdit ? "Nabídka byla aktualizována" : "Nabídka byla vytvořena"),
|
||||||
);
|
);
|
||||||
if (!isEdit) clearOfferDraft();
|
|
||||||
if (!isEdit && result.data?.id) {
|
if (!isEdit && result.data?.id) {
|
||||||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
|||||||
@@ -20,12 +20,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||||
import { offerListOptions, offerCustomersOptions } from "../lib/queries/offers";
|
import { offerListOptions, offerCustomersOptions } from "../lib/queries/offers";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import {
|
|
||||||
readDraft,
|
|
||||||
clearDraft,
|
|
||||||
type DraftEnvelope,
|
|
||||||
} from "../hooks/useDocumentDraft";
|
|
||||||
import { DRAFT_KEYS } from "../lib/draftKeys";
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -38,7 +32,6 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
StatusChip,
|
StatusChip,
|
||||||
FileUpload,
|
FileUpload,
|
||||||
Alert,
|
|
||||||
EmptyState,
|
EmptyState,
|
||||||
FilterBar,
|
FilterBar,
|
||||||
Tabs,
|
Tabs,
|
||||||
@@ -48,19 +41,23 @@ import {
|
|||||||
type DataColumn,
|
type DataColumn,
|
||||||
type TabDef,
|
type TabDef,
|
||||||
} from "../ui";
|
} from "../ui";
|
||||||
import { OFFER_STATUS, statusLabel, statusColor } from "../lib/documentStatus";
|
import {
|
||||||
|
OFFER_STATUS,
|
||||||
|
statusLabel,
|
||||||
|
statusColor,
|
||||||
|
statusOptions,
|
||||||
|
} from "../lib/documentStatus";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
// Filter tabs cover only the real stored offer statuses (the `expired` entry
|
// Filter tabs derive from OFFER_STATUS so `draft` ("Koncept") flows in
|
||||||
// in OFFER_STATUS is a front-end-derived state, not a stored value).
|
// automatically; the leading "Všechny" tab is the all-statuses filter. The
|
||||||
const STATUS_FILTERS = [
|
// `expired` entry in OFFER_STATUS is a front-end-derived state (not a stored
|
||||||
{ value: "", label: "Vše" },
|
// value), so it is excluded from the filter tabs.
|
||||||
...(["active", "ordered", "invalidated"] as const).map((value) => ({
|
const STATUS_FILTERS = statusOptions(OFFER_STATUS, {
|
||||||
value,
|
value: "",
|
||||||
label: statusLabel(OFFER_STATUS, value),
|
label: "Všechny",
|
||||||
})),
|
}).filter((o) => o.value !== "expired");
|
||||||
];
|
|
||||||
|
|
||||||
interface Quotation {
|
interface Quotation {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -76,17 +73,6 @@ interface Quotation {
|
|||||||
order_status?: string;
|
order_status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DraftData {
|
|
||||||
form: {
|
|
||||||
project_code: string;
|
|
||||||
customer_name: string;
|
|
||||||
created_at: string;
|
|
||||||
valid_until: string;
|
|
||||||
currency: string;
|
|
||||||
};
|
|
||||||
items: unknown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const TemplatesIcon = (
|
const TemplatesIcon = (
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="20"
|
||||||
@@ -365,19 +351,6 @@ export default function Offers() {
|
|||||||
}>({ show: false, quotation: null });
|
}>({ show: false, quotation: null });
|
||||||
const [customerOrderNumber, setCustomerOrderNumber] = useState("");
|
const [customerOrderNumber, setCustomerOrderNumber] = useState("");
|
||||||
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
|
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
|
||||||
// Only the "new offer" draft (recordId === null) is surfaced here; a draft
|
|
||||||
// scoped to a specific record id is never shown on the list (no leakage).
|
|
||||||
const [draft, setDraft] = useState<DraftEnvelope<DraftData> | null>(() => {
|
|
||||||
const env = readDraft<DraftData>(DRAFT_KEYS.offer);
|
|
||||||
if (
|
|
||||||
env &&
|
|
||||||
env.recordId === null &&
|
|
||||||
env.data?.form &&
|
|
||||||
Array.isArray(env.data.items)
|
|
||||||
)
|
|
||||||
return env;
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const {
|
const {
|
||||||
@@ -440,11 +413,6 @@ export default function Offers() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const discardDraft = () => {
|
|
||||||
clearDraft(DRAFT_KEYS.offer);
|
|
||||||
setDraft(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!hasPermission("offers.view")) return <Forbidden />;
|
if (!hasPermission("offers.view")) return <Forbidden />;
|
||||||
|
|
||||||
const handleDuplicate = async (quotation: Quotation) => {
|
const handleDuplicate = async (quotation: Quotation) => {
|
||||||
@@ -873,81 +841,6 @@ export default function Offers() {
|
|||||||
</Box>
|
</Box>
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
{draft && !debouncedSearch && (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Alert severity="info" onClose={discardDraft}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Typography component="span" sx={{ fontWeight: 600 }}>
|
|
||||||
Rozpracovaný koncept
|
|
||||||
</Typography>
|
|
||||||
{draft.savedAt && (
|
|
||||||
<Typography
|
|
||||||
component="span"
|
|
||||||
sx={{ ml: 1, opacity: 0.8, fontSize: "0.875rem" }}
|
|
||||||
>
|
|
||||||
·{" "}
|
|
||||||
{new Date(draft.savedAt).toLocaleTimeString("cs-CZ", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{ color: "text.secondary", mt: 0.25 }}
|
|
||||||
>
|
|
||||||
{draft.data.form.project_code || "—"} ·{" "}
|
|
||||||
{draft.data.form.customer_name || "—"} ·{" "}
|
|
||||||
{draft.data.form.created_at
|
|
||||||
? formatDate(draft.data.form.created_at)
|
|
||||||
: "—"}
|
|
||||||
{" — "}
|
|
||||||
{draft.data.form.valid_until
|
|
||||||
? formatDate(draft.data.form.valid_until)
|
|
||||||
: "—"}
|
|
||||||
{draft.data.form.currency
|
|
||||||
? ` · ${draft.data.form.currency}`
|
|
||||||
: ""}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
gap: 1,
|
|
||||||
flexWrap: "wrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
component={RouterLink}
|
|
||||||
to="/offers/new"
|
|
||||||
size="small"
|
|
||||||
startIcon={EditIcon}
|
|
||||||
>
|
|
||||||
Pokračovat v konceptu
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
color="inherit"
|
|
||||||
onClick={discardDraft}
|
|
||||||
>
|
|
||||||
Zahodit koncept
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Alert>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||||
<DataTable<Quotation>
|
<DataTable<Quotation>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|||||||
Reference in New Issue
Block a user