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:
BOHA
2026-06-10 16:22:39 +02:00
parent 1c1b9dca17
commit d1533ffc4d
35 changed files with 4762 additions and 2835 deletions

View File

@@ -1,5 +1,11 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
// Single shared definition (type-only import, erased at compile time) — the
// canonical CurrencyAmount lives in offers.ts; re-exported here so existing
// importers keep working.
import type { CurrencyAmount } from "./offers";
export type { CurrencyAmount };
export interface IssuedOrder {
id: number;
@@ -34,6 +40,15 @@ export interface IssuedOrderItem {
position?: number;
}
/** Rich-text CZ/EN PDF section (mirrors offers' scope sections). */
export interface IssuedOrderSection {
id?: number;
title: string | null;
title_cz: string | null;
content: string | null;
position?: number;
}
export interface IssuedOrderDetail extends IssuedOrder {
exchange_rate: number | string | null;
delivery_date: string | null;
@@ -45,8 +60,15 @@ export interface IssuedOrderDetail extends IssuedOrder {
notes: string | null;
internal_notes: string | null;
items: IssuedOrderItem[];
sections: IssuedOrderSection[];
supplier: Record<string, unknown> | null;
valid_transitions: string[];
/** Fresh edit lock held by ANOTHER user (same shape as offers), else null. */
locked_by: {
user_id: number;
username: string;
full_name: string;
} | null;
}
// Suppliers lookup for the PO supplier picker — hits the issued-orders-scoped
@@ -65,6 +87,7 @@ export const issuedOrderListOptions = (filters: {
page?: number;
perPage?: number;
status?: string;
supplier_id?: number;
month?: number;
year?: number;
}) =>
@@ -78,6 +101,8 @@ export const issuedOrderListOptions = (filters: {
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status);
if (filters.supplier_id)
params.set("supplier_id", String(filters.supplier_id));
if (filters.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year));
const qs = params.toString();
@@ -87,14 +112,10 @@ export const issuedOrderListOptions = (filters: {
},
});
export interface CurrencyAmount {
amount: number;
currency: string;
}
export const issuedOrderStatsOptions = (filters: {
search?: string;
status?: string;
supplier_id?: number;
month?: number;
year?: number;
}) =>
@@ -104,6 +125,8 @@ export const issuedOrderStatsOptions = (filters: {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.status) params.set("status", filters.status);
if (filters.supplier_id)
params.set("supplier_id", String(filters.supplier_id));
if (filters.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year));
const qs = params.toString();
@@ -120,4 +143,16 @@ export const issuedOrderDetailOptions = (id: string | undefined) =>
queryFn: () =>
jsonQuery<IssuedOrderDetail>(`/api/admin/issued-orders/${id}`),
enabled: !!id,
// 404s (deleted/missing orders) are not transient. Retrying just spams
// GETs and keeps the detail page on an infinite spinner.
retry: false,
});
export const issuedOrderNextNumberOptions = () =>
queryOptions({
queryKey: ["issued-orders", "next-number"],
queryFn: () =>
jsonQuery<{ next_number?: string; number?: string }>(
"/api/admin/issued-orders/next-number",
).then((d) => d?.next_number || d?.number || ""),
});