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>
967 lines
27 KiB
TypeScript
967 lines
27 KiB
TypeScript
"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>
|
||
);
|
||
}
|