"use client"; import { useEffect, useState } from "react"; import Link from "next/link"; import { AppShell } from "@/components/AppShell"; import { addService, archiveProperty, getProperty, propertyDocumentDownloadUrl, removePropertyDocument, removeService, removeTrust, restoreProperty, updateService, uploadPropertyDocument, upsertTrust, } from "@/lib/api"; import { useCan } from "@/lib/abilities"; import { ChildCollection, type ChildConfig } from "@/components/ChildCollection"; import { expiryPhrase, formatDate, formatMoney, formatNumber, SERVICE_KIND_LABELS, serviceKindGlyph, serviceKindLabel, serviceNoteLabel, SIN_NOMBRE, trustStatusLabel, } from "@/lib/labels"; import type { PropertyDetail, Service, Transaction, TrustInput } from "@/lib/types"; export default function PropiedadDetailPage({ params, }: { params: { id: string }; }) { // Next 14 passes `params` as a plain object here — no `use()` unwrapping. 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); getProperty(id) .then((d) => { if (alive) { setData(d); setLoading(false); } }) .catch((e) => { if (alive) { setError( e?.status === 404 ? "No encontramos esta propiedad." : e?.message ?? "No se pudo cargar la propiedad.", ); setLoading(false); } }); return () => { alive = false; }; }, [id]); if (loading) return ; if (error) return ( <>
{error}
); if (!data) return null; const reload = () => getProperty(id).then(setData).catch(() => {}); return (
{data.policy && }
); } /** Edit / archive controls for the property header. */ function PropertyActions({ data, onChange, }: { data: PropertyDetail; onChange: () => void; }) { const canEdit = useCan("property:update"); const canDelete = useCan("property:delete"); const [busy, setBusy] = useState(false); const archived = data.archivedAt != null; async function toggle() { const verb = archived ? "restaurar" : "archivar"; if (!window.confirm(`¿Seguro que desea ${verb} esta propiedad?`)) return; setBusy(true); try { if (archived) await restoreProperty(data.id); else await archiveProperty(data.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 && Archivada} {canEdit && ( Editar )} {canDelete && ( )}
); } /** Editable services + trust + documents — only for users who can edit. */ function PropertyEditor({ data, onChange, }: { data: PropertyDetail; onChange: () => void; }) { const canEdit = useCan("property:update"); if (!canEdit) return null; const SERVICES: ChildConfig = { apiKind: "services", title: "Servicios", fields: [ { key: "kind", label: "Tipo", type: "select", options: Object.entries(SERVICE_KIND_LABELS).map(([value, label]) => ({ value, label, })), }, { key: "accountNumber", label: "Cuenta" }, { key: "meterNumber", label: "Medidor" }, { key: "route", label: "Ruta" }, { key: "dueDay", label: "Día pago" }, { key: "notes", label: "Notas" }, { key: "active", label: "Activo", type: "checkbox" }, ], }; return (

Administrar propiedad

[]} canEdit={canEdit} onAdd={async (p) => { await addService(data.id, p as never); onChange(); }} onSave={async (sid, p) => { await updateService(data.id, sid, p as never); onChange(); }} onRemove={async (sid) => { await removeService(data.id, sid); onChange(); }} />
); } /** Upload / download / delete document blobs stored in object storage (MinIO). */ function DocumentsEditor({ data, onChange, }: { data: PropertyDetail; onChange: () => void; }) { const [file, setFile] = useState(null); const [type, setType] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); async function upload() { if (!file) return; setBusy(true); setError(null); try { await uploadPropertyDocument(data.id, file, type.trim() || undefined); setFile(null); setType(""); onChange(); } catch (e) { setError((e as Error)?.message ?? "No se pudo subir el archivo."); } finally { setBusy(false); } } return (

Documentos

{data.documents.length > 0 && (
{data.documents.map((d) => ( ))}
TipoClaveAcción
{d.documentType ?? "—"} {d.storageKey ?? "—"}
{d.id && ( Descargar )}
)} {error &&
{error}
}
setType(e.target.value)} /> setFile(e.target.files?.[0] ?? null)} />
); } /** Trust is 1:1 — a small inline form that upserts or clears it. */ function TrustEditor({ data, onChange, }: { data: PropertyDetail; onChange: () => void; }) { const t = data.trustAccount; const [bankName, setBankName] = useState(t?.bankName ?? ""); const [trustNumber, setTrustNumber] = useState(t?.trustNumber ?? ""); const [bankFee, setBankFee] = useState(t?.bankFee != null ? String(t.bankFee) : ""); const [dueDate1, setDueDate1] = useState(toDateInput(t?.dueDate1)); const [dueDate2, setDueDate2] = useState(toDateInput(t?.dueDate2)); const [busy, setBusy] = useState(false); async function save() { setBusy(true); const input: TrustInput = { bankName: bankName.trim() || undefined, trustNumber: trustNumber.trim() || undefined, bankFee: bankFee.trim() === "" ? undefined : Number(bankFee), dueDate1: dueDate1 || undefined, dueDate2: dueDate2 || undefined, }; try { await upsertTrust(data.id, input); onChange(); } catch (e) { window.alert((e as Error)?.message ?? "No se pudo guardar el fideicomiso."); } finally { setBusy(false); } } async function clear() { if (!window.confirm("¿Eliminar el fideicomiso de esta propiedad?")) return; try { await removeTrust(data.id); onChange(); } catch (e) { window.alert((e as Error)?.message ?? "No se pudo eliminar."); } } return (

Fideicomiso

{t && ( )}
); } function toDateInput(v: string | null | undefined): string { if (!v) return ""; const d = new Date(v); return isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10); } function BackLink() { return ( ← Volver a Propiedades ); } /* ------------------------------------------------------------------ Hero */ function Hero({ data }: { data: PropertyDetail }) { const addr = [data.addressLine1, data.addressLine2].filter(Boolean).join(", "); const phones = [data.phone1, data.phone2, data.phone3].filter(Boolean); const provenance = [data.legacySourceTable, data.legacyId] .filter(Boolean) .join(" #"); const activos = data.services.filter((s) => s.active).length; const phrase = expiryPhrase(data.daysToTrustDue); const facts: { label: string; value: string }[] = [ { label: "Cliente", value: data.customer.name }, { label: "Servicios", value: data.services.length === 0 ? "Ninguno" : `${activos} activos de ${data.services.length}`, }, { label: "Municipio", value: data.municipality || "—" }, { label: "Fideicomiso", value: data.trustAccount ? formatDate(data.trustAccount.dueDate2) : "Sin fideicomiso", }, { label: "Teléfonos", value: phones.join(" · ") || "—" }, ]; return (

{addr || "Propiedad sin dirección"}

{data.zone ? `Zona ${data.zone} · ` : ""} {data.customer.name}
{provenance && (
Origen: {provenance}
)}
{data.municipality && ( {data.municipality} )} {data.trustAccount ? ( Fideicomiso · {trustStatusLabel(data.trustStatus)} {phrase && data.trustStatus !== "expired" ? ` · ${phrase}` : ""} ) : ( Sin fideicomiso )} {data.services.length === 0 && ( Sin servicios )}
{facts.map((f) => (
{f.label}
{f.value}
))}
); } /* -------------------------------------------------------------- Cliente */ function ClienteSection({ data }: { data: PropertyDetail }) { const c = data.customer; const location = [c.city?.replace(/,\s*$/, ""), c.state] .filter(Boolean) .join(", "); return (
{c.name}
{location && {location}} {location && (c.phone || c.mobile || c.email) && ( · )} {(c.phone || c.mobile) && {c.phone || c.mobile}} {c.email && ( <> · {c.email} )} {c._count.policies > 0 && ( <> · {c._count.policies}{" "} {c._count.policies === 1 ? "póliza" : "pólizas"} )}
Ver expediente → {data.siblings.length > 0 && (
Otras propiedades de este cliente ({data.siblings.length})
{data.siblings.map((s) => ( {[s.addressLine1, s.addressLine2].filter(Boolean).join(", ") || "Propiedad"} {s.zone ? ` · Zona ${s.zone}` : ""} · {s.serviceCount}{" "} {s.serviceCount === 1 ? "servicio" : "servicios"} ))}
)}
); } /* ------------------------------------------------------------- Servicios */ function ServiciosSection({ data }: { data: PropertyDetail }) { return (
{data.services.length === 0 ? (
Esta propiedad no tiene servicios registrados.
) : (
{data.services.map((s) => ( ))}
)}
); } function ServiceCard({ s }: { s: Service }) { const noteLabel = serviceNoteLabel(s.kind); return (
{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 && ( {noteLabel ? `${noteLabel}: ` : ""} {s.notes} )}
); } /* ---------------------------------------------------------- Fideicomiso */ function FideicomisoSection({ data }: { data: PropertyDetail }) { const t = data.trustAccount; const phrase = expiryPhrase(data.daysToTrustDue); return (
{!t ? (
Esta propiedad no tiene fideicomiso registrado.
) : ( <>
La comisión bancaria se cobra cada año en el próximo vencimiento; el sistema anterior guardaba el par de fechas (vence1 / vence2) del periodo en curso y del siguiente.
)}
); } /* --------------------------------------------------------------- Póliza */ function PolizaSection({ data }: { data: PropertyDetail }) { const p = data.policy!; return (
{p.policyNumber || "—"}
{p.policyType?.name && {p.policyType.name}} {p.policyTo && ( <> · Vence {formatDate(p.policyTo)} )}
Ver póliza →
); } /* ---------------------------------------------------------- Movimientos */ function MovimientosSection({ data }: { data: PropertyDetail }) { return (
{data.customerLedger.length > 0 && (
{data.customerLedger.map((row) => (
Servicios · {row.currency}
{formatMoney(row.total, row.currency)}
{formatNumber(row.count)}{" "} {row.count === 1 ? "movimiento" : "movimientos"}
))}
)}
{data.customerTransactions.length === 0 ? (
Sin movimientos de servicios.
) : (
{data.customerTransactions.map((t) => ( ))}
Fecha Tipo Periodo Referencia Monto
)}
Los movimientos pertenecen al cliente, no a esta propiedad: el sistema anterior nunca ligó un pago a una propiedad concreta. Ver el{" "} estado de cuenta completo .
); } function TxRow({ t }: { t: Transaction }) { const num = t.amount != null ? Number(t.amount) : NaN; const sign = !Number.isNaN(num) && num < 0 ? "neg" : "pos"; return ( {formatDate(t.transactionDate)} {t.type?.nameEs || t.type?.nameEn || "—"} {t.period || "—"} {t.reference || "—"} {formatMoney(t.amount, t.currency)} {" "} {t.currency} ); } /* ----------------------------------------------------------- Documentos */ function DocumentosSection({ data }: { data: PropertyDetail }) { return (
{data.documents.length === 0 ? (
No hay documentos registrados para esta propiedad.
) : ( <>
{data.documents.map((d, i) => (
{d.documentType || "Documento"}
{d.storageKey || "—"}
{d.id && ( Descargar )}
))}
)}
); } /* ------------------------------------------------------------ helpers */ function KV({ label, value, }: { label: string; value: string | null | undefined; }) { return (
{label}
{value || "—"}
); } function SectionHead({ rule, title, count, countSuffix, }: { rule: string; title: string; count?: number; countSuffix?: string; }) { return (

{title}

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