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:
@@ -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 || ""),
|
||||
});
|
||||
|
||||
@@ -9,6 +9,32 @@ export interface ApiError extends Error {
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full success envelope (`data` + server `message`) — the result shape of a
|
||||
* mutation created with `envelope: true`. Used by the document detail pages
|
||||
* (offers, issued orders) to pass the server's Czech success message through
|
||||
* to the toast instead of a hardcoded client string.
|
||||
*/
|
||||
export interface ApiEnvelope<T> {
|
||||
data?: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Czech-safe error message for toasts: returns the server-provided message
|
||||
* from an `ApiError` (those carry `.status` and a Czech `error` string), and
|
||||
* falls back when the message is a client-generated English string — network
|
||||
* failures ("Failed to fetch" has no status), "Invalid JSON response",
|
||||
* "Unauthorized" (401) or the generic "Request failed (NNN)".
|
||||
*/
|
||||
export function apiErrorMessage(err: unknown, fallback: string): string {
|
||||
const status = (err as ApiError | null | undefined)?.status;
|
||||
const message = err instanceof Error ? err.message : "";
|
||||
if (!message || status === undefined || status === 401) return fallback;
|
||||
if (/^Request failed \(\d+\)$/.test(message)) return fallback;
|
||||
return message;
|
||||
}
|
||||
|
||||
export type HttpMethod = "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
interface ApiResponseBody<T> {
|
||||
@@ -22,6 +48,7 @@ async function performMutation<TIn, TOut>(opts: {
|
||||
url: string | ((input: TIn) => string);
|
||||
method: HttpMethod | ((input: TIn) => HttpMethod);
|
||||
body?: TIn;
|
||||
envelope?: boolean;
|
||||
}): Promise<TOut> {
|
||||
const url =
|
||||
typeof opts.url === "function" ? opts.url(opts.body as TIn) : opts.url;
|
||||
@@ -59,6 +86,9 @@ async function performMutation<TIn, TOut>(opts: {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (opts.envelope) {
|
||||
return { data: result.data, message: result.message } as TOut;
|
||||
}
|
||||
return result.data as TOut;
|
||||
}
|
||||
|
||||
@@ -67,6 +97,12 @@ export interface ApiMutationOptions<TIn, TOut> {
|
||||
method: HttpMethod | ((input: TIn) => HttpMethod);
|
||||
/** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */
|
||||
invalidate?: readonly string[];
|
||||
/**
|
||||
* When true, the mutation resolves with the full `ApiEnvelope` (`{ data,
|
||||
* message }`) instead of the bare `data` — declare `TOut` as
|
||||
* `ApiEnvelope<...>` accordingly.
|
||||
*/
|
||||
envelope?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,12 +125,12 @@ export function useApiMutation<TIn, TOut>(
|
||||
opts: ApiMutationOptions<TIn, TOut> &
|
||||
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
|
||||
) {
|
||||
const { url, method, invalidate, onSuccess, ...rest } = opts;
|
||||
const { url, method, invalidate, envelope, onSuccess, ...rest } = opts;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TOut, ApiError, TIn, unknown>({
|
||||
mutationFn: (input: TIn) =>
|
||||
performMutation<TIn, TOut>({ url, method, body: input }),
|
||||
performMutation<TIn, TOut>({ url, method, body: input, envelope }),
|
||||
...rest,
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
if (invalidate) {
|
||||
|
||||
@@ -52,7 +52,9 @@ export interface Customer {
|
||||
|
||||
export const offerCustomersOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["offer-customers"],
|
||||
// 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,
|
||||
});
|
||||
@@ -70,6 +72,21 @@ export const scopeTemplatesOptions = () =>
|
||||
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;
|
||||
@@ -92,7 +109,9 @@ export const offerListOptions = (filters: {
|
||||
if (filters.customer_id)
|
||||
params.set("customer_id", String(filters.customer_id));
|
||||
const qs = params.toString();
|
||||
return paginatedJsonQuery(`/api/admin/offers${qs ? `?${qs}` : ""}`);
|
||||
return paginatedJsonQuery<Quotation>(
|
||||
`/api/admin/offers${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -162,6 +181,8 @@ export interface OfferDetailData {
|
||||
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) =>
|
||||
|
||||
Reference in New Issue
Block a user