(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 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 (
{archived && Archivado }
{showPortal && (
Habilitar acceso al portal
)}
{canEdit && (
Editar
)}
{canDelete && (
{archived ? "Restaurar" : "Archivar"}
)}
);
}
/* ------------------------------------------------------------------ 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 (
{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,
year,
}: {
customerId: string;
summary: TransactionSummaryRow[];
transactions: Transaction[];
year: number;
}) {
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 en {year}.
) : (
Fecha
Línea
Tipo
Referencia
Concepto
Monto
{transactions.map((t) => (
))}
)}
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.
{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 (
);
}