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,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 (A–Z)" },
|
||||
{ key: "address", label: "Dirección (A–Z)" },
|
||||
{ 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user