(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"}
);
}
/* ------------------------------------------------------- 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 && (
)}
);
}
function KV({
label,
value,
mono,
}: {
label: string;
value: string | null | undefined;
mono?: boolean;
}) {
return (
);
}
/* ----------------------------------------------- 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.
) : (
| Fecha |
Línea |
Tipo |
Referencia |
Concepto |
Monto |
{transactions.map((t) => (
))}
)}
{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) => (
))}
)}
);
}
/* -------------------------------------------------------------- helpers */
function SectionHead({
rule,
title,
count,
countSuffix,
}: {
rule: string;
title: string;
count?: number;
countSuffix?: string;
}) {
return (
{title}
{count != null && (
{count} {countSuffix ?? ""}
)}
);
}
function DetailSkeleton() {
return (
);
}