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:
BOHA
2026-06-09 18:25:24 +02:00
parent 473f7c4a8c
commit ce636215dc
7 changed files with 102 additions and 580 deletions

View File

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