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>
206 lines
5.2 KiB
TypeScript
206 lines
5.2 KiB
TypeScript
import { queryOptions } from "@tanstack/react-query";
|
|
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
|
|
|
export interface CurrencyAmount {
|
|
amount: number;
|
|
currency: string;
|
|
}
|
|
|
|
export interface ItemTemplate {
|
|
id: number;
|
|
name: string;
|
|
description: string;
|
|
default_price: number;
|
|
category: string;
|
|
}
|
|
|
|
export interface ScopeSection {
|
|
_key: string;
|
|
title: string;
|
|
title_cz: string;
|
|
content: string;
|
|
}
|
|
|
|
export interface ScopeTemplate {
|
|
id: number;
|
|
name: string;
|
|
title?: string;
|
|
description?: string;
|
|
scope_template_sections?: ScopeSection[];
|
|
}
|
|
|
|
export interface CustomField {
|
|
name: string;
|
|
value: string;
|
|
showLabel: boolean;
|
|
_key?: string;
|
|
}
|
|
|
|
export interface Customer {
|
|
id: number;
|
|
name: string;
|
|
street?: string;
|
|
city?: string;
|
|
postal_code?: string;
|
|
country?: string;
|
|
company_id?: string;
|
|
vat_id?: string;
|
|
quotation_count: number;
|
|
custom_fields?: CustomField[];
|
|
customer_field_order?: string[];
|
|
}
|
|
|
|
export const offerCustomersOptions = () =>
|
|
queryOptions({
|
|
// Under the broad ["offers"] domain so offer-domain invalidations
|
|
// (customer CRUD, offer mutations) refresh the picker automatically.
|
|
queryKey: ["offers", "customers"],
|
|
queryFn: () => jsonQuery<Customer[]>("/api/admin/customers"),
|
|
staleTime: 2 * 60_000,
|
|
});
|
|
|
|
export const itemTemplatesOptions = () =>
|
|
queryOptions({
|
|
queryKey: ["offer-templates", "items"],
|
|
queryFn: () =>
|
|
jsonQuery<ItemTemplate[]>("/api/admin/offers-templates?action=items"),
|
|
});
|
|
|
|
export const scopeTemplatesOptions = () =>
|
|
queryOptions({
|
|
queryKey: ["offer-templates", "scopes"],
|
|
queryFn: () => jsonQuery<ScopeTemplate[]>("/api/admin/offers-templates"),
|
|
});
|
|
|
|
/** Offer list row (the shape returned by GET /api/admin/offers). */
|
|
export 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;
|
|
}
|
|
|
|
export const offerListOptions = (filters: {
|
|
search?: string;
|
|
sort?: string;
|
|
order?: string;
|
|
page?: number;
|
|
perPage?: number;
|
|
status?: string;
|
|
customer_id?: number;
|
|
}) =>
|
|
queryOptions({
|
|
queryKey: ["offers", "list", filters],
|
|
queryFn: () => {
|
|
const params = new URLSearchParams();
|
|
if (filters.search) params.set("search", filters.search);
|
|
if (filters.sort) params.set("sort", filters.sort);
|
|
if (filters.order) params.set("order", filters.order);
|
|
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.customer_id)
|
|
params.set("customer_id", String(filters.customer_id));
|
|
const qs = params.toString();
|
|
return paginatedJsonQuery<Quotation>(
|
|
`/api/admin/offers${qs ? `?${qs}` : ""}`,
|
|
);
|
|
},
|
|
});
|
|
|
|
export const offerStatsOptions = (filters: {
|
|
search?: string;
|
|
status?: string;
|
|
customer_id?: number;
|
|
}) =>
|
|
queryOptions({
|
|
queryKey: ["offers", "stats", filters],
|
|
queryFn: async () => {
|
|
const params = new URLSearchParams();
|
|
if (filters.search) params.set("search", filters.search);
|
|
if (filters.status) params.set("status", filters.status);
|
|
if (filters.customer_id)
|
|
params.set("customer_id", String(filters.customer_id));
|
|
const qs = params.toString();
|
|
const data = await jsonQuery<{ totals?: CurrencyAmount[] }>(
|
|
`/api/admin/offers/stats${qs ? `?${qs}` : ""}`,
|
|
);
|
|
return data.totals ?? [];
|
|
},
|
|
});
|
|
|
|
export interface OfferItemData {
|
|
id?: number;
|
|
description: string;
|
|
item_description: string;
|
|
quantity: number;
|
|
unit: string;
|
|
unit_price: number;
|
|
is_included_in_total: boolean;
|
|
position?: number;
|
|
}
|
|
|
|
export interface OfferSectionData {
|
|
title: string;
|
|
title_cz: string;
|
|
content: string;
|
|
position?: number;
|
|
}
|
|
|
|
export interface OfferLockInfo {
|
|
user_id: number;
|
|
username: string;
|
|
full_name: string;
|
|
}
|
|
|
|
export interface OfferOrderInfo {
|
|
id: number;
|
|
order_number: string;
|
|
status?: string;
|
|
}
|
|
|
|
export interface OfferDetailData {
|
|
id: number;
|
|
quotation_number: string;
|
|
project_code: string;
|
|
customer_id: number | null;
|
|
customer_name: string;
|
|
created_at: string;
|
|
valid_until: string;
|
|
currency: string;
|
|
language: string;
|
|
items?: OfferItemData[];
|
|
sections?: OfferSectionData[];
|
|
status: string;
|
|
order: OfferOrderInfo | null;
|
|
locked_by: OfferLockInfo | null;
|
|
/** Legal next statuses, computed server-side (same contract as issued orders). */
|
|
valid_transitions: string[];
|
|
}
|
|
|
|
export const offerDetailOptions = (id: string | undefined) =>
|
|
queryOptions({
|
|
queryKey: ["offers", id],
|
|
queryFn: () => jsonQuery<OfferDetailData>(`/api/admin/offers/${id}`),
|
|
enabled: !!id,
|
|
// 404s (deleted/missing offers) are not transient. Retrying just spams
|
|
// GETs and fires the detail-page redirect toast repeatedly.
|
|
retry: false,
|
|
});
|
|
|
|
export const offerNextNumberOptions = () =>
|
|
queryOptions({
|
|
queryKey: ["offers", "next-number"],
|
|
queryFn: () =>
|
|
jsonQuery<{ next_number?: string; number?: string }>(
|
|
"/api/admin/offers/next-number",
|
|
),
|
|
});
|