Utilities module: property browser (list/search/detail) + trust renewals

Plan step 5. Properties, services and trust accounts become a first-class
browser the way /polizas is for insurance.

API (apps/api/src/properties):
  GET /properties         search over address, customer, service account
                          number, meter, trust number and phones; filters for
                          service kind, municipality, trust bank, trust bucket
                          (with|without|active|expiring|expired|undated) and
                          hasServices; 5 sorts
  GET /properties/stats   properties/owners/services/trusts, renewal counts,
                          service mix per kind
  GET /properties/facets  kinds, municipalities, banks — all with counts
  GET /properties/:id     services, fideicomiso, linked policy, owner and
                          sibling properties, owner-level utility ledger

Web: /servicios (renewals-first browser, clickable stat cells and service-mix
strip) and /servicios/[id]. Property cards on /clientes/[id] and linked
properties on /polizas/[id] now navigate into it.

Data findings baked into the design:
  - The trust deadline staff chase is trust_accounts.dueDate2 (DATMEX vence2),
    one year after vence1 on 531 of 541 dated trusts: 18 due within 30 days,
    119 already overdue. Every renewal bucket keys off dueDate2 alone.
  - properties.zone is dead (1444 of 1519 null, the rest near-unique), so the
    geographic filter is the municipality carried in the predial service's
    notes (ROSARITO 566 / TIJUANA 221 / ENSENADA 152, 939/939 populated).
  - PropertyService.notes means a different thing per kind (municipality, CFE
    PAR/IMPAR cycle, gas supply type, cable provider) and is labelled as such.
  - 240 of 1519 properties have no service rows at all — its own bucket.

Sorting by trust due date scopes to properties that have a trust, since MySQL
would otherwise float the ~966 trust-less NULLs above every real due date;
the sort label and the result meta both say so.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 22:04:07 -07:00
co-authored by Claude Opus 4.8
parent c291bc8d4c
commit 61193586a5
14 changed files with 2086 additions and 7 deletions
+566
View File
@@ -0,0 +1,566 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { getProperty } from "@/lib/api";
import {
expiryPhrase,
formatDate,
formatMoney,
formatNumber,
serviceKindGlyph,
serviceKindLabel,
serviceNoteLabel,
SIN_NOMBRE,
trustStatusLabel,
} from "@/lib/labels";
import type { PropertyDetail, Service, Transaction } 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 (
<AppShell>
<Detail id={id} />
</AppShell>
);
}
function Detail({ id }: { id: string }) {
const [data, setData] = useState<PropertyDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <DetailSkeleton />;
if (error)
return (
<>
<BackLink />
<div className="state-error" role="alert">
{error}
</div>
</>
);
if (!data) return null;
return (
<div className="rise">
<BackLink />
<Hero data={data} />
<ClienteSection data={data} />
<ServiciosSection data={data} />
<FideicomisoSection data={data} />
{data.policy && <PolizaSection data={data} />}
<MovimientosSection data={data} />
<DocumentosSection data={data} />
</div>
);
}
function BackLink() {
return (
<Link href="/servicios" className="back-link">
Volver a Propiedades
</Link>
);
}
/* ------------------------------------------------------------------ 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 (
<div className="detail-hero">
<div className="hero-top">
<div>
<h1 className="hero-name">{addr || "Propiedad sin dirección"}</h1>
<div className="hero-provenance">
{data.zone ? `Zona ${data.zone} · ` : ""}
{data.customer.name}
</div>
{provenance && (
<div className="hero-provenance">Origen: {provenance}</div>
)}
</div>
<div className="hero-badges">
{data.municipality && (
<span className="badge badge-servicios">
<span className="dot" /> {data.municipality}
</span>
)}
{data.trustAccount ? (
<span className={`badge status-${data.trustStatus}`}>
Fideicomiso · {trustStatusLabel(data.trustStatus)}
{phrase && data.trustStatus !== "expired" ? ` · ${phrase}` : ""}
</span>
) : (
<span className="badge badge-on-dark">Sin fideicomiso</span>
)}
{data.services.length === 0 && (
<span className="badge badge-neutral">Sin servicios</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>
);
}
/* -------------------------------------------------------------- Cliente */
function ClienteSection({ data }: { data: PropertyDetail }) {
const c = data.customer;
const location = [c.city?.replace(/,\s*$/, ""), c.state]
.filter(Boolean)
.join(", ");
return (
<section className="section">
<SectionHead rule="datos" title="Cliente" />
<div className="card">
<Link href={`/clientes/${c.id}`} className="owner-link">
<div>
<div
className={`owner-name${
c.name === SIN_NOMBRE ? " cust-name-missing" : ""
}`}
>
{c.name}
</div>
<div className="cust-sub">
{location && <span>{location}</span>}
{location && (c.phone || c.mobile || c.email) && (
<span className="sep">·</span>
)}
{(c.phone || c.mobile) && <span>{c.phone || c.mobile}</span>}
{c.email && (
<>
<span className="sep">·</span>
<span>{c.email}</span>
</>
)}
{c._count.policies > 0 && (
<>
<span className="sep">·</span>
<span>
{c._count.policies}{" "}
{c._count.policies === 1 ? "póliza" : "pólizas"}
</span>
</>
)}
</div>
</div>
<span className="owner-cta">Ver expediente </span>
</Link>
{data.siblings.length > 0 && (
<div className="linked-props">
<div className="kv-label">
Otras propiedades de este cliente ({data.siblings.length})
</div>
{data.siblings.map((s) => (
<Link key={s.id} href={`/servicios/${s.id}`} className="linked-prop link">
{[s.addressLine1, s.addressLine2].filter(Boolean).join(", ") ||
"Propiedad"}
<span className="muted">
{s.zone ? ` · Zona ${s.zone}` : ""} · {s.serviceCount}{" "}
{s.serviceCount === 1 ? "servicio" : "servicios"}
</span>
</Link>
))}
</div>
)}
</div>
</section>
);
}
/* ------------------------------------------------------------- Servicios */
function ServiciosSection({ data }: { data: PropertyDetail }) {
return (
<section className="section">
<SectionHead
rule="servicios"
title="Servicios"
count={data.services.length}
/>
<div className="card">
{data.services.length === 0 ? (
<div className="empty-inline">
Esta propiedad no tiene servicios registrados.
</div>
) : (
<div className="svc-grid" style={{ padding: 16 }}>
{data.services.map((s) => (
<ServiceCard key={s.id} s={s} />
))}
</div>
)}
</div>
</section>
);
}
function ServiceCard({ s }: { s: Service }) {
const noteLabel = serviceNoteLabel(s.kind);
return (
<div 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>
{noteLabel ? `${noteLabel}: ` : ""}
{s.notes}
</span>
)}
</div>
</div>
);
}
/* ---------------------------------------------------------- Fideicomiso */
function FideicomisoSection({ data }: { data: PropertyDetail }) {
const t = data.trustAccount;
const phrase = expiryPhrase(data.daysToTrustDue);
return (
<section className="section">
<SectionHead rule="cuenta" title="Fideicomiso" />
<div className="card">
{!t ? (
<div className="empty-inline">
Esta propiedad no tiene fideicomiso registrado.
</div>
) : (
<>
<div className="kv-grid">
<KV label="Banco" value={t.bankName} />
<KV label="Número de fideicomiso" value={t.trustNumber} />
<KV
label="Comisión anual"
value={formatMoney(t.bankFee, "MXN")}
/>
<KV label="Vigencia desde" value={formatDate(t.dueDate1)} />
<KV
label="Próximo vencimiento"
value={
t.dueDate2
? `${formatDate(t.dueDate2)}${phrase ? ` · ${phrase}` : ""}`
: null
}
/>
<KV
label="Estado"
value={trustStatusLabel(data.trustStatus)}
/>
</div>
<div className="section-note" style={{ padding: "0 22px 18px" }}>
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.
</div>
</>
)}
</div>
</section>
);
}
/* --------------------------------------------------------------- Póliza */
function PolizaSection({ data }: { data: PropertyDetail }) {
const p = data.policy!;
return (
<section className="section">
<SectionHead rule="seguros" title="Póliza vinculada" />
<div className="card">
<Link href={`/polizas/${p.id}`} className="owner-link">
<div>
<div className="owner-name mono">{p.policyNumber || "—"}</div>
<div className="cust-sub">
{p.policyType?.name && <span>{p.policyType.name}</span>}
{p.policyTo && (
<>
<span className="sep">·</span>
<span>Vence {formatDate(p.policyTo)}</span>
</>
)}
</div>
</div>
<span className="owner-cta">Ver póliza </span>
</Link>
</div>
</section>
);
}
/* ---------------------------------------------------------- Movimientos */
function MovimientosSection({ data }: { data: PropertyDetail }) {
return (
<section className="section">
<SectionHead
rule="cuenta"
title="Movimientos de servicios"
count={data.customerTransactions.length}
countSuffix="recientes"
/>
{data.customerLedger.length > 0 && (
<div className="summary-grid">
{data.customerLedger.map((row) => (
<div className="summary-card UTILITY" key={row.currency}>
<div className="summary-domain">
<span className="tx-dot UTILITY" />
Servicios · {row.currency}
</div>
<div className="summary-total">
{formatMoney(row.total, row.currency)}
</div>
<div className="summary-count">
{formatNumber(row.count)}{" "}
{row.count === 1 ? "movimiento" : "movimientos"}
</div>
</div>
))}
</div>
)}
<div className="card">
{data.customerTransactions.length === 0 ? (
<div className="empty-inline">Sin movimientos de servicios.</div>
) : (
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Fecha</th>
<th>Tipo</th>
<th>Periodo</th>
<th>Referencia</th>
<th className="num">Monto</th>
</tr>
</thead>
<tbody>
{data.customerTransactions.map((t) => (
<TxRow key={t.id} t={t} />
))}
</tbody>
</table>
</div>
)}
<div className="section-note" style={{ padding: "0 16px 14px" }}>
Los movimientos pertenecen al cliente, no a esta propiedad: el
sistema anterior nunca ligó un pago a una propiedad concreta. Ver el{" "}
<Link href={`/clientes/${data.customerId}`} className="inline-link">
estado de cuenta completo
</Link>
.
</div>
</div>
</section>
);
}
function TxRow({ t }: { t: Transaction }) {
const num = t.amount != null ? Number(t.amount) : NaN;
const sign = !Number.isNaN(num) && num < 0 ? "neg" : "pos";
return (
<tr>
<td className="mono" style={{ whiteSpace: "nowrap" }}>
{formatDate(t.transactionDate)}
</td>
<td>{t.type?.nameEs || t.type?.nameEn || "—"}</td>
<td>{t.period || "—"}</td>
<td className="tx-ref">{t.reference || "—"}</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: PropertyDetail }) {
return (
<section className="section">
<SectionHead rule="docs" title="Documentos" count={data.documents.length} />
<div className="card">
{data.documents.length === 0 ? (
<div className="empty-inline">
No hay documentos registrados para esta propiedad.
</div>
) : (
<>
<div className="doc-list">
{data.documents.map((d, i) => (
<div className="doc-item" key={d.id ?? i}>
<span className="doc-icon" aria-hidden>
</span>
<div style={{ minWidth: 0 }}>
<div className="doc-type">{d.documentType || "Documento"}</div>
<div className="doc-key">{d.storageKey || "—"}</div>
</div>
</div>
))}
</div>
<div className="section-note" style={{ padding: "0 22px 18px" }}>
Los archivos se almacenan en el object storage (storageKey); no se
descargan desde esta vista.
</div>
</>
)}
</div>
</section>
);
}
/* ------------------------------------------------------------ helpers */
function KV({
label,
value,
}: {
label: string;
value: string | null | undefined;
}) {
return (
<div>
<div className="kv-label">{label}</div>
<div className="kv-value">{value || "—"}</div>
</div>
);
}
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: 160, marginBottom: 18 }}
/>
<div className="skeleton" style={{ height: 180, borderRadius: 16 }} />
<div
className="skeleton"
style={{ height: 160, borderRadius: 16, marginTop: 34 }}
/>
<div
className="skeleton"
style={{ height: 240, borderRadius: 16, marginTop: 34 }}
/>
</div>
);
}