Files
jorgecuadros-platform/apps/web/src/app/clientes/[id]/page.tsx
T
rmancinasandClaude Opus 4.8 da0fa3cb47 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>
2026-07-22 20:21:36 -07:00

766 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}