Web: Spanish-first staff UI — login + unified customer browser

First real frontend feature against the live Customer module API.

- login/ — session login form posting to /auth/login with credentials
  included; the session cookie is what every subsequent request rides on.
- clientes/ — customer list with search and the cross-line stats header
  (customers, utilities/insurance split, both-lines count).
- clientes/[id]/ — unified detail view: identity, properties + services,
  policies, and transaction history for one customer, which is the whole
  point of the migration (one record spanning both business lines).
- components/AppShell.tsx, lib/{api,labels,types}.ts — shared fetch wrapper
  (always credentials: "include"), Spanish label maps for the enum values
  the API returns, and the API response types.
- globals.css + layout.tsx — Spanish-first document (lang="es"), the type
  scale, and the design tokens the pages share. Fonts load via <link> so an
  offline build still renders on the system fallback stacks.
- page.tsx now redirects / to /clientes.

Also fixes pnpm-workspace.yaml: the allowBuilds map held pnpm's literal
placeholder text ("set this to true or false"), which made every install
fail with ERR_PNPM_IGNORED_BUILDS. Since pnpm 11 auto-installs before
running a script, that broke `pnpm start:dev` outright. Set the values to
true and dropped the superseded onlyBuiltDependencies list.

Verified: both apps build clean, and login -> /auth/me -> /customers/stats
round-trips against the dev database (1682 customers, 526 on both lines).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 20:21:36 -07:00
co-authored by Claude Opus 4.8
parent 98f5cc20d8
commit da0fa3cb47
12 changed files with 3219 additions and 22 deletions
+100
View File
@@ -0,0 +1,100 @@
// 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,
BusinessLine,
CustomerDetail,
CustomerListResponse,
CustomerStats,
} from "./types";
export const API_ORIGIN =
process.env.NEXT_PUBLIC_API_ORIGIN ?? "http://localhost:3001";
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");
}
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}`);
}