Files
jorgecuadros-platform/apps/web/src/app/servicios/page.tsx
T
rmancinasandClaude Opus 4.8 506f8ce684 feat(properties): CRUD + service/trust/document editors (plan phase 4)
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>
2026-07-23 12:27:53 -07:00

587 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 { 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 (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 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>
);
}