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
+765
View File
@@ -0,0 +1,765 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { getCustomer } from "@/lib/api";
import {
domainLabel,
formatDate,
formatMoney,
serviceKindGlyph,
serviceKindLabel,
sourceSystemLabel,
} from "@/lib/labels";
import type {
CustomerDetail,
Installment,
Policy,
Property,
Transaction,
TransactionSummaryRow,
} from "@/lib/types";
export default function ClienteDetailPage({
params,
}: {
params: { id: string };
}) {
const { id } = params;
return (
<AppShell>
<Detail id={id} />
</AppShell>
);
}
function Detail({ id }: { id: string }) {
const [data, setData] = useState<CustomerDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
setLoading(true);
setError(null);
getCustomer(id)
.then((d) => {
if (alive) {
setData(d);
setLoading(false);
}
})
.catch((e) => {
if (alive) {
setError(
e?.status === 404
? "No encontramos este cliente."
: e?.message ?? "No se pudo cargar el cliente.",
);
setLoading(false);
}
});
return () => {
alive = false;
};
}, [id]);
if (loading) return <DetailSkeleton />;
if (error)
return (
<>
<BackLink />
<div className="state-error" role="alert">
{error}
</div>
</>
);
if (!data) return null;
const hasUtilities = data.properties.length > 0;
const hasInsurance = data.policies.length > 0;
return (
<div className="rise">
<BackLink />
<Hero data={data} hasUtilities={hasUtilities} hasInsurance={hasInsurance} />
<DatosSection data={data} />
<PropiedadesSection properties={data.properties} />
<PolizasSection policies={data.policies} />
<EstadoCuentaSection
summary={data.transactionSummary}
transactions={data.transactions}
/>
<DocumentosSection data={data} />
</div>
);
}
function BackLink() {
return (
<Link href="/clientes" className="back-link">
Volver a Clientes
</Link>
);
}
/* ------------------------------------------------------------------ Hero */
function Hero({
data,
hasUtilities,
hasInsurance,
}: {
data: CustomerDetail;
hasUtilities: boolean;
hasInsurance: boolean;
}) {
const provenance = data.legacyRefs
.map((r) => `${sourceSystemLabel(r.sourceSystem)} #${r.legacyId}`)
.join(" · ");
const facts: { label: string; value: string }[] = [
{ label: "Cliente desde", value: formatDate(data.customerSince) },
{
label: "Cuota",
value:
data.feeAmount != null && data.feeAmount !== ""
? formatMoney(data.feeAmount, data.preferredCurrency)
: "—",
},
{ label: "Propiedades", value: String(data.properties.length) },
{ label: "Pólizas", value: String(data.policies.length) },
{ label: "Moneda", value: data.preferredCurrency ?? "—" },
];
return (
<div className="detail-hero">
<div className="hero-top">
<div>
<h1 className="hero-name">{data.name}</h1>
{provenance && (
<div className="hero-provenance">Origen: {provenance}</div>
)}
</div>
<div className="hero-badges">
{hasUtilities && (
<span className="badge badge-servicios">
<span className="dot" /> Servicios
</span>
)}
{hasInsurance && (
<span className="badge badge-seguros">
<span className="dot" /> Seguros
</span>
)}
<span
className={`badge ${
data.status ? "badge-on-dark" : "badge-negative"
}`}
>
{data.status ? "Activo" : "Inactivo"}
</span>
</div>
</div>
<div className="hero-facts">
{facts.map((f) => (
<div key={f.label}>
<div className="hero-fact-label">{f.label}</div>
<div className="hero-fact-value">{f.value}</div>
</div>
))}
</div>
</div>
);
}
/* ------------------------------------------------------- Datos del cliente */
function DatosSection({ data }: { data: CustomerDetail }) {
const mxAddress = [data.addressLine1, data.addressLine2]
.filter(Boolean)
.join(", ");
const cityLine = [
data.city?.replace(/,\s*$/, ""),
data.state,
data.zipCode,
data.country,
]
.filter(Boolean)
.join(", ");
const idLine =
data.identificationNumber || data.identificationType
? [
data.identificationType,
data.identificationNumber,
data.identificationExpiration
? `vence ${formatDate(data.identificationExpiration)}`
: null,
]
.filter(Boolean)
.join(" · ")
: null;
return (
<section className="section">
<SectionHead rule="datos" title="Datos del cliente" />
<div className="card">
<div className="kv-grid">
<KV label="Teléfono" value={data.phone} mono />
<KV label="Móvil" value={data.mobile} mono />
<KV label="Fax" value={data.fax} mono />
<KV label="Correo electrónico" value={data.email} />
<KV label="Documento de identidad" value={idLine} />
<KV
label="Estado"
value={data.status ? "Activo" : "Inactivo"}
/>
{(mxAddress || cityLine) && (
<div className="kv-block">
<div className="kv-label">Domicilio</div>
<div className="kv-value">
{mxAddress && <div>{mxAddress}</div>}
{cityLine && <div>{cityLine}</div>}
{!mxAddress && !cityLine && "—"}
</div>
</div>
)}
{data.notes && (
<div className="kv-block">
<div className="kv-label">Notas</div>
<div className="kv-value">{data.notes}</div>
</div>
)}
</div>
</div>
</section>
);
}
function KV({
label,
value,
mono,
}: {
label: string;
value: string | null | undefined;
mono?: boolean;
}) {
return (
<div>
<div className="kv-label">{label}</div>
<div className={`kv-value${mono && value ? " mono" : ""}`}>
{value || "—"}
</div>
</div>
);
}
/* ----------------------------------------------- Propiedades y servicios */
function PropiedadesSection({ properties }: { properties: Property[] }) {
return (
<section className="section">
<SectionHead
rule="servicios"
title="Propiedades y servicios"
count={properties.length}
/>
<div className="card">
{properties.length === 0 ? (
<div className="empty-inline">
Este cliente no tiene propiedades registradas.
</div>
) : (
properties.map((p) => <PropertyCard key={p.id} p={p} />)
)}
</div>
</section>
);
}
function PropertyCard({ p }: { p: Property }) {
const addr = [p.addressLine1, p.addressLine2].filter(Boolean).join(", ");
const phones = [p.phone1, p.phone2, p.phone3].filter(Boolean);
return (
<div className="prop-card">
<div className="prop-addr">{addr || "Propiedad"}</div>
<div className="prop-meta">
{p.zone && <span>Zona: {p.zone}</span>}
{phones.length > 0 && (
<span className="mono">{phones.join(" · ")}</span>
)}
</div>
{p.services.length > 0 && (
<div className="svc-grid">
{p.services.map((s) => (
<div
key={s.id}
className={`svc-item${s.active ? "" : " inactive"}`}
>
<div className="svc-head">
<span className="svc-kind">
<span className="svc-glyph" aria-hidden>
{serviceKindGlyph(s.kind)}
</span>
{serviceKindLabel(s.kind)}
</span>
{!s.active && (
<span className="badge badge-neutral">Inactivo</span>
)}
</div>
<div className="svc-detail">
{s.accountNumber && (
<span>
Cuenta: <span className="mono">{s.accountNumber}</span>
</span>
)}
{s.meterNumber && (
<span>
Medidor: <span className="mono">{s.meterNumber}</span>
</span>
)}
{s.route && (
<span>
Ruta: <span className="mono">{s.route}</span>
</span>
)}
{s.dueDay && <span>Día de pago: {s.dueDay}</span>}
{s.notes && <span>{s.notes}</span>}
</div>
</div>
))}
</div>
)}
{p.trustAccount && (
<div className="trust-box">
<div className="trust-title">Fideicomiso</div>
<div className="trust-facts">
{p.trustAccount.bankName && (
<span>
<strong>Banco:</strong> {p.trustAccount.bankName}
</span>
)}
{p.trustAccount.trustNumber && (
<span>
<strong>No.:</strong>{" "}
<span className="mono">{p.trustAccount.trustNumber}</span>
</span>
)}
{p.trustAccount.bankFee && (
<span>
<strong>Comisión:</strong>{" "}
{formatMoney(p.trustAccount.bankFee, "MXN")}
</span>
)}
{p.trustAccount.dueDate1 && (
<span>
<strong>Vigencia:</strong>{" "}
{formatDate(p.trustAccount.dueDate1)}
{p.trustAccount.dueDate2
? ` ${formatDate(p.trustAccount.dueDate2)}`
: ""}
</span>
)}
</div>
</div>
)}
</div>
);
}
/* ------------------------------------------------------ Pólizas de seguro */
function PolizasSection({ policies }: { policies: Policy[] }) {
return (
<section className="section">
<SectionHead
rule="seguros"
title="Pólizas de seguro"
count={policies.length}
/>
<div className="card">
{policies.length === 0 ? (
<div className="empty-inline">
Este cliente no tiene pólizas registradas.
</div>
) : (
policies.map((p) => <PolicyCard key={p.id} p={p} />)
)}
</div>
</section>
);
}
function PolicyCard({ p }: { p: Policy }) {
const headline = p.total ?? p.netPremium;
const headlineLabel = p.total ? "Total" : "Prima neta";
return (
<div className="policy-card">
<div className="policy-head">
<div>
<div className="policy-num">{p.policyNumber || "—"}</div>
<div className="policy-type-row">
{p.policyType?.name && (
<span className="badge badge-seguros">
<span className="dot" /> {p.policyType.name}
</span>
)}
{p.insuranceProvider?.name && (
<span>{p.insuranceProvider.name}</span>
)}
{p.agentName && (
<>
<span className="sep">·</span>
<span>Agente: {p.agentName}</span>
</>
)}
<span
className={`badge ${
p.liquidated ? "badge-positive" : "badge-neutral"
}`}
>
{p.liquidated ? "Liquidada" : "Pendiente"}
</span>
</div>
<div className="policy-type-row">
<span className="kv-label" style={{ margin: 0 }}>
Vigencia:
</span>
<span className="mono">
{formatDate(p.policyFrom)} {formatDate(p.policyTo)}
</span>
</div>
</div>
<div className="policy-figures">
<div className="policy-total">
{formatMoney(headline, p.currency)}
</div>
<div className="policy-total-label">{headlineLabel}</div>
{p.total && p.netPremium && (
<div className="muted" style={{ fontSize: 12, marginTop: 4 }}>
Prima neta {formatMoney(p.netPremium, p.currency)}
</div>
)}
</div>
</div>
<div className="policy-body">
{p.installments.length > 0 && (
<div className="subpanel">
<div className="subpanel-title">
<span>Pagos</span>
<span>{p.installments.length}</span>
</div>
{p.installments.map((inst) => (
<InstallmentRow key={inst.id} inst={inst} />
))}
</div>
)}
{p.vehicles.length > 0 && (
<div className="subpanel">
<div className="subpanel-title">
<span>Vehículos</span>
<span>{p.vehicles.length}</span>
</div>
<div className="mini-list">
{p.vehicles.map((v) => (
<div key={v.id}>
{[v.make, v.model, v.modelYear].filter(Boolean).join(" ") ||
"Vehículo"}
<div className="mini-sub">
{[
v.bodyType,
v.licensePlate ? `Placa ${v.licensePlate}` : null,
]
.filter(Boolean)
.join(" · ")}
</div>
</div>
))}
</div>
</div>
)}
{p.insuredDrivers.length > 0 && (
<div className="subpanel">
<div className="subpanel-title">
<span>Asegurados</span>
<span>{p.insuredDrivers.length}</span>
</div>
<div className="mini-list">
{p.insuredDrivers.map((d) => (
<div key={d.id}>
{d.fullName || "—"}
{d.licenseNumber && (
<div className="mini-sub mono">Lic. {d.licenseNumber}</div>
)}
</div>
))}
</div>
</div>
)}
{p.beneficiaries.length > 0 && (
<div className="subpanel">
<div className="subpanel-title">
<span>Beneficiarios</span>
<span>{p.beneficiaries.length}</span>
</div>
<div className="mini-list">
{p.beneficiaries.map((b) => (
<div key={b.id}>
{b.name || "—"}
{(b.phone || b.email) && (
<div className="mini-sub">
{[b.phone, b.email].filter(Boolean).join(" · ")}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
function InstallmentRow({ inst }: { inst: Installment }) {
const method = inst.isCash
? "Efectivo"
: inst.checkNumber
? `Ref. ${inst.checkNumber}`
: null;
return (
<div className="pay-row">
<span style={{ display: "flex", alignItems: "center", gap: 9 }}>
<span className="pay-seq">{inst.sequence}</span>
<span>
{inst.paidDate ? formatDate(inst.paidDate) : "Sin pagar"}
{method && (
<span className="muted" style={{ fontSize: 11 }}>
{" "}
· {method}
</span>
)}
</span>
</span>
<span className="mono" style={{ fontWeight: 600 }}>
{formatMoney(inst.amount, inst.currency)}
</span>
</div>
);
}
/* ------------------------------------------------------- Estado de cuenta */
function EstadoCuentaSection({
summary,
transactions,
}: {
summary: TransactionSummaryRow[];
transactions: Transaction[];
}) {
return (
<section className="section">
<SectionHead
rule="cuenta"
title="Estado de cuenta"
count={transactions.length}
countSuffix="movimientos"
/>
{summary.length > 0 && (
<div className="summary-grid">
{summary.map((row, i) => (
<div className={`summary-card ${row.domain}`} key={i}>
<div className="summary-domain">
<span className={`tx-dot ${row.domain}`} />
{domainLabel(row.domain)} · {row.currency}
</div>
<div className="summary-total">
{formatMoney(row.total, row.currency)}
</div>
<div className="summary-count">
{row.count}{" "}
{row.count === 1 ? "movimiento" : "movimientos"}
</div>
</div>
))}
</div>
)}
<div className="card">
{transactions.length === 0 ? (
<div className="empty-inline">Sin movimientos registrados.</div>
) : (
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Fecha</th>
<th>Línea</th>
<th>Tipo</th>
<th>Referencia</th>
<th>Concepto</th>
<th className="num">Monto</th>
</tr>
</thead>
<tbody>
{transactions.map((t) => (
<TxRow key={t.id} t={t} />
))}
</tbody>
</table>
</div>
)}
{transactions.length >= 100 && (
<div className="section-note" style={{ padding: "0 16px 14px" }}>
Mostrando los 100 movimientos más recientes.
</div>
)}
</div>
</section>
);
}
function TxRow({ t }: { t: Transaction }) {
const num = t.amount != null ? Number(t.amount) : NaN;
const sign = !Number.isNaN(num) && num < 0 ? "neg" : "pos";
const tipo =
t.type?.nameEs || t.type?.nameEn || "—";
const concept = t.message || t.period || "—";
return (
<tr>
<td className="mono" style={{ whiteSpace: "nowrap" }}>
{formatDate(t.transactionDate)}
</td>
<td className="tx-domain-cell">
<span className={`tx-dot ${t.domain}`} />
{domainLabel(t.domain)}
</td>
<td>{tipo}</td>
<td className="tx-ref">{t.reference || "—"}</td>
<td className="tx-concept">{concept}</td>
<td className="num">
<span className={`tx-amount ${sign}`}>
{formatMoney(t.amount, t.currency)}
</span>{" "}
<span className="tx-cur">{t.currency}</span>
</td>
</tr>
);
}
/* ----------------------------------------------------------- Documentos */
function DocumentosSection({ data }: { data: CustomerDetail }) {
type Doc = { type: string; key: string | null; scope: string };
const docs: Doc[] = [];
data.properties.forEach((p) => {
const label = [p.addressLine1].filter(Boolean).join("") || "Propiedad";
p.documents.forEach((d) =>
docs.push({
type: d.documentType || "Documento",
key: d.storageKey,
scope: label,
}),
);
});
data.policies.forEach((p) => {
p.documents.forEach((d) =>
docs.push({
type: d.documentType || "Documento",
key: d.storageKey,
scope: `Póliza ${p.policyNumber ?? ""}`.trim(),
}),
);
});
return (
<section className="section">
<SectionHead rule="docs" title="Documentos" count={docs.length} />
<div className="card">
{docs.length === 0 ? (
<div className="empty-inline">
No hay documentos registrados para este cliente.
</div>
) : (
<>
<div className="doc-list">
{docs.map((d, i) => (
<div className="doc-item" key={i}>
<span className="doc-icon" aria-hidden>
</span>
<div style={{ minWidth: 0 }}>
<div className="doc-type">{d.type}</div>
<div className="doc-key">{d.scope}</div>
</div>
</div>
))}
</div>
<div
className="section-note"
style={{ padding: "0 22px 18px" }}
>
Los archivos se almacenan en el object storage
(storageKey); no se descargan desde esta vista.
</div>
</>
)}
</div>
</section>
);
}
/* -------------------------------------------------------------- helpers */
function SectionHead({
rule,
title,
count,
countSuffix,
}: {
rule: string;
title: string;
count?: number;
countSuffix?: string;
}) {
return (
<div className="section-head">
<span className={`section-rule ${rule}`} aria-hidden />
<h2 className="section-title">{title}</h2>
{count != null && (
<span className="section-count">
{count} {countSuffix ?? ""}
</span>
)}
</div>
);
}
function DetailSkeleton() {
return (
<div>
<div
className="skeleton"
style={{ height: 16, width: 140, marginBottom: 18 }}
/>
<div className="skeleton" style={{ height: 180, borderRadius: 16 }} />
<div
className="skeleton"
style={{ height: 200, borderRadius: 16, marginTop: 34 }}
/>
<div
className="skeleton"
style={{ height: 260, borderRadius: 16, marginTop: 34 }}
/>
</div>
);
}