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:
@@ -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}`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// TypeScript types for the Jorge Cuadros & Asociados API responses.
|
||||
// Decimals arrive as strings, dates as ISO strings.
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface CustomerStats {
|
||||
customers: number;
|
||||
withUtilities: number;
|
||||
withInsurance: number;
|
||||
bothLines: number;
|
||||
policies: number;
|
||||
properties: number;
|
||||
transactions: number;
|
||||
}
|
||||
|
||||
export type BusinessLine = "utility" | "insurance" | "both";
|
||||
|
||||
export interface CustomerListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
mobile: string | null;
|
||||
status: boolean;
|
||||
propertyCount: number;
|
||||
policyCount: number;
|
||||
transactionCount: number;
|
||||
hasUtilities: boolean;
|
||||
hasInsurance: boolean;
|
||||
}
|
||||
|
||||
export interface CustomerListResponse {
|
||||
items: CustomerListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
export interface LegacyRef {
|
||||
id: string;
|
||||
sourceSystem: string; // "utilities" | "insurance"
|
||||
sourceTable: string;
|
||||
legacyId: string;
|
||||
}
|
||||
|
||||
export type ServiceKind =
|
||||
| "WATER"
|
||||
| "ELECTRIC"
|
||||
| "GAS"
|
||||
| "CABLE"
|
||||
| "PROPERTY_TAX"
|
||||
| "FEDERAL_ZONE"
|
||||
| "ALARM"
|
||||
| "OTHER"
|
||||
| string;
|
||||
|
||||
export interface Service {
|
||||
id: string;
|
||||
kind: ServiceKind;
|
||||
accountNumber: string | null;
|
||||
meterNumber: string | null;
|
||||
route: string | null;
|
||||
dueDay: string | null;
|
||||
active: boolean;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface TrustAccount {
|
||||
id: string;
|
||||
bankName: string | null;
|
||||
trustNumber: string | null;
|
||||
bankFee: string | null;
|
||||
dueDate1: string | null;
|
||||
dueDate2: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentRef {
|
||||
id?: string;
|
||||
documentType: string | null;
|
||||
storageKey: string | null;
|
||||
}
|
||||
|
||||
export interface Property {
|
||||
id: string;
|
||||
addressLine1: string | null;
|
||||
addressLine2: string | null;
|
||||
phone1: string | null;
|
||||
phone2: string | null;
|
||||
phone3: string | null;
|
||||
zone: string | null;
|
||||
services: Service[];
|
||||
trustAccount: TrustAccount | null;
|
||||
documents: DocumentRef[];
|
||||
}
|
||||
|
||||
export interface Installment {
|
||||
id: string;
|
||||
sequence: number;
|
||||
amount: string | null;
|
||||
currency: string | null;
|
||||
dueDate: string | null;
|
||||
paidDate: string | null;
|
||||
checkNumber: string | null;
|
||||
isCash: boolean;
|
||||
}
|
||||
|
||||
export interface Vehicle {
|
||||
id: string;
|
||||
make: string | null;
|
||||
model: string | null;
|
||||
modelYear: number | null;
|
||||
licensePlate: string | null;
|
||||
bodyType: string | null;
|
||||
engineNumber?: string | null;
|
||||
vinNumber?: string | null;
|
||||
}
|
||||
|
||||
export interface InsuredDriver {
|
||||
id: string;
|
||||
fullName: string | null;
|
||||
licenseNumber: string | null;
|
||||
}
|
||||
|
||||
export interface Beneficiary {
|
||||
id: string;
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
address?: string | null;
|
||||
}
|
||||
|
||||
export interface NamedRef {
|
||||
name: string | null;
|
||||
shortDescription?: string | null;
|
||||
}
|
||||
|
||||
export interface Policy {
|
||||
id: string;
|
||||
policyNumber: string | null;
|
||||
policyType: NamedRef | null;
|
||||
insuranceProvider: NamedRef | null;
|
||||
agentName: string | null;
|
||||
policyFrom: string | null;
|
||||
policyTo: string | null;
|
||||
netPremium: string | null;
|
||||
total: string | null;
|
||||
currency: string | null;
|
||||
liquidated: boolean;
|
||||
installments: Installment[];
|
||||
vehicles: Vehicle[];
|
||||
insuredDrivers: InsuredDriver[];
|
||||
beneficiaries: Beneficiary[];
|
||||
claims: unknown[];
|
||||
documents: DocumentRef[];
|
||||
}
|
||||
|
||||
export type TransactionDomain = "UTILITY" | "INSURANCE" | "TRUST" | string;
|
||||
|
||||
export interface TransactionType {
|
||||
nameEn: string | null;
|
||||
nameEs: string | null;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
transactionDate: string | null;
|
||||
domain: TransactionDomain;
|
||||
amount: string | null;
|
||||
currency: string | null;
|
||||
reference: string | null;
|
||||
period?: string | null;
|
||||
message: string | null;
|
||||
checkNumber: string | null;
|
||||
type: TransactionType | null;
|
||||
}
|
||||
|
||||
export interface TransactionSummaryRow {
|
||||
domain: TransactionDomain;
|
||||
currency: string;
|
||||
total: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface CustomerDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
addressLine1: string | null;
|
||||
addressLine2: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
zipCode: string | null;
|
||||
country: string | null;
|
||||
phone: string | null;
|
||||
mobile: string | null;
|
||||
fax: string | null;
|
||||
email: string | null;
|
||||
notes: string | null;
|
||||
identificationType: string | null;
|
||||
identificationNumber: string | null;
|
||||
identificationExpiration: string | null;
|
||||
customerSince: string | null;
|
||||
status: boolean;
|
||||
feeAmount: string | number | null;
|
||||
preferredCurrency: string | null;
|
||||
legacyRefs: LegacyRef[];
|
||||
properties: Property[];
|
||||
policies: Policy[];
|
||||
transactions: Transaction[];
|
||||
transactionSummary: TransactionSummaryRow[];
|
||||
}
|
||||
Reference in New Issue
Block a user