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:
BOHA
2026-06-09 16:24:22 +02:00
parent b4f1b6ce2b
commit c4668357aa
6 changed files with 219 additions and 100 deletions

View File

@@ -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 ───

View File

@@ -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>

View File

@@ -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"] });

View File

@@ -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