Files
jorgecuadros-platform/apps/web/src/lib/api.ts
T
rmancinasandClaude Opus 5 c100dfa224
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m13s
Build and Push Images / Build jorgecuadros-api (push) Successful in 3m16s
feat(web,api): scale spacing with text size, persist preference per account
Two follow-ups to the text-size control.

Spacing now scales with the text. All padding, margin, gap and min-height
declarations in globals.css move from px to rem (263 declarations, converted
mechanically), so --ui-scale drives the whole layout rather than just the
glyphs. Deliberately left in px: border widths, which must stay hairlines;
box-shadow offsets; border-radius, which reads as bloated when scaled on large
cards; --shell-max, a container cap that must not outgrow the viewport; and
media-query breakpoints, which are conditions rather than declarations. With
spacing following along, the presets gain a 1.5 "Máximo" step and MAX_UI_SCALE
rises from 1.4.

The preference now lives on the account instead of only in one browser.
User.uiScale (Float, default 1) is added to the schema and to the safe select,
so it rides along on /auth/login and /auth/me. PATCH /auth/preferences writes
it, guarded by AuthenticatedGuard only — every role including VIEWER may set
their own, and the target is always the session's user id, never a body
parameter, so this cannot be used to touch another account. The global
ValidationPipe's whitelist rejects any extra field, so role cannot ride in
alongside uiScale.

localStorage stays, demoted to a pre-paint cache for the layout.tsx script;
AppShell reconciles it against the account once /auth/me answers, with the
account winning. FontScaleControl becomes a controlled component since the
same value is now edited from the appbar and the drawer.

Verified against the dev API: PATCH persists and is reflected by a subsequent
/auth/me, out-of-range values are rejected 400, and an extra "role" field in
the body is rejected 400.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:30:06 -07:00

817 lines
24 KiB
TypeScript

// Cross-origin API client. All requests send the session cookie (connect.sid)
// via credentials: "include". The API origin comes from NEXT_PUBLIC_API_ORIGIN.
import type {
AuthUser,
BalanceFilter,
BalanceListResponse,
BalanceSort,
BankCleared,
BankDirection,
BankFacets,
BankListResponse,
BankSort,
BankStats,
BankSummary,
BatchCreateInput,
BatchCreateResponse,
BillingFacets,
BillingStats,
BusinessLine,
ByCheckResponse,
CreateBankMovementInput,
CreateMovementInput,
ResolveOutstandingInput,
CustomerDetail,
CustomerInput,
CustomerListResponse,
CustomerStats,
LedgerCurrency,
LedgerDirection,
MovementListResponse,
MovementSort,
PolicyDetail,
PolicyFacets,
PolicyInput,
PolicyListResponse,
PolicySort,
PolicyStats,
PolicyStatus,
LookupsResponse,
OpsJob,
OpsJobKind,
IngestFile,
BackupFile,
PropertyDetail,
PropertyFacets,
PropertyInput,
PropertyListResponse,
PropertySort,
PropertyStats,
ReportCatalog,
ReportRunResult,
ServiceInput,
TrustInput,
Role,
ServiceKind,
Statement,
Transaction,
TransactionDomain,
TrustFilter,
UserRow,
} from "./types";
// Resolve the API origin at runtime, not build time. In the browser it comes
// from window.__API_ORIGIN__, injected server-side by the root layout from the
// deploy .env (API_ORIGIN) — so one built image serves any deployment. On the
// server (SSR) read process.env directly. NEXT_PUBLIC_API_ORIGIN stays as the
// dev/build fallback.
function resolveApiOrigin(): string {
if (typeof window !== "undefined") {
const injected = (window as { __API_ORIGIN__?: string }).__API_ORIGIN__;
if (injected) return injected;
}
return (
process.env.API_ORIGIN ??
process.env.NEXT_PUBLIC_API_ORIGIN ??
"http://localhost:3001"
);
}
export const API_ORIGIN = resolveApiOrigin();
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "ApiError";
}
}
async function apiFetch<T>(
path: string,
init?: RequestInit,
): Promise<T> {
let res: Response;
try {
res = await fetch(`${API_ORIGIN}${path}`, {
credentials: "include",
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
...init,
});
} catch (e) {
throw new ApiError(
0,
"No se pudo conectar con el servidor. Verifica tu conexión.",
);
}
if (!res.ok) {
let message = `Error ${res.status}`;
try {
const body = await res.json();
if (body?.message) message = body.message;
} catch {
/* ignore non-JSON error bodies */
}
throw new ApiError(res.status, message);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export function login(email: string, password: string): Promise<AuthUser> {
return apiFetch<AuthUser>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
}
export function me(): Promise<AuthUser> {
return apiFetch<AuthUser>("/auth/me");
}
/** Persist the caller's own text-size preference on their account. */
export function updateUiScale(uiScale: number): Promise<AuthUser> {
return apiFetch<AuthUser>("/auth/preferences", {
method: "PATCH",
body: JSON.stringify({ uiScale }),
});
}
export function logout(): Promise<{ success: boolean }> {
return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" });
}
export function getStats(): Promise<CustomerStats> {
return apiFetch<CustomerStats>("/customers/stats");
}
export interface CustomerQuery {
query?: string;
page?: number;
pageSize?: number;
line?: BusinessLine;
}
export function listCustomers(
q: CustomerQuery,
): Promise<CustomerListResponse> {
const params = new URLSearchParams();
if (q.query) params.set("query", q.query);
if (q.page) params.set("page", String(q.page));
if (q.pageSize) params.set("pageSize", String(q.pageSize));
if (q.line) params.set("line", q.line);
const qs = params.toString();
return apiFetch<CustomerListResponse>(`/customers${qs ? `?${qs}` : ""}`);
}
export function getCustomer(id: string): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>(`/customers/${id}`);
}
export function createCustomer(input: CustomerInput): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>("/customers", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateCustomer(
id: string,
input: Partial<CustomerInput>,
): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>(`/customers/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function archiveCustomer(id: string): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>(`/customers/${id}`, { method: "DELETE" });
}
export function restoreCustomer(id: string): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>(`/customers/${id}/restore`, { method: "POST" });
}
/* ------------------------------------------------------ Policies module */
/** Renewal horizon in days, shared by the list, stats and detail calls so the
* "por vencer" bucket means the same thing everywhere. */
export const EXPIRY_WINDOW_DAYS = 30;
export interface PolicyQuery {
query?: string;
page?: number;
pageSize?: number;
status?: PolicyStatus;
days?: number;
typeId?: string;
providerId?: string;
liquidated?: boolean;
sort?: PolicySort;
}
export function listPolicies(q: PolicyQuery): Promise<PolicyListResponse> {
const params = new URLSearchParams();
if (q.query) params.set("query", q.query);
if (q.page) params.set("page", String(q.page));
if (q.pageSize) params.set("pageSize", String(q.pageSize));
if (q.status) params.set("status", q.status);
if (q.days) params.set("days", String(q.days));
if (q.typeId) params.set("typeId", q.typeId);
if (q.providerId) params.set("providerId", q.providerId);
if (q.liquidated !== undefined) params.set("liquidated", String(q.liquidated));
if (q.sort) params.set("sort", q.sort);
const qs = params.toString();
return apiFetch<PolicyListResponse>(`/policies${qs ? `?${qs}` : ""}`);
}
export function getPolicyStats(
days: number = EXPIRY_WINDOW_DAYS,
): Promise<PolicyStats> {
return apiFetch<PolicyStats>(`/policies/stats?days=${days}`);
}
export function getPolicyFacets(): Promise<PolicyFacets> {
return apiFetch<PolicyFacets>("/policies/facets");
}
export function getPolicy(
id: string,
days: number = EXPIRY_WINDOW_DAYS,
): Promise<PolicyDetail> {
return apiFetch<PolicyDetail>(`/policies/${id}?days=${days}`);
}
export function createPolicy(input: PolicyInput): Promise<PolicyDetail> {
return apiFetch<PolicyDetail>("/policies", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updatePolicy(
id: string,
input: Partial<PolicyInput>,
): Promise<PolicyDetail> {
return apiFetch<PolicyDetail>(`/policies/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function archivePolicy(id: string): Promise<PolicyDetail> {
return apiFetch<PolicyDetail>(`/policies/${id}`, { method: "DELETE" });
}
export function restorePolicy(id: string): Promise<PolicyDetail> {
return apiFetch<PolicyDetail>(`/policies/${id}/restore`, { method: "POST" });
}
// Generic policy-child CRUD. `kind` is the URL segment
// (installments|vehicles|drivers|beneficiaries|claims).
export function addPolicyChild<T>(
policyId: string,
kind: string,
input: T,
): Promise<unknown> {
return apiFetch(`/policies/${policyId}/${kind}`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function updatePolicyChild<T>(
policyId: string,
kind: string,
childId: string,
input: T,
): Promise<unknown> {
return apiFetch(`/policies/${policyId}/${kind}/${childId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function removePolicyChild(
policyId: string,
kind: string,
childId: string,
): Promise<unknown> {
return apiFetch(`/policies/${policyId}/${kind}/${childId}`, {
method: "DELETE",
});
}
/* ------------------------------------------------- Lookups (insurance ref) */
export function getLookups(): Promise<LookupsResponse> {
return apiFetch<LookupsResponse>("/lookups");
}
export function createLookup(kind: string, input: unknown): Promise<unknown> {
return apiFetch(`/lookups/${kind}`, { method: "POST", body: JSON.stringify(input) });
}
export function updateLookup(
kind: string,
id: string,
input: unknown,
): Promise<unknown> {
return apiFetch(`/lookups/${kind}/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function removeLookup(kind: string, id: string): Promise<unknown> {
return apiFetch(`/lookups/${kind}/${id}`, { method: "DELETE" });
}
/* ----------------------------------------------------- Utilities module */
export interface PropertyQuery {
query?: string;
page?: number;
pageSize?: number;
serviceKind?: ServiceKind;
municipality?: string;
bank?: string;
trust?: TrustFilter;
hasServices?: boolean;
customerId?: string;
days?: number;
sort?: PropertySort;
}
export function listProperties(q: PropertyQuery): Promise<PropertyListResponse> {
const params = new URLSearchParams();
if (q.query) params.set("query", q.query);
if (q.page) params.set("page", String(q.page));
if (q.pageSize) params.set("pageSize", String(q.pageSize));
if (q.serviceKind) params.set("serviceKind", q.serviceKind);
if (q.municipality) params.set("municipality", q.municipality);
if (q.bank) params.set("bank", q.bank);
if (q.trust) params.set("trust", q.trust);
if (q.hasServices !== undefined)
params.set("hasServices", String(q.hasServices));
if (q.customerId) params.set("customerId", q.customerId);
if (q.days) params.set("days", String(q.days));
if (q.sort) params.set("sort", q.sort);
const qs = params.toString();
return apiFetch<PropertyListResponse>(`/properties${qs ? `?${qs}` : ""}`);
}
export function getPropertyStats(
days: number = EXPIRY_WINDOW_DAYS,
): Promise<PropertyStats> {
return apiFetch<PropertyStats>(`/properties/stats?days=${days}`);
}
export function getPropertyFacets(): Promise<PropertyFacets> {
return apiFetch<PropertyFacets>("/properties/facets");
}
export function getProperty(
id: string,
days: number = EXPIRY_WINDOW_DAYS,
): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}?days=${days}`);
}
export function createProperty(input: PropertyInput): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>("/properties", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateProperty(
id: string,
input: Partial<PropertyInput>,
): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function archiveProperty(id: string): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}`, { method: "DELETE" });
}
export function restoreProperty(id: string): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}/restore`, { method: "POST" });
}
// Service child CRUD.
export function addService(propertyId: string, input: ServiceInput): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/services`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateService(
propertyId: string,
serviceId: string,
input: Partial<ServiceInput>,
): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/services/${serviceId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function removeService(propertyId: string, serviceId: string): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/services/${serviceId}`, {
method: "DELETE",
});
}
// Trust account (1:1 upsert).
export function upsertTrust(propertyId: string, input: TrustInput): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/trust`, {
method: "PUT",
body: JSON.stringify(input),
});
}
export function removeTrust(propertyId: string): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/trust`, { method: "DELETE" });
}
export function removePropertyDocument(
propertyId: string,
documentId: string,
): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/documents/${documentId}`, {
method: "DELETE",
});
}
export function propertyDocumentDownloadUrl(
propertyId: string,
documentId: string,
): string {
return `${API_ORIGIN}/properties/${propertyId}/documents/${documentId}/download`;
}
export function uploadPropertyDocument(
propertyId: string,
file: File,
type?: string,
): Promise<unknown> {
const q = type ? `?type=${encodeURIComponent(type)}` : "";
return uploadFile(`/properties/${propertyId}/documents${q}`, file);
}
export function removePolicyDocument(
policyId: string,
documentId: string,
): Promise<unknown> {
return apiFetch(`/policies/${policyId}/documents/${documentId}`, {
method: "DELETE",
});
}
export function policyDocumentDownloadUrl(
policyId: string,
documentId: string,
): string {
return `${API_ORIGIN}/policies/${policyId}/documents/${documentId}/download`;
}
export function uploadPolicyDocument(
policyId: string,
file: File,
type?: string,
): Promise<unknown> {
const q = type ? `?type=${encodeURIComponent(type)}` : "";
return uploadFile(`/policies/${policyId}/documents${q}`, file);
}
/* ------------------------------------------- Billing / statements module */
export interface MovementQuery {
query?: string;
page?: number;
pageSize?: number;
domain?: TransactionDomain;
currency?: LedgerCurrency;
direction?: LedgerDirection;
typeId?: string;
source?: string;
customerId?: string;
/** Restrict to captured-but-unpaid rows (the NOPAGO worklist). */
outstanding?: boolean;
/** Exact check number — the by-check reconciliation lookup. */
checkNumber?: string;
/** `YYYY-MM-DD`, inclusive on both ends. */
from?: string;
to?: string;
sort?: MovementSort;
}
export function listMovements(q: MovementQuery): Promise<MovementListResponse> {
const params = new URLSearchParams();
if (q.query) params.set("query", q.query);
if (q.page) params.set("page", String(q.page));
if (q.pageSize) params.set("pageSize", String(q.pageSize));
if (q.domain) params.set("domain", q.domain);
if (q.currency) params.set("currency", q.currency);
if (q.direction) params.set("direction", q.direction);
if (q.typeId) params.set("typeId", q.typeId);
if (q.source) params.set("source", q.source);
if (q.customerId) params.set("customerId", q.customerId);
if (q.outstanding !== undefined) params.set("outstanding", String(q.outstanding));
if (q.checkNumber) params.set("checkNumber", q.checkNumber);
if (q.from) params.set("from", q.from);
if (q.to) params.set("to", q.to);
if (q.sort) params.set("sort", q.sort);
const qs = params.toString();
return apiFetch<MovementListResponse>(`/billing${qs ? `?${qs}` : ""}`);
}
export interface BalanceQuery {
query?: string;
page?: number;
pageSize?: number;
currency?: LedgerCurrency;
balance?: BalanceFilter;
domain?: TransactionDomain;
sort?: BalanceSort;
}
export function listBalances(q: BalanceQuery): Promise<BalanceListResponse> {
const params = new URLSearchParams();
if (q.query) params.set("query", q.query);
if (q.page) params.set("page", String(q.page));
if (q.pageSize) params.set("pageSize", String(q.pageSize));
if (q.currency) params.set("currency", q.currency);
if (q.balance) params.set("balance", q.balance);
if (q.domain) params.set("domain", q.domain);
if (q.sort) params.set("sort", q.sort);
const qs = params.toString();
return apiFetch<BalanceListResponse>(`/billing/balances${qs ? `?${qs}` : ""}`);
}
export function getBillingStats(): Promise<BillingStats> {
return apiFetch<BillingStats>("/billing/stats");
}
export function getBillingFacets(): Promise<BillingFacets> {
return apiFetch<BillingFacets>("/billing/facets");
}
export function getStatement(customerId: string): Promise<Statement> {
return apiFetch<Statement>(`/billing/customers/${customerId}`);
}
/** Append a new ledger movement. Booked movements are never edited — fix
* mistakes with voidMovement + a fresh capture. */
export function createMovement(
input: CreateMovementInput,
): Promise<Transaction> {
return apiFetch<Transaction>("/billing", {
method: "POST",
body: JSON.stringify(input),
});
}
/** Reverse a movement by marking it voided; totals drop it. 400 if already void. */
export function voidMovement(id: string): Promise<Transaction> {
return apiFetch<Transaction>(`/billing/${id}/void`, { method: "POST" });
}
/** Capture many customers' receipts against one check, in one transaction. The
* returned `items` are positionally parallel to `input.lines`. */
export function createMovementBatch(
input: BatchCreateInput,
): Promise<BatchCreateResponse> {
return apiFetch<BatchCreateResponse>("/billing/batch", {
method: "POST",
body: JSON.stringify(input),
});
}
/** Clear an outstanding (NOPAGO) row: stamps the check number + resolution date
* and starts counting it toward the balance. 400 if not outstanding or voided. */
export function resolveOutstanding(
id: string,
input: ResolveOutstandingInput,
): Promise<Transaction> {
return apiFetch<Transaction>(`/billing/${id}/resolve-outstanding`, {
method: "POST",
body: JSON.stringify(input),
});
}
/** Everything captured against one check, with its reconciliation total. */
export function getByCheck(checkNumber: string): Promise<ByCheckResponse> {
return apiFetch<ByCheckResponse>(
`/billing/by-check?checkNumber=${encodeURIComponent(checkNumber)}`,
);
}
/* ------------------------------------------------- Bank register (chequera) */
export interface BankQuery {
query?: string;
page?: number;
pageSize?: number;
direction?: BankDirection;
cleared?: BankCleared;
/** `YYYY-MM-DD`, inclusive on both ends. */
from?: string;
to?: string;
sort?: BankSort;
}
export function listBankMovements(q: BankQuery): Promise<BankListResponse> {
const params = new URLSearchParams();
if (q.query) params.set("query", q.query);
if (q.page) params.set("page", String(q.page));
if (q.pageSize) params.set("pageSize", String(q.pageSize));
if (q.direction) params.set("direction", q.direction);
if (q.cleared) params.set("cleared", q.cleared);
if (q.from) params.set("from", q.from);
if (q.to) params.set("to", q.to);
if (q.sort) params.set("sort", q.sort);
const qs = params.toString();
return apiFetch<BankListResponse>(`/bank${qs ? `?${qs}` : ""}`);
}
export function getBankStats(): Promise<BankStats> {
return apiFetch<BankStats>("/bank/stats");
}
export function getBankFacets(): Promise<BankFacets> {
return apiFetch<BankFacets>("/bank/facets");
}
export function getBankSummary(year?: number): Promise<BankSummary> {
return apiFetch<BankSummary>(`/bank/summary${year ? `?year=${year}` : ""}`);
}
/** Append a new chequera movement. Booked rows are never edited — fix mistakes
* with voidBankMovement + a fresh capture. */
export function createBankMovement(
input: CreateBankMovementInput,
): Promise<unknown> {
return apiFetch("/bank", {
method: "POST",
body: JSON.stringify(input),
});
}
/** Reverse a chequera movement by marking it voided; totals drop it. */
export function voidBankMovement(id: string): Promise<unknown> {
return apiFetch(`/bank/${id}/void`, { method: "POST" });
}
/* ------------------------------------------------- Users / administration */
export function listUsers(): Promise<UserRow[]> {
return apiFetch<UserRow[]>("/users");
}
export interface CreateUserInput {
name: string;
email: string;
password: string;
role: Role;
active?: boolean;
}
export function createUser(input: CreateUserInput): Promise<UserRow> {
return apiFetch<UserRow>("/users", {
method: "POST",
body: JSON.stringify(input),
});
}
export interface UpdateUserInput {
name?: string;
email?: string;
role?: Role;
active?: boolean;
}
export function updateUser(id: string, input: UpdateUserInput): Promise<UserRow> {
return apiFetch<UserRow>(`/users/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function resetUserPassword(id: string, password: string): Promise<UserRow> {
return apiFetch<UserRow>(`/users/${id}/reset-password`, {
method: "POST",
body: JSON.stringify({ password }),
});
}
export function deleteUser(id: string): Promise<void> {
return apiFetch<void>(`/users/${id}`, { method: "DELETE" });
}
/* ------------------------------------------- DB operations (admin only) */
export function listIngest(): Promise<IngestFile[]> {
return apiFetch<IngestFile[]>("/ops/ingest");
}
/**
* Multipart upload — not JSON, so it bypasses apiFetch's Content-Type. `path`
* is API-relative (may include a query string); `filename` overrides the part
* name sent to the server.
*/
export async function uploadFile(
path: string,
file: File,
filename?: string,
): Promise<unknown> {
const body = new FormData();
body.append("file", file, filename ?? file.name);
const res = await fetch(`${API_ORIGIN}${path}`, {
method: "POST",
credentials: "include",
body,
});
if (!res.ok) {
let message = `Error ${res.status}`;
try {
const b = await res.json();
if (b?.message) message = b.message;
} catch {
/* ignore */
}
throw new ApiError(res.status, message);
}
return res.status === 204 ? undefined : res.json().catch(() => undefined);
}
export function uploadIngest(name: string, file: File): Promise<unknown> {
return uploadFile(`/ops/ingest/${encodeURIComponent(name)}`, file, name);
}
export function deleteIngest(name: string): Promise<unknown> {
return apiFetch(`/ops/ingest/${encodeURIComponent(name)}`, { method: "DELETE" });
}
export function listBackups(): Promise<BackupFile[]> {
return apiFetch<BackupFile[]>("/ops/backups");
}
export function backupDownloadUrl(name: string): string {
return `${API_ORIGIN}/ops/backups/${encodeURIComponent(name)}/download`;
}
export function deleteBackup(name: string): Promise<unknown> {
return apiFetch(`/ops/backups/${encodeURIComponent(name)}`, { method: "DELETE" });
}
export function listOpsJobs(): Promise<OpsJob[]> {
return apiFetch<OpsJob[]>("/ops/jobs");
}
export function getOpsJob(id: string): Promise<OpsJob> {
return apiFetch<OpsJob>(`/ops/jobs/${id}`);
}
/** Start a mutating op. `file` is required for RESTORE. 409 if one is running. */
export function startOpsJob(kind: OpsJobKind, file?: string): Promise<OpsJob> {
return apiFetch<OpsJob>("/ops/jobs", {
method: "POST",
body: JSON.stringify({ kind, file }),
});
}
/* ----------------------------------------------------------- Reports module */
export function getReportCatalog(): Promise<ReportCatalog> {
return apiFetch<ReportCatalog>("/reports");
}
export function runReport(
slug: string,
params: Record<string, string | undefined>,
): Promise<ReportRunResult> {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v != null && v !== "") qs.set(k, v);
}
const tail = qs.toString();
return apiFetch<ReportRunResult>(`/reports/${slug}${tail ? `?${tail}` : ""}`);
}
/** Build a download URL for a report's file output. The session cookie
* travels with the browser's same-origin navigation, so a plain `href`
* is enough — no fetch-with-credentials dance. */
export function reportDownloadUrl(
slug: string,
format: "csv" | "xlsx" | "pdf" | "print",
params: Record<string, string | undefined>,
): string {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v != null && v !== "") qs.set(k, v);
}
const tail = qs.toString();
return `${API_ORIGIN}/reports/${slug}/${format}${tail ? `?${tail}` : ""}`;
}