feat(ui): shared record-scoped useDocumentDraft hook; fix half-wired invoice draft banner; dedupe draft keys
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
105
src/admin/hooks/useDocumentDraft.ts
Normal file
105
src/admin/hooks/useDocumentDraft.ts
Normal file
@@ -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<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 };
|
||||
}
|
||||
12
src/admin/lib/draftKeys.ts
Normal file
12
src/admin/lib/draftKeys.ts
Normal file
@@ -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];
|
||||
@@ -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 ───
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
items: Record<string, unknown>[];
|
||||
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<number | null>(null);
|
||||
const [draft, setDraft] = useState<DraftData | null>(() => {
|
||||
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<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 = () => {
|
||||
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)
|
||||
: "—"}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<OfferDraftData>(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<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) => {
|
||||
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"] });
|
||||
|
||||
@@ -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<File | null>(null);
|
||||
const [draft, setDraft] = useState<Draft | null>(() => {
|
||||
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<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;
|
||||
});
|
||||
|
||||
@@ -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}`
|
||||
: ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
|
||||
Reference in New Issue
Block a user