Files
jorgecuadros-platform/apps/web/src/app/servicios/page.tsx
T
rmancinasandClaude Opus 4.8 61193586a5 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>
2026-07-22 22:04:07 -07:00

579 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}