diff --git a/src/admin/hooks/useDocumentDraft.ts b/src/admin/hooks/useDocumentDraft.ts new file mode 100644 index 0000000..0ddd793 --- /dev/null +++ b/src/admin/hooks/useDocumentDraft.ts @@ -0,0 +1,105 @@ +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 { + 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(key: string): DraftEnvelope | null { + try { + if (typeof localStorage === "undefined") return null; + const raw = localStorage.getItem(key); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial>; + 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 { + /** `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( + key: string, + { recordId, enabled, data, debounceMs = 1500 }: UseDocumentDraftOptions, +): { 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 = { + 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 }; +} diff --git a/src/admin/lib/draftKeys.ts b/src/admin/lib/draftKeys.ts new file mode 100644 index 0000000..9b8c24e --- /dev/null +++ b/src/admin/lib/draftKeys.ts @@ -0,0 +1,12 @@ +/** + * 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]; diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx index a85b6ae..7355f5c 100644 --- a/src/admin/pages/InvoiceDetail.tsx +++ b/src/admin/pages/InvoiceDetail.tsx @@ -45,6 +45,8 @@ import { } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import apiFetch from "../utils/api"; +import useDocumentDraft from "../hooks/useDocumentDraft"; +import { DRAFT_KEYS } from "../lib/draftKeys"; import { companySettingsOptions } from "../lib/queries/settings"; import { invoiceDetailOptions } from "../lib/queries/invoices"; import { offerCustomersOptions, type Customer } from "../lib/queries/offers"; @@ -584,15 +586,15 @@ export default function InvoiceDetail() { label: `${v}%`, })); - const DRAFT_KEY = "boha_invoice_draft"; - - const clearDraft = useCallback(() => { - try { - localStorage.removeItem(DRAFT_KEY); - } catch { - /* ignore */ - } - }, []); + // 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 ─── diff --git a/src/admin/pages/Invoices.tsx b/src/admin/pages/Invoices.tsx index 8f5eb64..8349f1f 100644 --- a/src/admin/pages/Invoices.tsx +++ b/src/admin/pages/Invoices.tsx @@ -25,6 +25,12 @@ import { type Invoice, type CurrencyAmount, } from "../lib/queries/invoices"; +import { + readDraft, + clearDraft, + type DraftEnvelope, +} from "../hooks/useDocumentDraft"; +import { DRAFT_KEYS } from "../lib/draftKeys"; import { Button, Card, @@ -53,7 +59,6 @@ import { const ReceivedInvoices = lazy(() => import("./ReceivedInvoices")); const API_BASE = "/api/admin"; -const DRAFT_KEY = "boha_invoice_draft"; const MONTH_NAMES = [ "leden", @@ -101,7 +106,6 @@ const STATUS_FILTERS = [ interface DraftData { form: Record; items: Record[]; - savedAt?: string; } const PlusIcon = ( @@ -271,24 +275,22 @@ export default function Invoices() { }>({ show: false, invoice: null }); const [deleting, setDeleting] = useState(false); const [pdfLoading, setPdfLoading] = useState(null); - const [draft, setDraft] = useState(() => { - try { - const raw = localStorage.getItem(DRAFT_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw) as DraftData; - if (parsed && parsed.form && Array.isArray(parsed.items)) return parsed; - } catch { - /* ignore */ - } + // 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 | null>(() => { + const env = readDraft(DRAFT_KEYS.invoice); + if ( + env && + env.recordId === null && + env.data?.form && + Array.isArray(env.data.items) + ) + return env; return null; }); const discardDraft = () => { - try { - localStorage.removeItem(DRAFT_KEY); - } catch { - /* ignore */ - } + clearDraft(DRAFT_KEYS.invoice); setDraft(null); }; @@ -781,13 +783,13 @@ export default function Invoices() { variant="body2" sx={{ color: "text.secondary", mt: 0.25 }} > - {(draft.form.customer_name as string) || "—"} ·{" "} - {draft.form.issue_date - ? formatDate(draft.form.issue_date as string) + {(draft.data.form.customer_name as string) || "—"} ·{" "} + {draft.data.form.issue_date + ? formatDate(draft.data.form.issue_date as string) : "—"} {" — "} - {draft.form.due_date - ? formatDate(draft.form.due_date as string) + {draft.data.form.due_date + ? formatDate(draft.data.form.due_date as string) : "—"} diff --git a/src/admin/pages/OfferDetail.tsx b/src/admin/pages/OfferDetail.tsx index 20aebec..0e393ae 100644 --- a/src/admin/pages/OfferDetail.tsx +++ b/src/admin/pages/OfferDetail.tsx @@ -57,7 +57,8 @@ import { import { CSS } from "@dnd-kit/utilities"; import Forbidden from "../components/Forbidden"; import RichEditor from "../components/RichEditor"; -import useDebounce from "../hooks/useDebounce"; +import useDocumentDraft, { readDraft } from "../hooks/useDocumentDraft"; +import { DRAFT_KEYS } from "../lib/draftKeys"; import apiFetch from "../utils/api"; import { formatCurrency, todayLocalStr } from "../utils/formatters"; import { companySettingsOptions } from "../lib/queries/settings"; @@ -79,7 +80,6 @@ import { import { OFFER_STATUS, statusLabel, statusColor } from "../lib/documentStatus"; const API_BASE = "/api/admin"; -const DRAFT_KEY = "boha_offer_draft"; interface OfferItem { _key: string; @@ -489,18 +489,22 @@ function SortableItemRow({ ); } -function loadOfferDraft(): { +interface OfferDraftData { form?: Record; items?: unknown[]; sections?: unknown[]; -} | null { - try { - const raw = localStorage.getItem(DRAFT_KEY); - return raw ? JSON.parse(raw) : null; - } catch (e) { - console.error("Failed to load offer draft:", e); - return null; - } +} + +/** + * 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(DRAFT_KEYS.offer); + if (!env || env.recordId !== null) return null; + return env.data ?? null; } export default function OfferDetail() { @@ -803,34 +807,30 @@ export default function OfferDetail() { } }, [showCustomerDropdown]); - // Auto-save draft to localStorage (create mode only) - const draftPayload = JSON.stringify({ form, items, sections }); - const debouncedDraft = useDebounce(draftPayload, 1500); - useEffect(() => { - if (isEdit) return; - try { - const data = JSON.parse(debouncedDraft); - const draft = { - form: { - project_code: data.form.project_code ?? "", - customer_id: data.form.customer_id ?? null, - customer_name: data.form.customer_name ?? "", - created_at: data.form.created_at ?? "", - valid_until: data.form.valid_until ?? "", - currency: data.form.currency ?? "CZK", - language: data.form.language ?? "EN", - vat_rate: data.form.vat_rate ?? 21, - apply_vat: data.form.apply_vat ?? false, - }, - items: data.items, - sections: data.sections, - savedAt: new Date().toISOString(), - }; - localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); - } catch (e) { - console.error("Failed to save offer draft:", e); - } - }, [debouncedDraft]); // eslint-disable-line react-hooks/exhaustive-deps + // 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( + () => ({ + 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( + DRAFT_KEYS.offer, + { recordId: null, enabled: !isEdit, data: draftData }, + ); const updateForm = (field: keyof OfferForm, value: unknown) => { setForm((prev) => ({ ...prev, [field]: value })); @@ -917,13 +917,7 @@ export default function OfferDetail() { result.message || (isEdit ? "Nabídka byla aktualizována" : "Nabídka byla vytvořena"), ); - if (!isEdit) { - try { - localStorage.removeItem(DRAFT_KEY); - } catch (e) { - console.error("Failed to remove offer draft:", e); - } - } + if (!isEdit) clearOfferDraft(); if (!isEdit && result.data?.id) { queryClient.invalidateQueries({ queryKey: ["offers"] }); queryClient.invalidateQueries({ queryKey: ["orders"] }); diff --git a/src/admin/pages/Offers.tsx b/src/admin/pages/Offers.tsx index e39b07c..44b939c 100644 --- a/src/admin/pages/Offers.tsx +++ b/src/admin/pages/Offers.tsx @@ -16,6 +16,12 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { offerListOptions, offerCustomersOptions } from "../lib/queries/offers"; import { useApiMutation } from "../lib/queries/mutations"; +import { + readDraft, + clearDraft, + type DraftEnvelope, +} from "../hooks/useDocumentDraft"; +import { DRAFT_KEYS } from "../lib/draftKeys"; import { Button, Card, @@ -41,7 +47,6 @@ import { import { OFFER_STATUS, statusLabel, statusColor } from "../lib/documentStatus"; const API_BASE = "/api/admin"; -const DRAFT_KEY = "boha_offer_draft"; // Filter tabs cover only the real stored offer statuses (the `expired` entry // in OFFER_STATUS is a front-end-derived state, not a stored value). @@ -67,7 +72,7 @@ interface Quotation { order_status?: string; } -interface Draft { +interface DraftData { form: { project_code: string; customer_name: string; @@ -76,7 +81,6 @@ interface Draft { currency: string; }; items: unknown[]; - savedAt?: string; } const TemplatesIcon = ( @@ -268,15 +272,17 @@ export default function Offers() { }>({ show: false, quotation: null }); const [customerOrderNumber, setCustomerOrderNumber] = useState(""); const [orderAttachment, setOrderAttachment] = useState(null); - const [draft, setDraft] = useState(() => { - try { - const raw = localStorage.getItem(DRAFT_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw); - if (parsed && parsed.form && Array.isArray(parsed.items)) return parsed; - } catch { - /* ignore corrupt data */ - } + // 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 | null>(() => { + const env = readDraft(DRAFT_KEYS.offer); + if ( + env && + env.recordId === null && + env.data?.form && + Array.isArray(env.data.items) + ) + return env; return null; }); @@ -336,11 +342,7 @@ export default function Offers() { }); const discardDraft = () => { - try { - localStorage.removeItem(DRAFT_KEY); - } catch { - /* ignore */ - } + clearDraft(DRAFT_KEYS.offer); setDraft(null); }; @@ -812,16 +814,18 @@ export default function Offers() { variant="body2" sx={{ color: "text.secondary", mt: 0.25 }} > - {draft.form.project_code || "—"} ·{" "} - {draft.form.customer_name || "—"} ·{" "} - {draft.form.created_at - ? formatDate(draft.form.created_at) + {draft.data.form.project_code || "—"} ·{" "} + {draft.data.form.customer_name || "—"} ·{" "} + {draft.data.form.created_at + ? formatDate(draft.data.form.created_at) : "—"} {" — "} - {draft.form.valid_until - ? formatDate(draft.form.valid_until) + {draft.data.form.valid_until + ? formatDate(draft.data.form.valid_until) : "—"} - {draft.form.currency ? ` · ${draft.form.currency}` : ""} + {draft.data.form.currency + ? ` · ${draft.data.form.currency}` + : ""}