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
+578
View File
@@ -0,0 +1,578 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import {
EXPIRY_WINDOW_DAYS,
getPropertyFacets,
getPropertyStats,
listProperties,
} from "@/lib/api";
import {
expiryPhrase,
formatDate,
formatNumber,
serviceKindGlyph,
serviceKindLabel,
SIN_NOMBRE,
trustStatusLabel,
} from "@/lib/labels";
import type {
PropertyFacets,
PropertyListItem,
PropertyListResponse,
PropertySort,
PropertyStats,
ServiceKind,
} from "@/lib/types";
/**
* The buckets staff actually work from. Trust (fideicomiso) renewals are the
* recurring deadline in this line of business, so they get first-class filters
* next to the "nothing enrolled yet" bucket that flags incomplete records.
*/
type Focus = "all" | "trust" | "expiring" | "expired" | "no_services";
const FOCUS_FILTERS: { key: Focus; label: string }[] = [
{ key: "all", label: "Todas" },
{ key: "expiring", label: "Fideicomiso por vencer" },
{ key: "expired", label: "Fideicomiso vencido" },
{ key: "trust", label: "Con fideicomiso" },
{ key: "no_services", label: "Sin servicios" },
];
const SORTS: { key: PropertySort; label: string }[] = [
{ key: "customer", label: "Cliente (AZ)" },
{ key: "address", label: "Dirección (AZ)" },
{ key: "services_desc", label: "Más servicios" },
{
key: "trust_due_asc",
label: "Vencimiento de fideicomiso (solo con fideicomiso)",
},
{
key: "trust_due_desc",
label: "Vencimiento más lejano (solo con fideicomiso)",
},
];
/** Focus bucket → the query the API understands. */
function focusQuery(focus: Focus) {
switch (focus) {
case "trust":
return { trust: "with" as const };
case "expiring":
return { trust: "expiring" as const };
case "expired":
return { trust: "expired" as const };
case "no_services":
return { hasServices: false };
default:
return {};
}
}
export default function ServiciosPage() {
return (
<AppShell>
<ServiciosBrowser />
</AppShell>
);
}
function ServiciosBrowser() {
const [stats, setStats] = useState<PropertyStats | null>(null);
const [facets, setFacets] = useState<PropertyFacets | null>(null);
const [query, setQuery] = useState("");
const [focus, setFocus] = useState<Focus>("all");
const [serviceKind, setServiceKind] = useState<ServiceKind | "">("");
const [municipality, setMunicipality] = useState("");
const [bank, setBank] = useState("");
const [sort, setSort] = useState<PropertySort>("customer");
const [data, setData] = useState<PropertyListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
getPropertyStats().then(setStats).catch(() => setStats(null));
getPropertyFacets().then(setFacets).catch(() => setFacets(null));
}, []);
const runSearch = useCallback(
(p: number) => {
setLoading(true);
setError(null);
listProperties({
query: query || undefined,
serviceKind: serviceKind || undefined,
municipality: municipality || undefined,
bank: bank || undefined,
sort,
days: EXPIRY_WINDOW_DAYS,
page: p,
pageSize: 25,
...focusQuery(focus),
})
.then((res) => {
setData(res);
setLoading(false);
})
.catch((e) => {
setError(e?.message ?? "No se pudieron cargar las propiedades.");
setLoading(false);
});
},
[query, focus, serviceKind, municipality, bank, sort],
);
// Debounced re-query whenever any filter changes; always back to page 1.
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => runSearch(1), 280);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [runSearch]);
/** Picking a renewal bucket also switches the sort to due-date order —
* a list of renewals sorted by customer name isn't a worklist. */
function pickFocus(f: Focus) {
setFocus(f);
if (f === "expiring" || f === "expired") setSort("trust_due_asc");
else if (sort === "trust_due_asc" || sort === "trust_due_desc")
setSort("customer");
}
function goToPage(p: number) {
runSearch(p);
if (typeof window !== "undefined")
window.scrollTo({ top: 0, behavior: "smooth" });
}
const filtered =
query !== "" ||
focus !== "all" ||
serviceKind !== "" ||
municipality !== "" ||
bank !== "";
return (
<>
<div className="page-head rise">
<p className="eyebrow">Administración de servicios</p>
<h1 className="page-title">Propiedades</h1>
<StatStrip stats={stats} focus={focus} onPickFocus={pickFocus} />
<ServiceMixStrip
stats={stats}
serviceKind={serviceKind}
onPickKind={(k) => setServiceKind(k === serviceKind ? "" : k)}
/>
</div>
<div className="toolbar">
<div className="search-box">
<span className="search-icon" aria-hidden>
</span>
<input
className="input search-input"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar por dirección, cliente, cuenta, medidor, fideicomiso…"
aria-label="Buscar propiedades"
/>
</div>
<div className="seg" role="tablist" aria-label="Filtrar propiedades">
{FOCUS_FILTERS.map((f) => (
<button
key={f.key}
type="button"
role="tab"
aria-selected={focus === f.key}
className={`seg-btn ${focus === f.key ? "active" : ""}`}
onClick={() => pickFocus(f.key)}
>
{f.label}
</button>
))}
</div>
</div>
<div className="filter-row">
<label className="filter-field">
<span className="filter-label">Servicio</span>
<select
className="input select"
value={serviceKind}
onChange={(e) => setServiceKind(e.target.value as ServiceKind | "")}
>
<option value="">Todos los servicios</option>
{facets?.kinds.map((k) => (
<option key={k.kind} value={k.kind}>
{serviceKindLabel(k.kind)} ({formatNumber(k.count)})
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Municipio</span>
<select
className="input select"
value={municipality}
onChange={(e) => setMunicipality(e.target.value)}
>
<option value="">Todos los municipios</option>
{facets?.municipalities.map((m) => (
<option key={m.name} value={m.name}>
{m.name} ({formatNumber(m.count)})
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Banco (fideicomiso)</span>
<select
className="input select"
value={bank}
onChange={(e) => setBank(e.target.value)}
>
<option value="">Todos los bancos</option>
{facets?.banks.map((b) => (
<option key={b.name} value={b.name}>
{b.name} ({formatNumber(b.count)})
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Ordenar por</span>
<select
className="input select"
value={sort}
onChange={(e) => setSort(e.target.value as PropertySort)}
>
{SORTS.map((s) => (
<option key={s.key} value={s.key}>
{s.label}
</option>
))}
</select>
</label>
{filtered && (
<button
type="button"
className="btn btn-ghost filter-clear"
onClick={() => {
setQuery("");
setFocus("all");
setServiceKind("");
setMunicipality("");
setBank("");
setSort("customer");
}}
>
Limpiar filtros
</button>
)}
</div>
{data && !loading && !error && (
<div className="result-meta" aria-live="polite">
{data.total === 0
? "Sin resultados"
: `${formatNumber(data.total)} ${
data.total === 1 ? "propiedad" : "propiedades"
}`}
{query ? ` para “${query}` : ""}
{(sort === "trust_due_asc" || sort === "trust_due_desc") &&
" · solo propiedades con fideicomiso"}
</div>
)}
{error ? (
<div className="state-error" role="alert">
{error}
</div>
) : loading ? (
<ListSkeleton />
) : data && data.items.length === 0 ? (
<EmptyState query={query} />
) : (
<>
<div className="cust-list">
{data?.items.map((p) => (
<PropertyRow key={p.id} p={p} />
))}
</div>
{data && data.pageCount > 1 && (
<Pager
page={data.page}
pageCount={data.pageCount}
onChange={goToPage}
/>
)}
</>
)}
</>
);
}
/** Counts double as filter shortcuts — clicking a cell applies that bucket. */
function StatStrip({
stats,
focus,
onPickFocus,
}: {
stats: PropertyStats | null;
focus: Focus;
onPickFocus: (f: Focus) => void;
}) {
if (!stats) {
return (
<div className="stat-strip" aria-hidden>
{Array.from({ length: 6 }).map((_, i) => (
<div className="stat-cell" key={i}>
<div className="skeleton" style={{ height: 25, width: "60%" }} />
<div
className="skeleton"
style={{ height: 11, width: "80%", marginTop: 8 }}
/>
</div>
))}
</div>
);
}
const cells: {
key: Focus;
value: number;
label: string;
accent?: boolean;
}[] = [
{ key: "all", value: stats.properties, label: "Propiedades", accent: true },
{
key: "expiring",
value: stats.trustExpiring,
label: `Fideicomisos en ${stats.days} días`,
accent: true,
},
{ key: "expired", value: stats.trustExpired, label: "Fideicomisos vencidos" },
{ key: "trust", value: stats.trusts, label: "Con fideicomiso" },
{ key: "no_services", value: stats.withoutServices, label: "Sin servicios" },
];
return (
<div className="stat-strip">
{cells.map((c) => (
<button
type="button"
key={c.label}
className={`stat-cell stat-cell-btn${c.accent ? " accent" : ""}${
focus === c.key ? " selected" : ""
}`}
onClick={() => onPickFocus(c.key)}
aria-pressed={focus === c.key}
>
<div className="stat-value">{formatNumber(c.value)}</div>
<div className="stat-label">{c.label}</div>
</button>
))}
<div className="stat-cell">
<div className="stat-value">{formatNumber(stats.services)}</div>
<div className="stat-label">Servicios · {formatNumber(stats.owners)} clientes</div>
</div>
</div>
);
}
/** The monthly workload, per service type — also the fastest kind filter. */
function ServiceMixStrip({
stats,
serviceKind,
onPickKind,
}: {
stats: PropertyStats | null;
serviceKind: ServiceKind | "";
onPickKind: (k: ServiceKind) => void;
}) {
if (!stats || stats.byKind.length === 0) return null;
return (
<div className="mix-strip">
<span className="premium-caption">Servicios administrados</span>
{stats.byKind.map((k) => (
<button
type="button"
key={k.kind}
className={`mix-chip${serviceKind === k.kind ? " selected" : ""}`}
onClick={() => onPickKind(k.kind)}
aria-pressed={serviceKind === k.kind}
>
<span className="mix-glyph" aria-hidden>
{serviceKindGlyph(k.kind)}
</span>
<span className="mix-body">
<strong>{formatNumber(k.count)}</strong>
<span className="mix-label">{serviceKindLabel(k.kind)}</span>
</span>
{k.active < k.count && (
<span className="mix-inactive">
{formatNumber(k.count - k.active)} inactivos
</span>
)}
</button>
))}
</div>
);
}
function PropertyRow({ p }: { p: PropertyListItem }) {
const addr = [p.addressLine1, p.addressLine2].filter(Boolean).join(", ");
const location = [p.customerCity?.replace(/,\s*$/, ""), p.customerState]
.filter(Boolean)
.join(", ");
const phrase = p.trust ? expiryPhrase(p.trust.daysToDue) : null;
return (
<Link href={`/servicios/${p.id}`} className="cust-row prop-row">
<div className="cust-main">
<div className="cust-name">
<span>{addr || "Propiedad sin dirección"}</span>
{p.municipality && (
<span className="badge badge-servicios">
<span className="dot" /> {p.municipality}
</span>
)}
{p.trust && (
<span className={`badge status-${p.trust.status}`}>
Fideicomiso · {trustStatusLabel(p.trust.status)}
</span>
)}
{p.serviceCount === 0 && (
<span className="badge badge-neutral">Sin servicios</span>
)}
</div>
<div className="cust-sub">
<span
className={
p.customerName === SIN_NOMBRE ? "cust-name-missing" : undefined
}
>
{p.customerName}
</span>
{location && (
<>
<span className="sep">·</span>
<span>{location}</span>
</>
)}
{p.zone && (
<>
<span className="sep">·</span>
<span>Zona {p.zone}</span>
</>
)}
{p.phones.length > 0 && (
<>
<span className="sep">·</span>
<span className="mono">{p.phones[0]}</span>
</>
)}
</div>
</div>
<div className="prop-side">
<div className="svc-chips">
{p.services.map((s) => (
<span
key={s.id}
className={`svc-chip${s.active ? "" : " inactive"}`}
title={`${serviceKindLabel(s.kind)}${s.active ? "" : " (inactivo)"}`}
>
<span aria-hidden>{serviceKindGlyph(s.kind)}</span>
<span className="sr-only">{serviceKindLabel(s.kind)}</span>
</span>
))}
{p.services.length === 0 && <span className="muted"></span>}
</div>
{p.trust?.dueDate2 && (
<>
<div className="pol-dates mono">
Vence {formatDate(p.trust.dueDate2)}
</div>
{phrase && (
<div className={`pol-phrase ${p.trust.status}`}>{phrase}</div>
)}
</>
)}
</div>
</Link>
);
}
function Pager({
page,
pageCount,
onChange,
}: {
page: number;
pageCount: number;
onChange: (p: number) => void;
}) {
return (
<nav className="pager" aria-label="Paginación">
<button
type="button"
className="btn btn-outline"
onClick={() => onChange(page - 1)}
disabled={page <= 1}
>
Anterior
</button>
<span className="pager-info">
Página <strong>{page}</strong> de {pageCount}
</span>
<button
type="button"
className="btn btn-outline"
onClick={() => onChange(page + 1)}
disabled={page >= pageCount}
>
Siguiente
</button>
</nav>
);
}
function ListSkeleton() {
return (
<div className="cust-list" aria-hidden>
{Array.from({ length: 8 }).map((_, i) => (
<div className="skeleton skel-row" key={i} />
))}
</div>
);
}
function EmptyState({ query }: { query: string }) {
return (
<div className="state-box">
<div className="state-glyph" aria-hidden>
</div>
<h3>Sin resultados</h3>
<p>
{query
? `No encontramos propiedades para “${query}”.`
: "No hay propiedades que coincidan con los filtros."}
</p>
</div>
);
}