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
+95
View File
@@ -0,0 +1,95 @@
// Spanish label maps + formatting helpers. Single source of truth for i18n.
import type { ServiceKind, TransactionDomain } from "./types";
export const DOMAIN_LABELS: Record<string, string> = {
UTILITY: "Servicios",
INSURANCE: "Seguros",
TRUST: "Fideicomiso",
};
export function domainLabel(domain: TransactionDomain): string {
return DOMAIN_LABELS[domain] ?? domain;
}
export const SERVICE_KIND_LABELS: Record<string, string> = {
WATER: "Agua",
ELECTRIC: "Electricidad",
GAS: "Gas",
CABLE: "Cable/TV",
PROPERTY_TAX: "Predial",
FEDERAL_ZONE: "Zona Federal",
ALARM: "Alarma",
OTHER: "Otro",
};
export function serviceKindLabel(kind: ServiceKind): string {
return SERVICE_KIND_LABELS[kind] ?? kind;
}
// A short glyph per service kind, drawn with unicode so no icon dependency.
export const SERVICE_KIND_GLYPH: Record<string, string> = {
WATER: "≈",
ELECTRIC: "⚡",
GAS: "◐",
CABLE: "▤",
PROPERTY_TAX: "⌂",
FEDERAL_ZONE: "⇲",
ALARM: "◈",
OTHER: "•",
};
export function serviceKindGlyph(kind: ServiceKind): string {
return SERVICE_KIND_GLYPH[kind] ?? "•";
}
// ----- formatting -----
export function formatMoney(
value: string | number | null | undefined,
currency: string | null | undefined,
): string {
if (value === null || value === undefined || value === "") return "—";
const num = typeof value === "string" ? Number(value) : value;
if (Number.isNaN(num)) return String(value);
const cur = (currency ?? "USD").toUpperCase();
try {
return new Intl.NumberFormat("es-MX", {
style: "currency",
currency: cur,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(num);
} catch {
// Unknown currency code — fall back to plain number + suffix.
return `${num.toLocaleString("es-MX", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} ${cur}`;
}
}
export function formatDate(
iso: string | null | undefined,
): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
const dd = String(d.getUTCDate()).padStart(2, "0");
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const yyyy = d.getUTCFullYear();
return `${dd}/${mm}/${yyyy}`;
}
export function formatNumber(n: number): string {
return n.toLocaleString("es-MX");
}
// "sourceSystem" from legacyRefs → display label.
export function sourceSystemLabel(source: string): string {
const map: Record<string, string> = {
utilities: "Servicios",
insurance: "Seguros",
};
return map[source] ?? source;
}