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>
87 lines
3.0 KiB
TypeScript
87 lines
3.0 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import apiFetch from "../utils/api";
|
|
import { useAlert } from "../context/AlertContext";
|
|
|
|
/**
|
|
* Shared "Zobrazit PDF" handlers for the document pages (offers, issued
|
|
* orders) — both call their GET `…/:id/file` endpoint (archived NAS PDF with
|
|
* live-render fallback). Hardened flow extracted from OfferDetail:
|
|
* - pre-opens the tab synchronously (popup blockers only allow it inside the
|
|
* click's call stack), then streams the blob URL into it;
|
|
* - double-click guard via a ref (state updates are async, a ref is not);
|
|
* - 401 → silently close the tab (apiFetch already flagged the dead session);
|
|
* - blob URLs are revoked after 60 s; pending revoke timeouts are cleared on
|
|
* unmount so they can't fire into a dead page.
|
|
*/
|
|
|
|
/**
|
|
* List variant — ONE hook instance serves every row. `openPdf(id, url)`
|
|
* fetches that row's PDF; `pdfLoadingId` reports which row is in flight (use
|
|
* it for the per-row spinner + disabled state).
|
|
*/
|
|
export function useDocumentListPdf() {
|
|
const alert = useAlert();
|
|
const [pdfLoadingId, setPdfLoadingId] = useState<number | null>(null);
|
|
const loadingRef = useRef(false);
|
|
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
|
|
|
useEffect(() => {
|
|
// The array object is stable (we only push to it, never reassign), so
|
|
// capturing it here keeps the cleanup in sync with the ref's content.
|
|
const timeouts = blobTimeoutsRef.current;
|
|
return () => {
|
|
timeouts.forEach(clearTimeout);
|
|
};
|
|
}, []);
|
|
|
|
const openPdf = useCallback(
|
|
async (id: number, url: string) => {
|
|
if (loadingRef.current) return;
|
|
const newWindow = window.open("", "_blank");
|
|
loadingRef.current = true;
|
|
setPdfLoadingId(id);
|
|
try {
|
|
const response = await apiFetch(url);
|
|
if (response.status === 401) {
|
|
newWindow?.close();
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
newWindow?.close();
|
|
alert.error("Nepodařilo se vygenerovat PDF");
|
|
return;
|
|
}
|
|
const blob = await response.blob();
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
if (newWindow) newWindow.location.href = blobUrl;
|
|
const timeoutId = setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
|
|
blobTimeoutsRef.current.push(timeoutId);
|
|
} catch {
|
|
newWindow?.close();
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
loadingRef.current = false;
|
|
setPdfLoadingId(null);
|
|
}
|
|
},
|
|
[alert],
|
|
);
|
|
|
|
return { openPdf, pdfLoadingId };
|
|
}
|
|
|
|
/**
|
|
* Detail variant — a single fixed URL, `openPdf()` takes no arguments.
|
|
* Implemented on top of the list variant so the hardened flow lives once.
|
|
*/
|
|
export function useDocumentPdf(url: string | null) {
|
|
const { openPdf: openListPdf, pdfLoadingId } = useDocumentListPdf();
|
|
|
|
const openPdf = useCallback(async () => {
|
|
if (!url) return;
|
|
await openListPdf(0, url);
|
|
}, [url, openListPdf]);
|
|
|
|
return { openPdf, pdfLoading: pdfLoadingId !== null };
|
|
}
|