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:
@@ -23,10 +23,12 @@ import useTableSort from "../hooks/useTableSort";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import { useDocumentListPdf } from "../hooks/useDocumentPdf";
|
||||
import {
|
||||
offerListOptions,
|
||||
offerStatsOptions,
|
||||
offerCustomersOptions,
|
||||
type Quotation,
|
||||
} from "../lib/queries/offers";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import {
|
||||
@@ -69,20 +71,6 @@ const STATUS_FILTERS = statusOptions(OFFER_STATUS, {
|
||||
label: "Všechny",
|
||||
}).filter((o) => o.value !== "expired");
|
||||
|
||||
interface Quotation {
|
||||
id: number;
|
||||
quotation_number: string;
|
||||
project_code: string;
|
||||
customer_name: string;
|
||||
created_at: string;
|
||||
valid_until: string;
|
||||
currency: string;
|
||||
total: number;
|
||||
status: string;
|
||||
order_id?: number;
|
||||
order_status?: string;
|
||||
}
|
||||
|
||||
const TemplatesIcon = (
|
||||
<svg
|
||||
width="20"
|
||||
@@ -336,25 +324,17 @@ export default function Offers() {
|
||||
show: boolean;
|
||||
quotation: Quotation | null;
|
||||
}>({ show: false, quotation: null });
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [invalidateConfirm, setInvalidateConfirm] = useState<{
|
||||
show: boolean;
|
||||
quotation: Quotation | null;
|
||||
}>({ show: false, quotation: null });
|
||||
const [invalidating, setInvalidating] = useState(false);
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const [duplicating, setDuplicating] = useState<number | null>(null);
|
||||
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
|
||||
const [creatingOrder, setCreatingOrder] = useState<number | null>(null);
|
||||
|
||||
// Shared hardened PDF flow (per-row spinner, double-click guard, 401 silent
|
||||
// close, blob-URL revoke + unmount cleanup) — same hook as the issued list.
|
||||
const { openPdf, pdfLoadingId } = useDocumentListPdf();
|
||||
const [orderModal, setOrderModal] = useState<{
|
||||
show: boolean;
|
||||
quotation: Quotation | null;
|
||||
@@ -490,13 +470,10 @@ export default function Offers() {
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm.quotation) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteOfferMutation.mutateAsync(deleteConfirm.quotation.id);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -512,37 +489,6 @@ export default function Offers() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePdf = async (quotation: Quotation) => {
|
||||
if (pdfLoading) return;
|
||||
const newWindow = window.open("", "_blank");
|
||||
setPdfLoading(quotation.id);
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/offers/${quotation.id}/file`,
|
||||
);
|
||||
if (response.status === 401) {
|
||||
newWindow?.close();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
newWindow?.close();
|
||||
alert.error("PDF soubor nenalezen — otevřete nabídku a uložte ji");
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
}
|
||||
blobUrlRef.current = URL.createObjectURL(blob);
|
||||
if (newWindow) newWindow.location.href = blobUrlRef.current;
|
||||
} catch {
|
||||
newWindow?.close();
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setPdfLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Only show the full-page skeleton on the very first load; on subsequent
|
||||
// refetches (filter/customer/tab/page change) keep the table visible (the
|
||||
// Card dims via isFetching) so it doesn't flash.
|
||||
@@ -573,6 +519,7 @@ export default function Offers() {
|
||||
header: "Číslo",
|
||||
width: "13%",
|
||||
sortKey: "quotation_number",
|
||||
mono: true,
|
||||
render: (q) => (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
@@ -597,7 +544,7 @@ export default function Offers() {
|
||||
{
|
||||
key: "customer_name",
|
||||
header: "Zákazník",
|
||||
width: "13%",
|
||||
width: "16%",
|
||||
render: (q) => q.customer_name || "—",
|
||||
},
|
||||
{
|
||||
@@ -637,17 +584,10 @@ export default function Offers() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "currency",
|
||||
header: "Měna",
|
||||
width: "7%",
|
||||
sortKey: "currency",
|
||||
render: (q) => <StatusChip label={q.currency} color="default" />,
|
||||
},
|
||||
{
|
||||
key: "total",
|
||||
header: "Celkem",
|
||||
width: "11%",
|
||||
width: "12%",
|
||||
align: "right",
|
||||
mono: true,
|
||||
bold: true,
|
||||
@@ -693,7 +633,9 @@ export default function Offers() {
|
||||
{OrderViewIcon}
|
||||
</IconButton>
|
||||
) : (
|
||||
!readOnly &&
|
||||
// Only active offers can spawn an order — the server transition
|
||||
// guard rejects drafts/invalidated with a 400.
|
||||
q.status === "active" &&
|
||||
hasPermission("orders.create") && (
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -714,15 +656,20 @@ export default function Offers() {
|
||||
</IconButton>
|
||||
)
|
||||
)}
|
||||
{hasPermission("offers.view") && (
|
||||
{/* Drafts have no number yet — /file 404s for them. */}
|
||||
{hasPermission("offers.view") && q.quotation_number && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handlePdf(q)}
|
||||
onClick={() => openPdf(q.id, `${API_BASE}/offers/${q.id}/file`)}
|
||||
aria-label="Zobrazit nabídku"
|
||||
title="Zobrazit nabídku"
|
||||
disabled={pdfLoading === q.id}
|
||||
disabled={pdfLoadingId === q.id}
|
||||
>
|
||||
{pdfLoading === q.id ? <CircularProgress size={16} /> : PdfIcon}
|
||||
{pdfLoadingId === q.id ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
PdfIcon
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
{hasPermission("offers.delete") && (
|
||||
@@ -927,10 +874,10 @@ export default function Offers() {
|
||||
onClose={() => setDeleteConfirm({ show: false, quotation: null })}
|
||||
onConfirm={handleDelete}
|
||||
title="Smazat nabídku"
|
||||
message={`Opravdu chcete smazat nabídku "${deleteConfirm.quotation?.quotation_number}"? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`}
|
||||
message={`Opravdu chcete smazat nabídku „${documentNumberLabel(deleteConfirm.quotation?.quotation_number)}“? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`}
|
||||
confirmText="Smazat"
|
||||
confirmVariant="danger"
|
||||
loading={deleting}
|
||||
loading={deleteOfferMutation.isPending}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -938,7 +885,7 @@ export default function Offers() {
|
||||
onClose={() => setInvalidateConfirm({ show: false, quotation: null })}
|
||||
onConfirm={handleInvalidate}
|
||||
title="Zneplatnit nabídku"
|
||||
message={`Opravdu chcete zneplatnit nabídku "${invalidateConfirm.quotation?.quotation_number}"? Nabídka bude pouze pro čtení a nepůjde upravovat.`}
|
||||
message={`Opravdu chcete zneplatnit nabídku „${documentNumberLabel(invalidateConfirm.quotation?.quotation_number)}“? Nabídka bude pouze pro čtení a nepůjde upravovat.`}
|
||||
confirmText="Zneplatnit"
|
||||
confirmVariant="danger"
|
||||
loading={invalidating}
|
||||
|
||||
Reference in New Issue
Block a user