Web: Spanish-first staff UI — login + unified customer browser
First real frontend feature against the live Customer module API.
- login/ — session login form posting to /auth/login with credentials
included; the session cookie is what every subsequent request rides on.
- clientes/ — customer list with search and the cross-line stats header
(customers, utilities/insurance split, both-lines count).
- clientes/[id]/ — unified detail view: identity, properties + services,
policies, and transaction history for one customer, which is the whole
point of the migration (one record spanning both business lines).
- components/AppShell.tsx, lib/{api,labels,types}.ts — shared fetch wrapper
(always credentials: "include"), Spanish label maps for the enum values
the API returns, and the API response types.
- globals.css + layout.tsx — Spanish-first document (lang="es"), the type
scale, and the design tokens the pages share. Fonts load via <link> so an
offline build still renders on the system fallback stacks.
- page.tsx now redirects / to /clientes.
Also fixes pnpm-workspace.yaml: the allowBuilds map held pnpm's literal
placeholder text ("set this to true or false"), which made every install
fail with ERR_PNPM_IGNORED_BUILDS. Since pnpm 11 auto-installs before
running a script, that broke `pnpm start:dev` outright. Set the values to
true and dropped the superseded onlyBuiltDependencies list.
Verified: both apps build clean, and login -> /auth/me -> /customers/stats
round-trips against the dev database (1682 customers, 526 on both lines).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { getStats, listCustomers } from "@/lib/api";
|
||||
import { formatNumber } 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 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>
|
||||
<h1 className="page-title">Clientes</h1>
|
||||
<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 />
|
||||
)}
|
||||
{c.name}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user