diff --git a/src/admin/pages/OfferDetail.tsx b/src/admin/pages/OfferDetail.tsx index 986a6ad..96fc44a 100644 --- a/src/admin/pages/OfferDetail.tsx +++ b/src/admin/pages/OfferDetail.tsx @@ -543,7 +543,9 @@ export default function OfferDetail() { const [saving, setSaving] = useState(false); const [errors, setErrors] = useState>({}); const [form, setForm] = useState(() => { - const draft = loadOfferDraft(); + // The draft is the unsaved-NEW-offer buffer — never restore it over an + // existing offer in edit mode (it would also leak its stale item keys). + const draft = isEdit ? null : loadOfferDraft(); if (draft?.form) { return { quotation_number: @@ -566,14 +568,20 @@ export default function OfferDetail() { return emptyForm; }); const [items, setItems] = useState(() => { - const draft = loadOfferDraft(); + const draft = isEdit ? null : loadOfferDraft(); if (Array.isArray(draft?.items) && draft.items.length > 0) { - return draft.items as OfferItem[]; + // Re-key restored items: their persisted _key values are stale and the + // counter restarts at 0 each load, so reusing them would collide with + // newly-added rows (duplicate React key / dnd-kit id → the warning). + return (draft.items as OfferItem[]).map((it) => ({ + ...it, + _key: `item-${++itemKeyCounter.current}`, + })); } return [emptyItem()]; }); const [sections, setSections] = useState(() => { - const draft = loadOfferDraft(); + const draft = isEdit ? null : loadOfferDraft(); if (Array.isArray(draft?.sections) && draft.sections.length > 0) { return draft.sections as ScopeSection[]; } @@ -723,9 +731,19 @@ export default function OfferDetail() { return; heartbeatRef.current = setInterval(() => { - apiFetch(`${API_BASE}/offers/${id}/heartbeat`, { method: "POST" }).catch( - () => {}, - ); + apiFetch(`${API_BASE}/offers/${id}/heartbeat`, { method: "POST" }) + .then((res) => { + // 401 here means the access token expired AND the refresh failed + // (session truly gone — apiFetch retries with a refreshed token on a + // recoverable 401 and would return 200). Stop the interval so we don't + // spam a 401 every 10s while the user sits idle on a dead session; + // apiFetch has already flagged session-expired for the next action. + if (res.status === 401 && heartbeatRef.current) { + clearInterval(heartbeatRef.current); + heartbeatRef.current = null; + } + }) + .catch(() => {}); }, 10 * 1000); // every 10 seconds return () => { diff --git a/src/admin/ui/PageEnter.tsx b/src/admin/ui/PageEnter.tsx index 4857ab5..13824ea 100644 --- a/src/admin/ui/PageEnter.tsx +++ b/src/admin/ui/PageEnter.tsx @@ -3,7 +3,7 @@ import { motion, useReducedMotion, type Variants } from "framer-motion"; import Box from "@mui/material/Box"; import type { SxProps, Theme } from "@mui/material/styles"; -const MotionBox = motion(Box); +const MotionBox = motion.create(Box); const container: Variants = { hidden: {},