Files
jorgecuadros-platform/apps/web/src/app/clientes/page.tsx
T
rmancinas 8802f08d4f
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m35s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m11s
feat(reports): reports module + /inicio + edo-cuenta-datos prefill
- New reports backend (registry, service, controller, outputs, types)
  with catalog endpoint + slug/CSV/XLSX/PDF/print outputs.
- /reportes catalog + /reportes/[slug] runner; ReportRunner + ContextReports
  components wire pre-filtered links from domain pages.
- Fix: /reportes/[slug] now reads searchParams and forwards initialParams to
  ReportRunner so /reportes/edo-cuenta-datos?customerId=... auto-runs
  instead of dropping the id and forcing a manual customer search.
- /inicio landing page; root + login redirect to /inicio.
- Company header env vars + logo asset for PDF/print rendering.
- exceljs + pdfkit deps.
2026-07-23 23:20:41 -07:00

350 lines
9.5 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { ContextReports } from "@/components/ContextReports";
import { getStats, listCustomers } from "@/lib/api";
import { useCan } from "@/lib/abilities";
import { formatNumber, SIN_NOMBRE } from "@/lib/labels";
import type {
BusinessLine,
CustomerListItem,
CustomerListResponse,
CustomerStats,
} from "@/lib/types";
type Filter = "all" | BusinessLine;
const FILTERS: { key: Filter; label: string }[] = [
{ key: "all", label: "Todos" },
{ key: "utility", label: "Servicios" },
{ key: "insurance", label: "Seguros" },
{ key: "both", label: "Ambos" },
];
export default function ClientesPage() {
return (
<AppShell>
<ClientesBrowser />
</AppShell>
);
}
function ClientesBrowser() {
const [stats, setStats] = useState<CustomerStats | null>(null);
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const [page, setPage] = useState(1);
const [data, setData] = useState<CustomerListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const canCreate = useCan("customer:create");
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
getStats().then(setStats).catch(() => setStats(null));
}, []);
const runSearch = useCallback(
(q: string, f: Filter, p: number) => {
setLoading(true);
setError(null);
listCustomers({
query: q || undefined,
line: f === "all" ? undefined : f,
page: p,
pageSize: 25,
})
.then((res) => {
setData(res);
setLoading(false);
})
.catch((e) => {
setError(
e?.message ?? "No se pudieron cargar los clientes.",
);
setLoading(false);
});
},
[],
);
// Debounced search on query/filter change; resets to page 1.
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setPage(1);
runSearch(query, filter, 1);
}, 280);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, filter]);
function goToPage(p: number) {
setPage(p);
runSearch(query, filter, p);
if (typeof window !== "undefined")
window.scrollTo({ top: 0, behavior: "smooth" });
}
return (
<>
<div className="page-head rise">
<p className="eyebrow">Directorio unificado</p>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<h1 className="page-title" style={{ margin: 0 }}>Clientes</h1>
<span style={{ flex: 1 }} />
<ContextReports
entries={[
{ slug: "listado-en-rojo", label: "En rojo" },
{ slug: "pagos-no-efectuados", label: "Sin pagos (agua)" },
]}
/>
{canCreate && (
<Link href="/clientes/nuevo" className="btn btn-primary">
+ Nuevo cliente
</Link>
)}
</div>
<StatStrip stats={stats} />
</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 nombre, ciudad, teléfono…"
aria-label="Buscar clientes"
/>
</div>
<div
className="seg"
role="tablist"
aria-label="Filtrar por línea de negocio"
>
{FILTERS.map((f) => (
<button
key={f.key}
type="button"
role="tab"
aria-selected={filter === f.key}
className={`seg-btn ${filter === f.key ? "active" : ""}`}
onClick={() => setFilter(f.key)}
>
{f.label}
</button>
))}
</div>
</div>
{data && !loading && !error && (
<div className="result-meta" aria-live="polite">
{data.total === 0
? "Sin resultados"
: `${formatNumber(data.total)} ${
data.total === 1 ? "cliente" : "clientes"
}`}
{query ? ` para “${query}”` : ""}
</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((c) => (
<CustomerRow key={c.id} c={c} />
))}
</div>
{data && data.pageCount > 1 && (
<Pager
page={data.page}
pageCount={data.pageCount}
onChange={goToPage}
/>
)}
</>
)}
</>
);
}
function StatStrip({ stats }: { stats: CustomerStats | null }) {
const cells: {
value: string;
label: string;
accent?: boolean;
}[] = stats
? [
{ value: formatNumber(stats.customers), label: "Clientes", accent: true },
{ value: formatNumber(stats.withUtilities), label: "Con servicios" },
{ value: formatNumber(stats.withInsurance), label: "Con seguros" },
{ value: formatNumber(stats.bothLines), label: "Ambas líneas", accent: true },
{ value: formatNumber(stats.policies), label: "Pólizas" },
{ value: formatNumber(stats.properties), label: "Propiedades" },
]
: [];
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>
);
}
return (
<div className="stat-strip">
{cells.map((c) => (
<div
className={`stat-cell${c.accent ? " accent" : ""}`}
key={c.label}
>
<div className="stat-value">{c.value}</div>
<div className="stat-label">{c.label}</div>
</div>
))}
</div>
);
}
function CustomerRow({ c }: { c: CustomerListItem }) {
const location = [c.city?.replace(/,\s*$/, ""), c.state]
.filter(Boolean)
.join(", ");
const contact = c.phone || c.mobile || c.email;
return (
<Link href={`/clientes/${c.id}`} className="cust-row">
<div className="cust-main">
<div className="cust-name">
{!c.status && (
<span className="inactive-dot" title="Inactivo" aria-hidden />
)}
<span className={c.name === SIN_NOMBRE ? "cust-name-missing" : undefined}>
{c.name}
</span>
{c.nameSource && (
<span
className="name-source"
title={`El registro original no tenía nombre. Recuperado de ${c.nameSource}.`}
>
nombre recuperado
</span>
)}
</div>
<div className="cust-sub">
{location && <span>{location}</span>}
{location && contact && <span className="sep">·</span>}
{contact && <span>{contact}</span>}
</div>
</div>
<div className="cust-side">
{c.hasUtilities && (
<span className="badge badge-servicios">
<span className="dot" /> Servicios
{c.propertyCount > 0 && (
<span className="badge-count">· {c.propertyCount}</span>
)}
</span>
)}
{c.hasInsurance && (
<span className="badge badge-seguros">
<span className="dot" /> Seguros
{c.policyCount > 0 && (
<span className="badge-count">· {c.policyCount}</span>
)}
</span>
)}
</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 clientes para “${query}”.`
: "No hay clientes que coincidan con el filtro."}
</p>
</div>
);
}