The --sync path had never been run and was broken in several ways. Fixed and verified against the dev DB (two consecutive syncs, both exit 0, 32/32 assertions: stable PKs, manual-row preservation, changed-row updates, legacy-delete, no child duplication, zero FK orphans; idempotent). - policies/properties: reuse each legacy row's existing id (by provenance) BEFORE building child rows, so children no longer point at a discarded fresh uuid; rebuild legacy-owned children via scoped delete + reinsert. - customers: replace zip(customers, refs) (mispaired almost every row) with a ref-grouped id remap; names now restore and no spurious customers appear. - drop the invalid Vehicle @@unique(legacySourceTable, legacyId) — one legacy policy row carries up to 3 vehicles sharing a legacyId; handle via delete+reinsert. - upsert lookup tables (policy_types, insurance_providers, type_transactions, adjusters) by natural name and remap child FKs instead of inserting fresh uuids that nothing points at. - transactions: drop updatedAt=NOW() (no such column); guard report formatting on NULL legacySourceTable (manual rows). Same report guard in bank. - add manual-safe prune (prune_empty_customers.py --sync, in SYNC_STEPS): prune only legacy-owned empties, never manually-added customers. web: customer-detail mini tx list now strikes voided rows with an "(anulado)" tag (was the last void-UI rendering gap; /estado-cuenta already handled it). docs: RESUME.md updated — Phase B sync marked verified end-to-end, void-UI browser pass recorded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
916 lines
25 KiB
TypeScript
916 lines
25 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,
|
||
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}
|
||
/>
|
||
<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 [busy, setBusy] = useState(false);
|
||
const archived = customer.archivedAt != null;
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
if (!canEdit && !canDelete) return null;
|
||
|
||
return (
|
||
<div className="row-actions">
|
||
{archived && <span className="badge badge-negative">Archivado</span>}
|
||
{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: "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,
|
||
}: {
|
||
customerId: string;
|
||
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>
|
||
{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>
|
||
);
|
||
}
|