feat(documents)!: unify offers and issued orders end to end
One coherent pass over the two sibling document models, driven by a 3-lens
1:1 scan (~60 divergences inventoried) + user decisions. Migration adds
issued_orders.locked_by/locked_at and the issued_order_sections table
(mirror of scope_sections; applied to dev + test DBs).
Issued orders gained (offers as reference implementation):
- rich-text SECTIONS with CZ/EN titles, rendered on their own PDF page in
the PO template's red style (shared DocumentSectionSchema, one-transaction
create/update incl. items - fixes a torn-write bug)
- edit LOCKING (lock/heartbeat/unlock routes, 423 + holder name, 30s TTL =
3 missed 10s heartbeats; locked_by enrichment on detail)
- archived-PDF serving: GET /:id/file reads the NAS copy (new
readIssuedByNumber sweep) with live-render fallback + re-archive; offers'
/file got the same fallback (kills the 'ulozte nabidku' dead end)
- NAS cleanup on delete, in-tx po_number uniqueness (409), collision-advancing
number previews (also invoice previews), PUT returns assigned po_number
Offers hardened (issued as reference):
- VALID_TRANSITIONS enforced (no more numberless 'ordered' offers; invalidate
follows the table; order creation only from active offers) +
valid_transitions on detail
- explicit 400 instead of silent edits outside draft/active (mirrored on
issued: explicit 400 replaced silent drops)
- customer existence check + Number(null)->0 clear bug fixed; delete of a
linked offer -> 409 instead of P2003 500; error-token convention; Zod caps
DB-aligned (desc 500/unit 20/number 50/project_code 100, isoDateString
dates, ints for positions); audit old/new values + koncept fallbacks;
list id tiebreaks; stats include trimmed; NAS delete dedupe
Frontend unification (6 new shared modules):
- components/document/{DocumentItemsEditor,SectionsEditor,LockBanner}
- hooks/{useDocumentLock,useUnsavedChangesGuard,useDocumentPdf(+list variant)}
- OfferDetail 1940->1100 lines, IssuedOrderDetail 1400->980: headline-only
document number (form field removed), one form layout/readonly convention,
view-permission opens read-only everywhere (issued's editable-for-viewers
hole closed), server-driven transition buttons, dirty guard + Enter submit
on both, useApiMutation everywhere (new opt-in envelope mode), fixed
infinite spinner on failed detail fetch, draft PDFs hidden (no number yet)
- lists: supplier filter + count line on issued, Mena column dropped + mono
numbers on offers, shared hardened per-row PDF flow (spinner, double-click
guard, 401 close, blob cleanup), proper Czech quotes, real CTA empty
states, query-lib cleanups (["offers","customers"] key, typed list rows,
shared CurrencyAmount, retry:false on details)
- pdf-shared.ts: one escapeHtml/cleanQuillHtml(strict)/formatNum(NBSP)/
formatCurrency/formatDate for all four PDF routes; offer PDF keeps its
monochrome look (fractional qty fix: 1.5 no longer prints as 2); issued
PDF language now comes from the document column
+49 tests (suite 364 -> 413). Each stage passed an independent review; final
cross-stage integration review verified the FE<->BE contracts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
81
src/admin/hooks/useDocumentLock.ts
Normal file
81
src/admin/hooks/useDocumentLock.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import apiFetch from "../utils/api";
|
||||
|
||||
/** Lock holder as enriched by the detail endpoints (offers, issued orders). */
|
||||
export interface DocumentLockInfo {
|
||||
user_id: number;
|
||||
username: string;
|
||||
full_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client side of the document edit lock shared by the offer / issued-order
|
||||
* detail pages (extracted from OfferDetail). The server exposes the same
|
||||
* trio under the entity base URL: POST `:base/lock`, `:base/heartbeat`,
|
||||
* `:base/unlock` (lock expires after 30 s without a heartbeat — 3 missed
|
||||
* 10 s beats, so background-tab interval throttling can't hand the lock away).
|
||||
*
|
||||
* Responsibilities:
|
||||
* - `lockedBy` state — the page hydrates it from the detail response via
|
||||
* `setLockedBy(detail.locked_by ?? null)`.
|
||||
* - `acquire()` — fire-and-forget lock acquisition; the page calls it from
|
||||
* its one-shot hydration effect when the doc is editable, the user holds
|
||||
* the edit permission and nobody else holds the lock.
|
||||
* - 10 s heartbeat while `enabled` (and not locked by another user), with
|
||||
* hard-401 self-stop: a 401 here means the access token expired AND the
|
||||
* refresh failed (apiFetch retries recoverable 401s itself), so we stop
|
||||
* the interval instead of spamming a dead session every 10 s.
|
||||
* - unlock on unmount/disable, with an AbortController so a stale in-flight
|
||||
* unlock can be cancelled by the next one.
|
||||
*/
|
||||
export function useDocumentLock({
|
||||
baseUrl,
|
||||
enabled,
|
||||
}: {
|
||||
/** Entity endpoint base (e.g. `/api/admin/offers/42`); null in create mode. */
|
||||
baseUrl: string | null;
|
||||
/**
|
||||
* External conditions for holding the lock: edit mode + editable status +
|
||||
* edit permission. The hook adds `!isLockedByOther` itself.
|
||||
*/
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const [lockedBy, setLockedBy] = useState<DocumentLockInfo | null>(null);
|
||||
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const unlockAbortRef = useRef<AbortController | null>(null);
|
||||
const isLockedByOther = lockedBy !== null;
|
||||
|
||||
const acquire = useCallback(() => {
|
||||
if (!baseUrl) return;
|
||||
apiFetch(`${baseUrl}/lock`, { method: "POST" }).catch(() => {});
|
||||
}, [baseUrl]);
|
||||
|
||||
// Heartbeat to keep the lock alive + release on unmount/disable.
|
||||
useEffect(() => {
|
||||
if (!baseUrl || !enabled || isLockedByOther) return;
|
||||
|
||||
heartbeatRef.current = setInterval(() => {
|
||||
apiFetch(`${baseUrl}/heartbeat`, { method: "POST" })
|
||||
.then((res) => {
|
||||
if (res.status === 401 && heartbeatRef.current) {
|
||||
clearInterval(heartbeatRef.current);
|
||||
heartbeatRef.current = null;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 10 * 1000); // every 10 seconds
|
||||
|
||||
return () => {
|
||||
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
||||
if (unlockAbortRef.current) unlockAbortRef.current.abort();
|
||||
const controller = new AbortController();
|
||||
unlockAbortRef.current = controller;
|
||||
apiFetch(`${baseUrl}/unlock`, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
}).catch(() => {});
|
||||
};
|
||||
}, [baseUrl, enabled, isLockedByOther]);
|
||||
|
||||
return { lockedBy, isLockedByOther, setLockedBy, acquire };
|
||||
}
|
||||
Reference in New Issue
Block a user