- 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.
350 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
}
|