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:
@@ -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"] });
|
||||
|
||||
Reference in New Issue
Block a user