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>
150 lines
4.6 KiB
TypeScript
150 lines
4.6 KiB
TypeScript
import {
|
|
useMutation,
|
|
useQueryClient,
|
|
type UseMutationOptions,
|
|
} from "@tanstack/react-query";
|
|
import apiFetch from "../../utils/api";
|
|
|
|
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> {
|
|
success: boolean;
|
|
data?: T;
|
|
error?: string;
|
|
message?: string;
|
|
}
|
|
|
|
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;
|
|
const method =
|
|
typeof opts.method === "function"
|
|
? opts.method(opts.body as TIn)
|
|
: opts.method;
|
|
const response = await apiFetch(url, {
|
|
method,
|
|
headers:
|
|
opts.body !== undefined
|
|
? { "Content-Type": "application/json" }
|
|
: undefined,
|
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
const err: ApiError = new Error("Unauthorized");
|
|
err.status = 401;
|
|
throw err;
|
|
}
|
|
|
|
let result: ApiResponseBody<TOut>;
|
|
try {
|
|
result = (await response.json()) as ApiResponseBody<TOut>;
|
|
} catch {
|
|
throw new Error("Invalid JSON response");
|
|
}
|
|
|
|
if (!response.ok || !result.success) {
|
|
const err: ApiError = new Error(
|
|
result.error || `Request failed (${response.status})`,
|
|
);
|
|
err.status = response.status;
|
|
throw err;
|
|
}
|
|
|
|
if (opts.envelope) {
|
|
return { data: result.data, message: result.message } as TOut;
|
|
}
|
|
return result.data as TOut;
|
|
}
|
|
|
|
export interface ApiMutationOptions<TIn, TOut> {
|
|
url: string | ((input: TIn) => string);
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Hook that wraps `useMutation` with `apiFetch` and broad-invalidation.
|
|
*
|
|
* Usage:
|
|
* const createCategory = useApiMutation<CategoryForm, WarehouseCategory>({
|
|
* url: "/api/admin/warehouse/categories",
|
|
* method: "POST",
|
|
* invalidate: ["warehouse"],
|
|
* });
|
|
* createCategory.mutate(form, {
|
|
* onSuccess: () => { ... },
|
|
* });
|
|
*
|
|
* The returned `data` from a successful mutation is `TOut`. Errors are thrown
|
|
* as `ApiError` (with `.status` attached) so callers can branch on HTTP code.
|
|
*/
|
|
export function useApiMutation<TIn, TOut>(
|
|
opts: ApiMutationOptions<TIn, TOut> &
|
|
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
|
|
) {
|
|
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, envelope }),
|
|
...rest,
|
|
onSuccess: (data, variables, onMutateResult, context) => {
|
|
if (invalidate) {
|
|
for (const key of invalidate) {
|
|
queryClient.invalidateQueries({ queryKey: [key] });
|
|
}
|
|
}
|
|
// Forward to user-provided onSuccess with the 4-arg signature expected by TanStack.
|
|
(
|
|
onSuccess as
|
|
| ((d: TOut, v: TIn, r: unknown, c: unknown) => void)
|
|
| undefined
|
|
)?.(data, variables, onMutateResult, context);
|
|
},
|
|
});
|
|
}
|