Utilities section becomes create/edit/archive-able, with its child data. API: - Property gains archivedAt (soft-delete); list/browser default to archivedAt=null with ?includeArchived opt-in. - PropertiesService: header create/update/archive/restore (customer FK validated); PropertyService add/update/remove scoped to the property; TrustAccount upsert (1:1) + remove; ServiceDocument pointer delete. - Controller write routes: create needs STAFF+ (property:create), archive MANAGER+ (property:delete), every service/trust/document route property:update. Mutations audited. DTOs added. - Document *upload* deliberately deferred: it needs the object-storage client wired into the API (today only the migration writes to MinIO); removing an existing pointer row is supported and the UI says so. Web: - PropertyForm (header) with CustomerPicker; /servicios/nuevo (accepts ?customerId prefill) and /servicios/[id]/editar. - Property detail: gated action bar (Editar/Archivar) + "Administrar propiedad" — services via the shared ChildCollection editor, an inline 1:1 TrustEditor (create/update/clear), and document-row delete. - "Nueva propiedad" buttons on the list and customer detail (prefilled). api.ts + types for all of it. Verified against dev: property create (archivedAt null), service add/update, VIEWER service-add 403, trust upsert (create then update the same row), trust/service remove, cross-property child guard 404, archive drops from the default list and includeArchived surfaces it. Both apps compile clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
587 lines
17 KiB
TypeScript
587 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import Link from "next/link";
|
||
import { AppShell } from "@/components/AppShell";
|
||
import { useCan } from "@/lib/abilities";
|
||
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 canCreate = useCan("property:create");
|
||
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>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||
<h1 className="page-title" style={{ margin: 0 }}>Propiedades</h1>
|
||
<span style={{ flex: 1 }} />
|
||
{canCreate && (
|
||
<Link href="/servicios/nuevo" className="btn btn-primary">+ Nueva propiedad</Link>
|
||
)}
|
||
</div>
|
||
<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>
|
||
);
|
||
}
|