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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user