Files
app/src/admin/lib/queries/mutations.ts
BOHA 907ee574e6 chore(lint): remove all 34 dead-code warnings (verified site by site)
unused-vars (25): leftover imports/types across pages, routes and services;
write-only sickHours accumulator; unused holidayCount pair; test helpers
authPatch/authDelete; TOut dropped from ApiMutationOptions (the hook keeps
its own generic — no call-site changes); requireAuth no longer importable
in customers/warehouse routes (matches the no-bare-auth-reads convention).

useless-assignment (5): initializers proven overwritten on every path
(systemQty x2, hours, writtenSize -> let x: number) and the seed's dead
adminUser reassignment (create kept, assignment dropped).

useless-escape (4): [eE+\-] -> [eE+-] and [\/...] -> [/...] — identical
semantics.

Behavior-neutral; lint 102 -> 68 warnings (0 errors), suite 641 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:13:52 +02:00

150 lines
4.5 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> {
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> &
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);
},
});
}