"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 ( ); } function Detail({ id }: { id: string }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 ; if (error) return ( <>
{error}
); if (!data) return null; const hasUtilities = data.properties.length > 0; const hasInsurance = data.policies.length > 0; return (
getCustomer(id).then(setData).catch(() => {})} />
); } function BackLink() { return ( ← Volver a Clientes ); } /** 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 (
{archived && Archivado} {canEdit && ( Editar )} {canDelete && ( )}
); } /* ------------------------------------------------------------------ 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 (

{data.name}

{data.nameSource && (
Nombre recuperado de {data.nameSource} — el registro original no tenía nombre.
)} {provenance && (
Origen: {provenance}
)}
{hasUtilities && ( Servicios )} {hasInsurance && ( Seguros )} {data.status ? "Activo" : "Inactivo"}
{facts.map((f) => (
{f.label}
{f.value}
))}
); } /* ------------------------------------------------------- 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 (
{(mxAddress || cityLine) && (
Domicilio
{mxAddress &&
{mxAddress}
} {cityLine &&
{cityLine}
} {!mxAddress && !cityLine && "—"}
)} {data.notes && (
Notas
{data.notes}
)}
); } function KV({ label, value, mono, }: { label: string; value: string | null | undefined; mono?: boolean; }) { return (
{label}
{value || "—"}
); } /* ----------------------------------------------- Propiedades y servicios */ function PropiedadesSection({ properties, customerId, customerName, }: { properties: Property[]; customerId: string; customerName: string; }) { const canCreate = useCan("property:create"); return (
{canCreate && ( + Nueva propiedad )}
{properties.length === 0 ? (
Este cliente no tiene propiedades registradas.
) : ( properties.map((p) => ) )}
); } 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 (
{addr || "Propiedad"}
{p.zone && Zona: {p.zone}} {phones.length > 0 && ( {phones.join(" · ")} )}
{p.services.length > 0 && (
{p.services.map((s) => (
{serviceKindGlyph(s.kind)} {serviceKindLabel(s.kind)} {!s.active && ( Inactivo )}
{s.accountNumber && ( Cuenta: {s.accountNumber} )} {s.meterNumber && ( Medidor: {s.meterNumber} )} {s.route && ( Ruta: {s.route} )} {s.dueDay && Día de pago: {s.dueDay}} {s.notes && {s.notes}}
))}
)} {p.trustAccount && (
Fideicomiso
{p.trustAccount.bankName && ( Banco: {p.trustAccount.bankName} )} {p.trustAccount.trustNumber && ( No.:{" "} {p.trustAccount.trustNumber} )} {p.trustAccount.bankFee && ( Comisión:{" "} {formatMoney(p.trustAccount.bankFee, "MXN")} )} {p.trustAccount.dueDate1 && ( Vigencia:{" "} {formatDate(p.trustAccount.dueDate1)} {p.trustAccount.dueDate2 ? ` – ${formatDate(p.trustAccount.dueDate2)}` : ""} )}
)}
); } /* ------------------------------------------------------ Pólizas de seguro */ function PolizasSection({ policies, customerId, customerName, }: { policies: Policy[]; customerId: string; customerName: string; }) { const canCreate = useCan("policy:create"); return (
{canCreate && ( + Nueva póliza )}
{policies.length === 0 ? (
Este cliente no tiene pólizas registradas.
) : ( policies.map((p) => ) )}
); } function PolicyCard({ p }: { p: Policy }) { const { value: headline, label: headlineLabel } = premiumHeadline(p); return (
{p.policyNumber || "—"} →
{p.policyType?.name && ( {p.policyType.name} )} {p.insuranceProvider?.name && ( {p.insuranceProvider.name} )} {p.agentName && ( <> · Agente: {p.agentName} )} {p.liquidated ? "Liquidada" : "Pendiente"}
Vigencia: {formatDate(p.policyFrom)} – {formatDate(p.policyTo)}
{formatMoney(headline, p.currency)}
{headlineLabel}
{p.installments.length > 0 && (
Pagos {p.installments.length}
{p.installments.map((inst) => ( ))}
)} {p.vehicles.length > 0 && (
Vehículos {p.vehicles.length}
{p.vehicles.map((v) => (
{[v.make, v.model, v.modelYear].filter(Boolean).join(" ") || "Vehículo"}
{[ v.bodyType, v.licensePlate ? `Placa ${v.licensePlate}` : null, ] .filter(Boolean) .join(" · ")}
))}
)} {p.insuredDrivers.length > 0 && (
Asegurados {p.insuredDrivers.length}
{p.insuredDrivers.map((d) => (
{d.fullName || "—"} {d.licenseNumber && (
Lic. {d.licenseNumber}
)}
))}
)} {p.beneficiaries.length > 0 && (
Beneficiarios {p.beneficiaries.length}
{p.beneficiaries.map((b) => (
{b.name || "—"} {(b.phone || b.email) && (
{[b.phone, b.email].filter(Boolean).join(" · ")}
)}
))}
)}
); } function InstallmentRow({ inst }: { inst: Installment }) { const method = inst.isCash ? "Efectivo" : inst.checkNumber ? `Ref. ${inst.checkNumber}` : null; return (
{inst.sequence} {inst.paidDate ? formatDate(inst.paidDate) : "Sin pagar"} {method && ( {" "} · {method} )} {formatMoney(inst.amount, inst.currency)}
); } /* ------------------------------------------------------- Estado de cuenta */ function EstadoCuentaSection({ customerId, summary, transactions, }: { customerId: string; summary: TransactionSummaryRow[]; transactions: Transaction[]; }) { return (
{summary.length > 0 && (
{summary.map((row, i) => (
{domainLabel(row.domain)} · {row.currency}
{formatMoney(row.total, row.currency)}
{row.count}{" "} {row.count === 1 ? "movimiento" : "movimientos"}
))}
)}
{transactions.length === 0 ? (
Sin movimientos registrados.
) : (
{transactions.map((t) => ( ))}
Fecha Línea Tipo Referencia Concepto Monto
)} {transactions.length >= 100 && (
Mostrando los 100 movimientos más recientes.
)}
{transactions.length > 0 && (

Ver estado de cuenta completo → {" "} con saldo, saldo corrido y desglose por línea de negocio.

)}
); } 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 ( {formatDate(t.transactionDate)} {domainLabel(t.domain)} {tipo} {t.reference || "—"} {concept} {voided && ( (anulado) )} {formatMoney(t.amount, t.currency)} {" "} {t.currency} ); } /* ----------------------------------------------------------- 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 (
{docs.length === 0 ? (
No hay documentos registrados para este cliente.
) : (
{docs.map((d, i) => (
{d.type}
{d.scope}
{d.href && ( Descargar )}
))}
)}
); } /* -------------------------------------------------------------- helpers */ function SectionHead({ rule, title, count, countSuffix, }: { rule: string; title: string; count?: number; countSuffix?: string; }) { return (

{title}

{count != null && ( {count} {countSuffix ?? ""} )}
); } function DetailSkeleton() { return (
); }