Files
jorgecuadros-platform/apps/web/src/app/clientes/[id]/page.tsx
T
rmancinasandClaude Opus 5.5 b016e53893
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m42s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m17s
feat(customers): a shelf folder number the office can hand back out
The office files every customer in a numbered paper folder and reuses the
number once an account closes. That number was the legacy NUMid, which is
also an import key and the portal login, so reusing it meant re-pointing an
identity. It is now customers.folderNumber: a plain label nothing joins on.

- Unique only among non-archived customers, checked in CustomersService
  (create, update, restore). Archived customers keep theirs as history.
- GET /customers/folder/next suggests the lowest free number; the new
  customer form pre-fills it.
- Backfilled from the utilities NUMid (dev: 1,171 folders, 0 shared); the
  importer copies it on load. Insurance-only customers start without one.
- Reports, the printed statement and the customer file show "Carpeta"
  instead of NUMid. The list searches by it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:04:01 -07:00

967 lines
27 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 { ContextReports } from "@/components/ContextReports";
import {
archiveCustomer,
getCustomer,
grantPortalAccess,
policyDocumentDownloadUrl,
propertyDocumentDownloadUrl,
restoreCustomer,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
import {
domainLabel,
formatDate,
formatMoney,
premiumHeadline,
serviceKindGlyph,
serviceKindLabel,
SIN_NOMBRE,
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">
<div className="detail-actionbar">
<BackLink />
<ContextReports
entries={[
{
slug: "edo-cuenta-datos",
label: "Estado de cuenta (reporte)",
params: { customerId: data.id },
},
]}
/>
<CustomerActions
customer={data}
onChange={() => getCustomer(id).then(setData).catch(() => {})}
/>
</div>
<Hero data={data} hasUtilities={hasUtilities} hasInsurance={hasInsurance} />
<DatosSection data={data} />
<PropiedadesSection
properties={data.properties}
customerId={data.id}
customerName={data.name}
/>
<PolizasSection
policies={data.policies}
customerId={data.id}
customerName={data.name}
/>
<EstadoCuentaSection
customerId={data.id}
summary={data.transactionSummary}
transactions={data.transactions}
year={data.transactionYear}
/>
<DocumentosSection data={data} />
</div>
);
}
function BackLink() {
return (
<Link href="/clientes" className="back-link">
← Volver a Clientes
</Link>
);
}
/** Edit / archive controls, each gated by the matching ability. */
function CustomerActions({
customer,
onChange,
}: {
customer: CustomerDetail;
onChange: () => void;
}) {
const canEdit = useCan("customer:update");
const canDelete = useCan("customer:delete");
const canGrantPortal = useCan("customer:portal-access");
const [busy, setBusy] = useState(false);
const archived = customer.archivedAt != null;
// The portal NUMid is a legacy ref, not a column: (utilities, DATGRAL) is the
// "Security Number" my.jorgecuadros.com asks for. An insurance ref is a
// different id space entirely and does not let anyone log in, so both columns
// are checked — matching on sourceTable alone would hide the button from
// customers who cannot actually reach the portal.
const hasPortalId = customer.legacyRefs.some(
(r) => r.sourceSystem === "utilities" && r.sourceTable === "DATGRAL",
);
async function grantPortal() {
if (
!window.confirm(
"¿Asignar un número de portal a este cliente? Con él podrá entrar a " +
"my.jorgecuadros.com.",
)
)
return;
setBusy(true);
try {
const { numid, origin } = await grantPortalAccess(customer.id);
window.alert(
origin === "existing"
? `Este cliente ya tenía el número de portal ${numid}.`
: `Número de portal asignado: ${numid}.`,
);
onChange();
} catch (e) {
window.alert((e as Error)?.message ?? "No se pudo completar la acción.");
} finally {
setBusy(false);
}
}
async function toggleArchive() {
const verb = archived ? "restaurar" : "archivar";
if (!window.confirm(`¿Seguro que desea ${verb} este cliente?`)) return;
setBusy(true);
try {
if (archived) await restoreCustomer(customer.id);
else await archiveCustomer(customer.id);
onChange();
} catch (e) {
window.alert((e as Error)?.message ?? "No se pudo completar la acción.");
} finally {
setBusy(false);
}
}
const showPortal = canGrantPortal && !hasPortalId && !archived;
if (!canEdit && !canDelete && !showPortal) return null;
return (
<div className="row-actions">
{archived && <span className="badge badge-negative">Archivado</span>}
{showPortal && (
<button
type="button"
className="btn btn-outline"
onClick={grantPortal}
disabled={busy}
title="Asigna el número que el cliente usa para entrar al portal"
>
Habilitar acceso al portal
</button>
)}
{canEdit && (
<Link href={`/clientes/${customer.id}/editar`} className="btn btn-outline">
Editar
</Link>
)}
{canDelete && (
<button
type="button"
className="btn btn-ghost"
onClick={toggleArchive}
disabled={busy}
>
{archived ? "Restaurar" : "Archivar"}
</button>
)}
</div>
);
}
/* ------------------------------------------------------------------ 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: "Carpeta", value: data.folderNumber != null ? String(data.folderNumber) : "—" },
{ 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 === SIN_NOMBRE ? " hero-name-missing" : ""
}`}
>
{data.name}
</h1>
{data.nameSource && (
<div className="hero-provenance">
Nombre recuperado de {data.nameSource} — el registro original no
tenía nombre.
</div>
)}
{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,
customerId,
customerName,
}: {
properties: Property[];
customerId: string;
customerName: string;
}) {
const canCreate = useCan("property:create");
return (
<section className="section">
<div className="detail-actionbar">
<SectionHead
rule="servicios"
title="Propiedades y servicios"
count={properties.length}
/>
{canCreate && (
<Link
href={`/servicios/nuevo?customerId=${customerId}&customerName=${encodeURIComponent(customerName)}`}
className="btn btn-outline"
>
+ Nueva propiedad
</Link>
)}
</div>
<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">
<Link href={`/servicios/${p.id}`} className="policy-num-link">
{addr || "Propiedad"}
</Link>
</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,
customerId,
customerName,
}: {
policies: Policy[];
customerId: string;
customerName: string;
}) {
const canCreate = useCan("policy:create");
return (
<section className="section">
<div className="detail-actionbar">
<SectionHead
rule="seguros"
title="Pólizas de seguro"
count={policies.length}
/>
{canCreate && (
<Link
href={`/polizas/nuevo?customerId=${customerId}&customerName=${encodeURIComponent(customerName)}`}
className="btn btn-outline"
>
+ Nueva póliza
</Link>
)}
</div>
<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 { value: headline, label: headlineLabel } = premiumHeadline(p);
return (
<div className="policy-card">
<div className="policy-head">
<div>
<Link href={`/polizas/${p.id}`} className="policy-num policy-num-link">
{p.policyNumber || "—"} →
</Link>
<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>
</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({
customerId,
summary,
transactions,
year,
}: {
customerId: string;
summary: TransactionSummaryRow[];
transactions: Transaction[];
year: number;
}) {
return (
<section className="section">
<SectionHead
rule="cuenta"
title={`Estado de cuenta ${year}`}
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 en {year}.</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>
)}
<div className="section-note" style={{ padding: "0 16px 14px" }}>
Movimientos de {year}, del más antiguo al más reciente. Los saldos de
arriba son el saldo actual por línea de negocio — los mismos del
estado de cuenta, no la suma del año.
</div>
</div>
{transactions.length > 0 && (
<p className="section-note">
<Link href={`/estado-cuenta/${customerId}`} className="inline-link">
Ver estado de cuenta completo →
</Link>{" "}
con saldo, saldo corrido y desglose por línea de negocio.
</p>
)}
</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 || "—";
const voided = !!t.voidedAt;
return (
<tr style={voided ? { textDecoration: "line-through", opacity: 0.55 } : undefined}>
<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}
{voided && (
<span className="tx-cur" style={{ marginLeft: 6, textDecoration: "none" }}>
(anulado)
</span>
)}
</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; scope: string; href: string | null };
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",
scope: label,
href: d.id ? propertyDocumentDownloadUrl(p.id, d.id) : null,
}),
);
});
data.policies.forEach((p) => {
p.documents.forEach((d) =>
docs.push({
type: d.documentType || "Documento",
scope: `Póliza ${p.policyNumber ?? ""}`.trim(),
href: d.id ? policyDocumentDownloadUrl(p.id, d.id) : null,
}),
);
});
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, flex: 1 }}>
<div className="doc-type">{d.type}</div>
<div className="doc-key">{d.scope}</div>
</div>
{d.href && (
<a className="btn btn-ghost" href={d.href}>
Descargar
</a>
)}
</div>
))}
</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>
);
}