>();
+
+ 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 (
+ <>
+
+
Directorio unificado
+
Clientes
+
+
+
+
+
+
+ ⌕
+
+ setQuery(e.target.value)}
+ placeholder="Buscar por nombre, ciudad, teléfono…"
+ aria-label="Buscar clientes"
+ />
+
+
+ {FILTERS.map((f) => (
+
+ ))}
+
+
+
+ {data && !loading && !error && (
+
+ {data.total === 0
+ ? "Sin resultados"
+ : `${formatNumber(data.total)} ${
+ data.total === 1 ? "cliente" : "clientes"
+ }`}
+ {query ? ` para “${query}”` : ""}
+
+ )}
+
+ {error ? (
+
+ {error}
+
+ ) : loading ? (
+
+ ) : data && data.items.length === 0 ? (
+
+ ) : (
+ <>
+
+ {data?.items.map((c) => (
+
+ ))}
+
+ {data && data.pageCount > 1 && (
+
+ )}
+ >
+ )}
+ >
+ );
+}
+
+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 (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {cells.map((c) => (
+
+
{c.value}
+
{c.label}
+
+ ))}
+
+ );
+}
+
+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 (
+
+
+
+ {!c.status && (
+
+ )}
+ {c.name}
+
+
+ {location && {location}}
+ {location && contact && ·}
+ {contact && {contact}}
+
+
+
+ {c.hasUtilities && (
+
+ Servicios
+ {c.propertyCount > 0 && (
+ · {c.propertyCount}
+ )}
+
+ )}
+ {c.hasInsurance && (
+
+ Seguros
+ {c.policyCount > 0 && (
+ · {c.policyCount}
+ )}
+
+ )}
+
+
+ );
+}
+
+function Pager({
+ page,
+ pageCount,
+ onChange,
+}: {
+ page: number;
+ pageCount: number;
+ onChange: (p: number) => void;
+}) {
+ return (
+
+ );
+}
+
+function ListSkeleton() {
+ return (
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+function EmptyState({ query }: { query: string }) {
+ return (
+
+
+ ⌕
+
+
Sin resultados
+
+ {query
+ ? `No encontramos clientes para “${query}”.`
+ : "No hay clientes que coincidan con el filtro."}
+
+
+ );
+}
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
new file mode 100644
index 0000000..433f77c
--- /dev/null
+++ b/apps/web/src/app/globals.css
@@ -0,0 +1,1420 @@
+/* ============================================================================
+ Jorge Cuadros & Asociados — design system
+ Warm coastal-professional identity for a Baja California firm.
+ Fraunces (display serif) + Work Sans (UI) + JetBrains Mono (data).
+ Palette: cream paper, deep pine brand, terracotta accent,
+ gold = Servicios, teal = Seguros, aubergine = Fideicomiso.
+ ========================================================================== */
+
+:root {
+ /* neutrals — warm paper family */
+ --paper: #f5f1e8;
+ --paper-2: #efe9dc;
+ --surface: #fffefb;
+ --surface-2: #faf6ee;
+ --line: #e4dccb;
+ --line-strong: #d3c8b1;
+
+ /* ink / text */
+ --ink: #211d17;
+ --ink-soft: #4b4438;
+ --muted: #756c5c;
+ --muted-2: #98907e;
+
+ /* brand — deep pine teal */
+ --brand-900: #0c322d;
+ --brand-800: #113f38;
+ --brand-700: #16544a;
+ --brand-600: #1c6b5e;
+ --brand-500: #24897a;
+ --brand-tint: #e2ede9;
+
+ /* accent — terracotta clay (CTA / focus / links) */
+ --accent: #bf5a34;
+ --accent-600: #a94c2a;
+ --accent-tint: #f6e4da;
+
+ /* line-of-business + domains */
+ --servicios: #9a6a12; /* gold / ochre — utilities */
+ --servicios-tint: #f2e6c8;
+ --servicios-ink: #6d4a08;
+
+ --seguros: #0f6f68; /* teal — insurance */
+ --seguros-tint: #d3ebe7;
+ --seguros-ink: #0a4b46;
+
+ --fideicomiso: #6d3f60; /* aubergine — trust */
+ --fideicomiso-tint: #eddced;
+ --fideicomiso-ink: #4a2a41;
+
+ /* semantic */
+ --positive: #2f6d3c;
+ --positive-tint: #dcecdd;
+ --negative: #a23a2c;
+ --negative-tint: #f4dcd6;
+
+ /* shape + motion */
+ --radius-sm: 6px;
+ --radius: 10px;
+ --radius-lg: 16px;
+ --shadow-sm: 0 1px 2px rgba(33, 29, 23, 0.06),
+ 0 1px 3px rgba(33, 29, 23, 0.05);
+ --shadow: 0 2px 4px rgba(33, 29, 23, 0.05),
+ 0 8px 24px -12px rgba(33, 29, 23, 0.18);
+ --shadow-lg: 0 24px 60px -24px rgba(12, 50, 45, 0.35);
+
+ --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
+ --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
+
+ --font-display: "Fraunces", "Iowan Old Style", "Palatino Linotype",
+ Georgia, serif;
+ --font-sans: "Work Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
+ --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", "Cascadia Code",
+ Menlo, monospace;
+
+ --shell-max: 1180px;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: var(--font-sans);
+ background-color: var(--paper);
+ /* subtle paper grain via layered radial dots */
+ background-image: radial-gradient(
+ circle at 1px 1px,
+ rgba(33, 29, 23, 0.035) 1px,
+ transparent 0
+ );
+ background-size: 22px 22px;
+ color: var(--ink);
+ font-size: 15px;
+ line-height: 1.55;
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+}
+
+h1,
+h2,
+h3,
+h4 {
+ font-family: var(--font-display);
+ font-weight: 560;
+ color: var(--ink);
+ letter-spacing: -0.01em;
+ margin: 0;
+ line-height: 1.12;
+}
+
+a {
+ color: var(--accent-600);
+ text-decoration: none;
+}
+a:hover {
+ text-decoration: underline;
+}
+
+button {
+ font-family: inherit;
+ cursor: pointer;
+}
+
+:focus-visible {
+ outline: 2.5px solid var(--accent);
+ outline-offset: 2px;
+ border-radius: 4px;
+}
+
+.mono {
+ font-family: var(--font-mono);
+ font-feature-settings: "tnum" 1;
+}
+
+/* ---- layout helpers ---- */
+.shell-main {
+ max-width: var(--shell-max);
+ margin: 0 auto;
+ padding: 34px 28px 96px;
+}
+@media (max-width: 640px) {
+ .shell-main {
+ padding: 22px 16px 72px;
+ }
+}
+
+.eyebrow {
+ font-family: var(--font-sans);
+ text-transform: uppercase;
+ letter-spacing: 0.16em;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--muted);
+}
+
+.muted {
+ color: var(--muted);
+}
+
+/* ============================================================================
+ App shell / top nav
+ ========================================================================== */
+.appbar {
+ position: sticky;
+ top: 0;
+ z-index: 40;
+ background: linear-gradient(180deg, var(--brand-900), var(--brand-800));
+ color: #f2efe7;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.25);
+}
+.appbar-inner {
+ max-width: var(--shell-max);
+ margin: 0 auto;
+ padding: 0 28px;
+ height: 62px;
+ display: flex;
+ align-items: center;
+ gap: 26px;
+}
+@media (max-width: 640px) {
+ .appbar-inner {
+ padding: 0 16px;
+ gap: 14px;
+ }
+}
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ color: #f6f3ec;
+ text-decoration: none;
+}
+.brand:hover {
+ text-decoration: none;
+}
+.brand-mark {
+ width: 34px;
+ height: 34px;
+ border-radius: 9px;
+ background: radial-gradient(circle at 30% 25%, var(--brand-500), var(--brand-700));
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ display: grid;
+ place-items: center;
+ font-family: var(--font-display);
+ font-weight: 600;
+ font-size: 16px;
+ color: #f5efe0;
+ box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.2);
+}
+.brand-text {
+ display: flex;
+ flex-direction: column;
+ line-height: 1.05;
+}
+.brand-name {
+ font-family: var(--font-display);
+ font-size: 16px;
+ font-weight: 560;
+ letter-spacing: -0.01em;
+}
+.brand-sub {
+ font-size: 10.5px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: rgba(242, 239, 231, 0.62);
+}
+@media (max-width: 500px) {
+ .brand-sub {
+ display: none;
+ }
+}
+.appbar-nav {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-left: 8px;
+}
+.appbar-link {
+ color: rgba(242, 239, 231, 0.78);
+ padding: 7px 12px;
+ border-radius: 7px;
+ font-size: 14px;
+ font-weight: 500;
+}
+.appbar-link:hover {
+ color: #fff;
+ background: rgba(255, 255, 255, 0.08);
+ text-decoration: none;
+}
+.appbar-link.active {
+ color: #fff;
+ background: rgba(255, 255, 255, 0.12);
+}
+.appbar-spacer {
+ flex: 1;
+}
+.appbar-user {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-size: 13px;
+ color: rgba(242, 239, 231, 0.7);
+}
+@media (max-width: 640px) {
+ .appbar-user .appbar-user-name {
+ display: none;
+ }
+}
+
+/* ============================================================================
+ Buttons
+ ========================================================================== */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ border-radius: var(--radius-sm);
+ border: 1px solid transparent;
+ padding: 9px 16px;
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1;
+ transition: transform 0.12s var(--ease-out-quart),
+ background 0.15s, box-shadow 0.15s, border-color 0.15s;
+}
+.btn:active {
+ transform: translateY(1px);
+}
+.btn-primary {
+ background: var(--accent);
+ color: #fff;
+ box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.15);
+}
+.btn-primary:hover {
+ background: var(--accent-600);
+ text-decoration: none;
+}
+.btn-ghost {
+ background: transparent;
+ color: rgba(242, 239, 231, 0.85);
+ border-color: rgba(255, 255, 255, 0.22);
+}
+.btn-ghost:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: #fff;
+ text-decoration: none;
+}
+.btn-outline {
+ background: var(--surface);
+ color: var(--ink-soft);
+ border-color: var(--line-strong);
+}
+.btn-outline:hover {
+ border-color: var(--brand-600);
+ color: var(--brand-700);
+ text-decoration: none;
+}
+.btn:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+ transform: none;
+}
+
+/* ============================================================================
+ Cards / panels
+ ========================================================================== */
+.card {
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+}
+
+/* ============================================================================
+ Badges / chips
+ ========================================================================== */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 9px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+ line-height: 1.4;
+ border: 1px solid transparent;
+ white-space: nowrap;
+}
+.badge .dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+}
+.badge-servicios {
+ background: var(--servicios-tint);
+ color: var(--servicios-ink);
+ border-color: rgba(154, 106, 18, 0.28);
+}
+.badge-servicios .dot {
+ background: var(--servicios);
+}
+.badge-seguros {
+ background: var(--seguros-tint);
+ color: var(--seguros-ink);
+ border-color: rgba(15, 111, 104, 0.28);
+}
+.badge-seguros .dot {
+ background: var(--seguros);
+}
+.badge-fideicomiso {
+ background: var(--fideicomiso-tint);
+ color: var(--fideicomiso-ink);
+ border-color: rgba(109, 63, 96, 0.28);
+}
+.badge-fideicomiso .dot {
+ background: var(--fideicomiso);
+}
+.badge-neutral {
+ background: var(--paper-2);
+ color: var(--muted);
+ border-color: var(--line-strong);
+}
+.badge-positive {
+ background: var(--positive-tint);
+ color: var(--positive);
+ border-color: rgba(47, 109, 60, 0.25);
+}
+.badge-negative {
+ background: var(--negative-tint);
+ color: var(--negative);
+ border-color: rgba(162, 58, 44, 0.25);
+}
+.badge-count {
+ font-family: var(--font-mono);
+ font-feature-settings: "tnum" 1;
+}
+
+/* ============================================================================
+ Utility animation
+ ========================================================================== */
+@keyframes rise {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+.rise {
+ animation: rise 0.5s var(--ease-out-expo) both;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+.spinner {
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ border: 2.5px solid currentColor;
+ border-top-color: transparent;
+ animation: spin 0.7s linear infinite;
+ display: inline-block;
+}
+
+@keyframes shimmer {
+ 0% {
+ background-position: -420px 0;
+ }
+ 100% {
+ background-position: 420px 0;
+ }
+}
+.skeleton {
+ background: linear-gradient(
+ 90deg,
+ var(--paper-2) 25%,
+ #e8e1d2 37%,
+ var(--paper-2) 63%
+ );
+ background-size: 840px 100%;
+ animation: shimmer 1.4s linear infinite;
+ border-radius: 6px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.001ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.001ms !important;
+ }
+}
+
+/* ============================================================================
+ Form fields (shared)
+ ========================================================================== */
+.field {
+ display: block;
+ margin-bottom: 18px;
+}
+.field-label {
+ display: block;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--ink-soft);
+ margin-bottom: 7px;
+}
+.input {
+ width: 100%;
+ font-family: inherit;
+ font-size: 15px;
+ color: var(--ink);
+ background: var(--surface);
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-sm);
+ padding: 11px 13px;
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+.input::placeholder {
+ color: var(--muted-2);
+}
+.input:focus {
+ outline: none;
+ border-color: var(--brand-600);
+ box-shadow: 0 0 0 3px var(--brand-tint);
+}
+
+/* ============================================================================
+ Login screen — split brand / form layout
+ ========================================================================== */
+.login-wrap {
+ min-height: 100vh;
+ display: grid;
+ grid-template-columns: 1.05fr 1fr;
+}
+@media (max-width: 860px) {
+ .login-wrap {
+ grid-template-columns: 1fr;
+ }
+}
+.login-aside {
+ position: relative;
+ background: linear-gradient(155deg, var(--brand-900) 0%, var(--brand-700) 62%, #17544a 100%);
+ color: #f2efe7;
+ padding: 44px 52px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ overflow: hidden;
+}
+.login-aside::after {
+ /* faint concentric coastal arcs */
+ content: "";
+ position: absolute;
+ right: -180px;
+ bottom: -180px;
+ width: 560px;
+ height: 560px;
+ border-radius: 50%;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ box-shadow: 0 0 0 60px rgba(255, 255, 255, 0.04),
+ 0 0 0 130px rgba(255, 255, 255, 0.03);
+ pointer-events: none;
+}
+@media (max-width: 860px) {
+ .login-aside {
+ padding: 32px 28px 40px;
+ }
+ .login-aside-mid {
+ margin: 30px 0;
+ }
+}
+.login-brand {
+ display: flex;
+ align-items: center;
+ gap: 13px;
+ position: relative;
+ z-index: 1;
+}
+.login-aside-mid {
+ position: relative;
+ z-index: 1;
+ max-width: 460px;
+}
+.login-headline {
+ font-family: var(--font-display);
+ color: #fbf8f0;
+ font-size: clamp(30px, 4.6vw, 46px);
+ font-weight: 500;
+ line-height: 1.08;
+ margin: 14px 0 20px;
+ letter-spacing: -0.015em;
+}
+.login-headline em {
+ font-style: italic;
+ color: #ecc98d;
+}
+.login-lede {
+ font-size: 16px;
+ line-height: 1.6;
+ color: rgba(242, 239, 231, 0.82);
+ max-width: 420px;
+}
+.login-aside-foot {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+.login-lob {
+ display: flex;
+ gap: 8px;
+}
+.login-foot-note {
+ font-size: 12.5px;
+ color: rgba(242, 239, 231, 0.55);
+}
+
+.login-form-panel {
+ display: grid;
+ place-items: center;
+ padding: 40px 28px;
+ background: var(--paper);
+}
+.login-form-inner {
+ width: 100%;
+ max-width: 380px;
+}
+.login-form-title {
+ font-size: 30px;
+ margin-top: 4px;
+}
+.login-error {
+ background: var(--negative-tint);
+ color: var(--negative);
+ border: 1px solid rgba(162, 58, 44, 0.28);
+ border-radius: var(--radius-sm);
+ padding: 10px 13px;
+ font-size: 13.5px;
+ font-weight: 500;
+ margin-bottom: 18px;
+}
+.login-submit {
+ width: 100%;
+ padding: 12px 16px;
+ font-size: 15px;
+}
+
+/* ============================================================================
+ Page heading + stats (customer list)
+ ========================================================================== */
+.page-head {
+ margin-bottom: 26px;
+}
+.page-title {
+ font-size: clamp(28px, 4vw, 38px);
+ font-weight: 500;
+ margin: 4px 0 0;
+}
+.stat-strip {
+ display: grid;
+ grid-template-columns: repeat(6, 1fr);
+ gap: 1px;
+ background: var(--line);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ margin-top: 22px;
+}
+@media (max-width: 900px) {
+ .stat-strip {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+@media (max-width: 520px) {
+ .stat-strip {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+.stat-cell {
+ background: var(--surface);
+ padding: 15px 16px 14px;
+}
+.stat-cell.accent {
+ background: var(--brand-tint);
+}
+.stat-value {
+ font-family: var(--font-display);
+ font-size: 25px;
+ font-weight: 560;
+ color: var(--ink);
+ font-feature-settings: "tnum" 1;
+ letter-spacing: -0.01em;
+}
+.stat-cell.accent .stat-value {
+ color: var(--brand-700);
+}
+.stat-label {
+ font-size: 11.5px;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-top: 3px;
+ font-weight: 600;
+}
+
+/* ============================================================================
+ Search + filter toolbar
+ ========================================================================== */
+.toolbar {
+ display: flex;
+ gap: 14px;
+ align-items: center;
+ flex-wrap: wrap;
+ margin: 26px 0 18px;
+}
+.search-box {
+ position: relative;
+ flex: 1 1 320px;
+}
+.search-box .search-icon {
+ position: absolute;
+ left: 13px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--muted-2);
+ font-size: 16px;
+ pointer-events: none;
+}
+.search-input {
+ width: 100%;
+ padding-left: 38px;
+}
+.seg {
+ display: inline-flex;
+ background: var(--paper-2);
+ border: 1px solid var(--line-strong);
+ border-radius: 999px;
+ padding: 3px;
+ gap: 2px;
+}
+.seg-btn {
+ border: none;
+ background: transparent;
+ color: var(--muted);
+ font-size: 13px;
+ font-weight: 600;
+ padding: 7px 15px;
+ border-radius: 999px;
+ transition: color 0.15s, background 0.15s;
+ white-space: nowrap;
+}
+.seg-btn:hover {
+ color: var(--ink);
+}
+.seg-btn.active {
+ background: var(--surface);
+ color: var(--brand-700);
+ box-shadow: var(--shadow-sm);
+}
+@media (max-width: 560px) {
+ .seg {
+ width: 100%;
+ justify-content: space-between;
+ }
+ .seg-btn {
+ flex: 1;
+ padding: 8px 6px;
+ }
+}
+
+.result-meta {
+ font-size: 13px;
+ color: var(--muted);
+ margin-bottom: 12px;
+}
+
+/* ============================================================================
+ Customer list (rows)
+ ========================================================================== */
+.cust-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.cust-row {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 16px;
+ align-items: center;
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ padding: 15px 18px;
+ text-decoration: none;
+ color: inherit;
+ transition: transform 0.14s var(--ease-out-quart),
+ box-shadow 0.14s, border-color 0.14s;
+}
+.cust-row:hover {
+ text-decoration: none;
+ transform: translateY(-1px);
+ box-shadow: var(--shadow);
+ border-color: var(--line-strong);
+}
+.cust-main {
+ min-width: 0;
+}
+.cust-name {
+ font-family: var(--font-display);
+ font-size: 17px;
+ font-weight: 560;
+ color: var(--ink);
+ letter-spacing: -0.01em;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.cust-name .inactive-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--muted-2);
+ flex: none;
+}
+.cust-sub {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 16px;
+ margin-top: 5px;
+ font-size: 13px;
+ color: var(--muted);
+}
+.cust-sub .sep {
+ color: var(--line-strong);
+}
+.cust-side {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+@media (max-width: 620px) {
+ .cust-row {
+ grid-template-columns: 1fr;
+ }
+ .cust-side {
+ justify-content: flex-start;
+ }
+}
+
+/* ============================================================================
+ Pagination
+ ========================================================================== */
+.pager {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ margin-top: 26px;
+}
+.pager-info {
+ font-size: 13px;
+ color: var(--muted);
+ min-width: 130px;
+ text-align: center;
+}
+
+/* ============================================================================
+ Empty / error / loading states
+ ========================================================================== */
+.state-box {
+ text-align: center;
+ padding: 60px 24px;
+ color: var(--muted);
+}
+.state-box h3 {
+ color: var(--ink-soft);
+ font-size: 20px;
+ margin-bottom: 6px;
+}
+.state-glyph {
+ font-size: 34px;
+ opacity: 0.5;
+ margin-bottom: 10px;
+}
+.state-error {
+ background: var(--negative-tint);
+ border: 1px solid rgba(162, 58, 44, 0.25);
+ color: var(--negative);
+ border-radius: var(--radius);
+ padding: 16px 18px;
+ font-size: 14px;
+}
+
+.skel-row {
+ height: 74px;
+ border-radius: var(--radius);
+}
+
+/* ============================================================================
+ Customer detail — hero
+ ========================================================================== */
+.back-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13.5px;
+ font-weight: 600;
+ color: var(--muted);
+ margin-bottom: 18px;
+}
+.back-link:hover {
+ color: var(--brand-700);
+ text-decoration: none;
+}
+
+.detail-hero {
+ background: linear-gradient(150deg, var(--brand-900), var(--brand-700) 88%);
+ color: #f2efe7;
+ border-radius: var(--radius-lg);
+ padding: 26px 28px;
+ position: relative;
+ overflow: hidden;
+ box-shadow: var(--shadow);
+}
+.detail-hero::after {
+ content: "";
+ position: absolute;
+ right: -120px;
+ top: -140px;
+ width: 340px;
+ height: 340px;
+ border-radius: 50%;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ box-shadow: 0 0 0 44px rgba(255, 255, 255, 0.04);
+ pointer-events: none;
+}
+.hero-top {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 18px;
+ flex-wrap: wrap;
+ position: relative;
+ z-index: 1;
+}
+.hero-name {
+ font-family: var(--font-display);
+ font-weight: 500;
+ color: #fbf8f0;
+ font-size: clamp(24px, 3.4vw, 34px);
+ letter-spacing: -0.015em;
+ line-height: 1.1;
+}
+.hero-provenance {
+ font-size: 12.5px;
+ color: rgba(242, 239, 231, 0.62);
+ margin-top: 8px;
+ font-family: var(--font-mono);
+}
+.hero-badges {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ align-items: center;
+}
+.badge-on-dark {
+ background: rgba(255, 255, 255, 0.12);
+ color: #f2efe7;
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.hero-facts {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 26px;
+ margin-top: 22px;
+ padding-top: 18px;
+ border-top: 1px solid rgba(255, 255, 255, 0.12);
+}
+.hero-fact-label {
+ font-size: 10.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: rgba(242, 239, 231, 0.55);
+ font-weight: 600;
+}
+.hero-fact-value {
+ font-size: 15px;
+ color: #f6f3ec;
+ margin-top: 2px;
+ font-weight: 500;
+}
+
+/* ============================================================================
+ Section scaffolding
+ ========================================================================== */
+.section {
+ margin-top: 34px;
+}
+.section-head {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+.section-rule {
+ width: 4px;
+ align-self: stretch;
+ border-radius: 3px;
+ min-height: 22px;
+}
+.section-rule.servicios {
+ background: var(--servicios);
+}
+.section-rule.seguros {
+ background: var(--seguros);
+}
+.section-rule.cuenta {
+ background: var(--accent);
+}
+.section-rule.datos {
+ background: var(--brand-600);
+}
+.section-rule.docs {
+ background: var(--muted-2);
+}
+.section-title {
+ font-size: 22px;
+ font-weight: 560;
+}
+.section-count {
+ font-size: 13px;
+ color: var(--muted);
+ font-weight: 600;
+ font-family: var(--font-mono);
+}
+
+/* ---- key/value grid (datos del cliente) ---- */
+.kv-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 18px 24px;
+ padding: 22px 24px;
+}
+.kv-label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--muted);
+ font-weight: 600;
+ margin-bottom: 3px;
+}
+.kv-value {
+ font-size: 14.5px;
+ color: var(--ink);
+}
+.kv-value.mono {
+ font-size: 14px;
+}
+.kv-block {
+ grid-column: 1 / -1;
+ border-top: 1px dashed var(--line-strong);
+ padding-top: 16px;
+ margin-top: -2px;
+}
+
+/* ---- property cards ---- */
+.prop-card {
+ padding: 20px 22px;
+}
+.prop-card + .prop-card {
+ border-top: 1px solid var(--line);
+}
+.prop-addr {
+ font-family: var(--font-display);
+ font-size: 17px;
+ font-weight: 560;
+ color: var(--ink);
+}
+.prop-meta {
+ font-size: 13px;
+ color: var(--muted);
+ margin-top: 3px;
+ display: flex;
+ gap: 14px;
+ flex-wrap: wrap;
+}
+.svc-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
+ gap: 10px;
+ margin-top: 16px;
+}
+.svc-item {
+ border: 1px solid var(--line);
+ border-left: 3px solid var(--servicios);
+ border-radius: var(--radius-sm);
+ background: var(--surface-2);
+ padding: 11px 13px;
+}
+.svc-item.inactive {
+ opacity: 0.62;
+ border-left-color: var(--muted-2);
+}
+.svc-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ justify-content: space-between;
+}
+.svc-kind {
+ font-weight: 700;
+ font-size: 13.5px;
+ color: var(--servicios-ink);
+ display: flex;
+ align-items: center;
+ gap: 7px;
+}
+.svc-glyph {
+ font-size: 14px;
+}
+.svc-detail {
+ margin-top: 7px;
+ font-size: 12px;
+ color: var(--muted);
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.svc-detail .mono {
+ color: var(--ink-soft);
+}
+
+.trust-box {
+ margin-top: 16px;
+ border: 1px solid var(--fideicomiso-tint);
+ background: linear-gradient(0deg, var(--fideicomiso-tint), transparent 140%);
+ border-left: 3px solid var(--fideicomiso);
+ border-radius: var(--radius-sm);
+ padding: 13px 15px;
+}
+.trust-title {
+ font-size: 12.5px;
+ font-weight: 700;
+ color: var(--fideicomiso-ink);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-bottom: 8px;
+}
+.trust-facts {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 22px;
+ font-size: 13px;
+}
+
+/* ---- policy cards ---- */
+.policy-card {
+ padding: 20px 22px;
+}
+.policy-card + .policy-card {
+ border-top: 1px solid var(--line);
+}
+.policy-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+.policy-num {
+ font-family: var(--font-mono);
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--seguros-ink);
+ letter-spacing: -0.01em;
+}
+.policy-type-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 5px;
+ font-size: 13px;
+ color: var(--muted);
+ flex-wrap: wrap;
+}
+.policy-figures {
+ text-align: right;
+}
+.policy-total {
+ font-family: var(--font-display);
+ font-size: 22px;
+ font-weight: 560;
+ color: var(--ink);
+ font-feature-settings: "tnum" 1;
+}
+.policy-total-label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--muted);
+}
+.policy-body {
+ margin-top: 16px;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 16px;
+}
+.subpanel {
+ background: var(--surface-2);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ padding: 12px 14px;
+}
+.subpanel-title {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ color: var(--muted);
+ font-weight: 700;
+ margin-bottom: 9px;
+ display: flex;
+ justify-content: space-between;
+}
+.pay-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ font-size: 13px;
+ padding: 5px 0;
+ border-bottom: 1px dashed var(--line);
+}
+.pay-row:last-child {
+ border-bottom: none;
+}
+.pay-seq {
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ background: var(--seguros-tint);
+ color: var(--seguros-ink);
+ font-size: 11px;
+ font-weight: 700;
+ display: grid;
+ place-items: center;
+ flex: none;
+}
+.mini-list {
+ font-size: 13.5px;
+ color: var(--ink-soft);
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+.mini-list .mini-sub {
+ font-size: 12px;
+ color: var(--muted);
+}
+
+/* ============================================================================
+ Estado de cuenta
+ ========================================================================== */
+.summary-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ gap: 12px;
+ margin-bottom: 18px;
+}
+.summary-card {
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ padding: 15px 16px;
+ background: var(--surface);
+ border-top: 3px solid var(--muted-2);
+}
+.summary-card.UTILITY {
+ border-top-color: var(--servicios);
+}
+.summary-card.INSURANCE {
+ border-top-color: var(--seguros);
+}
+.summary-card.TRUST {
+ border-top-color: var(--fideicomiso);
+}
+.summary-domain {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ font-size: 12px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--muted);
+}
+.summary-total {
+ font-family: var(--font-display);
+ font-size: 24px;
+ font-weight: 560;
+ margin-top: 8px;
+ font-feature-settings: "tnum" 1;
+ color: var(--ink);
+}
+.summary-count {
+ font-size: 12px;
+ color: var(--muted);
+ margin-top: 2px;
+}
+
+.tx-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13.5px;
+}
+.tx-table th {
+ text-align: left;
+ font-size: 10.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ color: var(--muted);
+ font-weight: 700;
+ padding: 0 12px 10px;
+ border-bottom: 1px solid var(--line);
+}
+.tx-table th.num,
+.tx-table td.num {
+ text-align: right;
+}
+.tx-table td {
+ padding: 11px 12px;
+ border-bottom: 1px solid var(--line);
+ vertical-align: top;
+}
+.tx-table tr:last-child td {
+ border-bottom: none;
+}
+.tx-table tr:hover td {
+ background: var(--surface-2);
+}
+.tx-domain-cell {
+ white-space: nowrap;
+}
+.tx-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 2px;
+ margin-right: 7px;
+ vertical-align: middle;
+}
+.tx-dot.UTILITY {
+ background: var(--servicios);
+}
+.tx-dot.INSURANCE {
+ background: var(--seguros);
+}
+.tx-dot.TRUST {
+ background: var(--fideicomiso);
+}
+.tx-amount {
+ font-family: var(--font-mono);
+ font-weight: 600;
+ font-feature-settings: "tnum" 1;
+ white-space: nowrap;
+}
+.tx-amount.neg {
+ color: var(--negative);
+}
+.tx-amount.pos {
+ color: var(--positive);
+}
+.tx-cur {
+ font-size: 11px;
+ color: var(--muted);
+ font-weight: 500;
+}
+.tx-concept {
+ color: var(--ink-soft);
+}
+.tx-ref {
+ font-family: var(--font-mono);
+ font-size: 12.5px;
+ color: var(--muted);
+}
+.tx-scroll {
+ overflow-x: auto;
+}
+
+/* ---- documents ---- */
+.doc-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 10px;
+ padding: 20px 22px;
+}
+.doc-item {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ padding: 11px 13px;
+ background: var(--surface-2);
+}
+.doc-icon {
+ width: 32px;
+ height: 32px;
+ border-radius: 7px;
+ background: var(--paper-2);
+ display: grid;
+ place-items: center;
+ font-size: 15px;
+ flex: none;
+ color: var(--brand-700);
+}
+.doc-type {
+ font-size: 13.5px;
+ font-weight: 600;
+ color: var(--ink);
+}
+.doc-key {
+ font-size: 11px;
+ color: var(--muted-2);
+ font-family: var(--font-mono);
+ word-break: break-all;
+}
+
+.empty-inline {
+ padding: 22px 24px;
+ color: var(--muted);
+ font-size: 14px;
+ font-style: italic;
+}
+
+.section-note {
+ font-size: 12px;
+ color: var(--muted);
+ margin-top: 10px;
+}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 9d3d6d5..fdf48cd 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -1,13 +1,29 @@
import type { ReactNode } from "react";
+import "./globals.css";
export const metadata = {
- title: "Jorge Cuadros & Assoc.",
- description: "Unified customer, insurance, and utilities platform",
+ title: "Jorge Cuadros & Asociados — Plataforma",
+ description:
+ "Plataforma interna unificada de clientes, servicios y seguros.",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
-
+
+
+ {/* Google Fonts via so an offline build still runs with the
+ system fallback stacks defined in globals.css. */}
+
+
+
+
{children}
);
diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx
new file mode 100644
index 0000000..362007f
--- /dev/null
+++ b/apps/web/src/app/login/page.tsx
@@ -0,0 +1,170 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { ApiError, login, me } from "@/lib/api";
+
+export default function LoginPage() {
+ const router = useRouter();
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+ const [bootChecking, setBootChecking] = useState(true);
+
+ // If already signed in, skip straight to the customer browser.
+ useEffect(() => {
+ let alive = true;
+ me()
+ .then(() => router.replace("/clientes"))
+ .catch(() => {
+ if (alive) setBootChecking(false);
+ });
+ return () => {
+ alive = false;
+ };
+ }, [router]);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError(null);
+ setSubmitting(true);
+ try {
+ await login(email.trim(), password);
+ router.replace("/clientes");
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 401) {
+ setError("Correo o contraseña incorrectos");
+ } else if (err instanceof ApiError && err.status === 0) {
+ setError(err.message);
+ } else {
+ setError("No se pudo iniciar sesión. Inténtalo de nuevo.");
+ }
+ setSubmitting(false);
+ }
+ }
+
+ if (bootChecking) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Brand / narrative panel */}
+
+
+ {/* Form panel */}
+
+
+
Acceso del personal
+
Iniciar sesión
+
+ Ingresa con tu cuenta para continuar.
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index 0a9af34..70a6814 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -1,8 +1,5 @@
+import { redirect } from "next/navigation";
+
export default function HomePage() {
- return (
-
- Jorge Cuadros & Assoc.
- Unified customer platform — scaffold in progress.
-
- );
+ redirect("/clientes");
}
diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx
new file mode 100644
index 0000000..c536f2a
--- /dev/null
+++ b/apps/web/src/components/AppShell.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { useEffect, useState, type ReactNode } from "react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+import { logout, me } from "@/lib/api";
+import type { AuthUser } from "@/lib/types";
+
+/**
+ * Authenticated shell: gates on /auth/me, redirects to /login when the
+ * session is missing, renders the brand header + logout, and wraps page
+ * content. Used by /clientes and /clientes/[id].
+ */
+export function AppShell({ children }: { children: ReactNode }) {
+ const router = useRouter();
+ const [user, setUser] = useState(null);
+ const [checking, setChecking] = useState(true);
+ const [loggingOut, setLoggingOut] = useState(false);
+
+ useEffect(() => {
+ let alive = true;
+ me()
+ .then((u) => {
+ if (alive) {
+ setUser(u);
+ setChecking(false);
+ }
+ })
+ .catch(() => {
+ router.replace("/login");
+ });
+ return () => {
+ alive = false;
+ };
+ }, [router]);
+
+ async function handleLogout() {
+ setLoggingOut(true);
+ try {
+ await logout();
+ } catch {
+ /* ignore — we redirect regardless */
+ }
+ router.replace("/login");
+ }
+
+ if (checking) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+ {children}
+ >
+ );
+}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
new file mode 100644
index 0000000..f339538
--- /dev/null
+++ b/apps/web/src/lib/api.ts
@@ -0,0 +1,100 @@
+// Cross-origin API client. All requests send the session cookie (connect.sid)
+// via credentials: "include". The API origin comes from NEXT_PUBLIC_API_ORIGIN.
+
+import type {
+ AuthUser,
+ BusinessLine,
+ CustomerDetail,
+ CustomerListResponse,
+ CustomerStats,
+} from "./types";
+
+export const API_ORIGIN =
+ process.env.NEXT_PUBLIC_API_ORIGIN ?? "http://localhost:3001";
+
+export class ApiError extends Error {
+ status: number;
+ constructor(status: number, message: string) {
+ super(message);
+ this.status = status;
+ this.name = "ApiError";
+ }
+}
+
+async function apiFetch(
+ path: string,
+ init?: RequestInit,
+): Promise {
+ let res: Response;
+ try {
+ res = await fetch(`${API_ORIGIN}${path}`, {
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json",
+ ...(init?.headers ?? {}),
+ },
+ ...init,
+ });
+ } catch (e) {
+ throw new ApiError(
+ 0,
+ "No se pudo conectar con el servidor. Verifica tu conexión.",
+ );
+ }
+
+ if (!res.ok) {
+ let message = `Error ${res.status}`;
+ try {
+ const body = await res.json();
+ if (body?.message) message = body.message;
+ } catch {
+ /* ignore non-JSON error bodies */
+ }
+ throw new ApiError(res.status, message);
+ }
+
+ if (res.status === 204) return undefined as T;
+ return (await res.json()) as T;
+}
+
+export function login(email: string, password: string): Promise {
+ return apiFetch("/auth/login", {
+ method: "POST",
+ body: JSON.stringify({ email, password }),
+ });
+}
+
+export function me(): Promise {
+ return apiFetch("/auth/me");
+}
+
+export function logout(): Promise<{ success: boolean }> {
+ return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" });
+}
+
+export function getStats(): Promise {
+ return apiFetch("/customers/stats");
+}
+
+export interface CustomerQuery {
+ query?: string;
+ page?: number;
+ pageSize?: number;
+ line?: BusinessLine;
+}
+
+export function listCustomers(
+ q: CustomerQuery,
+): Promise {
+ const params = new URLSearchParams();
+ if (q.query) params.set("query", q.query);
+ if (q.page) params.set("page", String(q.page));
+ if (q.pageSize) params.set("pageSize", String(q.pageSize));
+ if (q.line) params.set("line", q.line);
+ const qs = params.toString();
+ return apiFetch(`/customers${qs ? `?${qs}` : ""}`);
+}
+
+export function getCustomer(id: string): Promise {
+ return apiFetch(`/customers/${id}`);
+}
diff --git a/apps/web/src/lib/labels.ts b/apps/web/src/lib/labels.ts
new file mode 100644
index 0000000..40fa46c
--- /dev/null
+++ b/apps/web/src/lib/labels.ts
@@ -0,0 +1,95 @@
+// Spanish label maps + formatting helpers. Single source of truth for i18n.
+
+import type { ServiceKind, TransactionDomain } from "./types";
+
+export const DOMAIN_LABELS: Record = {
+ UTILITY: "Servicios",
+ INSURANCE: "Seguros",
+ TRUST: "Fideicomiso",
+};
+
+export function domainLabel(domain: TransactionDomain): string {
+ return DOMAIN_LABELS[domain] ?? domain;
+}
+
+export const SERVICE_KIND_LABELS: Record = {
+ WATER: "Agua",
+ ELECTRIC: "Electricidad",
+ GAS: "Gas",
+ CABLE: "Cable/TV",
+ PROPERTY_TAX: "Predial",
+ FEDERAL_ZONE: "Zona Federal",
+ ALARM: "Alarma",
+ OTHER: "Otro",
+};
+
+export function serviceKindLabel(kind: ServiceKind): string {
+ return SERVICE_KIND_LABELS[kind] ?? kind;
+}
+
+// A short glyph per service kind, drawn with unicode so no icon dependency.
+export const SERVICE_KIND_GLYPH: Record = {
+ WATER: "≈",
+ ELECTRIC: "⚡",
+ GAS: "◐",
+ CABLE: "▤",
+ PROPERTY_TAX: "⌂",
+ FEDERAL_ZONE: "⇲",
+ ALARM: "◈",
+ OTHER: "•",
+};
+
+export function serviceKindGlyph(kind: ServiceKind): string {
+ return SERVICE_KIND_GLYPH[kind] ?? "•";
+}
+
+// ----- formatting -----
+
+export function formatMoney(
+ value: string | number | null | undefined,
+ currency: string | null | undefined,
+): string {
+ if (value === null || value === undefined || value === "") return "—";
+ const num = typeof value === "string" ? Number(value) : value;
+ if (Number.isNaN(num)) return String(value);
+ const cur = (currency ?? "USD").toUpperCase();
+ try {
+ return new Intl.NumberFormat("es-MX", {
+ style: "currency",
+ currency: cur,
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(num);
+ } catch {
+ // Unknown currency code — fall back to plain number + suffix.
+ return `${num.toLocaleString("es-MX", {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })} ${cur}`;
+ }
+}
+
+export function formatDate(
+ iso: string | null | undefined,
+): string {
+ if (!iso) return "—";
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return "—";
+ const dd = String(d.getUTCDate()).padStart(2, "0");
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
+ const yyyy = d.getUTCFullYear();
+ return `${dd}/${mm}/${yyyy}`;
+}
+
+export function formatNumber(n: number): string {
+ return n.toLocaleString("es-MX");
+}
+
+// "sourceSystem" from legacyRefs → display label.
+export function sourceSystemLabel(source: string): string {
+ const map: Record = {
+ utilities: "Servicios",
+ insurance: "Seguros",
+ };
+ return map[source] ?? source;
+}
diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts
new file mode 100644
index 0000000..4b7699d
--- /dev/null
+++ b/apps/web/src/lib/types.ts
@@ -0,0 +1,219 @@
+// TypeScript types for the Jorge Cuadros & Asociados API responses.
+// Decimals arrive as strings, dates as ISO strings.
+
+export interface AuthUser {
+ id: string;
+ name: string;
+ email: string;
+ role: string;
+ active: boolean;
+}
+
+export interface CustomerStats {
+ customers: number;
+ withUtilities: number;
+ withInsurance: number;
+ bothLines: number;
+ policies: number;
+ properties: number;
+ transactions: number;
+}
+
+export type BusinessLine = "utility" | "insurance" | "both";
+
+export interface CustomerListItem {
+ id: string;
+ name: string;
+ city: string | null;
+ state: string | null;
+ email: string | null;
+ phone: string | null;
+ mobile: string | null;
+ status: boolean;
+ propertyCount: number;
+ policyCount: number;
+ transactionCount: number;
+ hasUtilities: boolean;
+ hasInsurance: boolean;
+}
+
+export interface CustomerListResponse {
+ items: CustomerListItem[];
+ total: number;
+ page: number;
+ pageSize: number;
+ pageCount: number;
+}
+
+export interface LegacyRef {
+ id: string;
+ sourceSystem: string; // "utilities" | "insurance"
+ sourceTable: string;
+ legacyId: string;
+}
+
+export type ServiceKind =
+ | "WATER"
+ | "ELECTRIC"
+ | "GAS"
+ | "CABLE"
+ | "PROPERTY_TAX"
+ | "FEDERAL_ZONE"
+ | "ALARM"
+ | "OTHER"
+ | string;
+
+export interface Service {
+ id: string;
+ kind: ServiceKind;
+ accountNumber: string | null;
+ meterNumber: string | null;
+ route: string | null;
+ dueDay: string | null;
+ active: boolean;
+ notes: string | null;
+}
+
+export interface TrustAccount {
+ id: string;
+ bankName: string | null;
+ trustNumber: string | null;
+ bankFee: string | null;
+ dueDate1: string | null;
+ dueDate2: string | null;
+}
+
+export interface DocumentRef {
+ id?: string;
+ documentType: string | null;
+ storageKey: string | null;
+}
+
+export interface Property {
+ id: string;
+ addressLine1: string | null;
+ addressLine2: string | null;
+ phone1: string | null;
+ phone2: string | null;
+ phone3: string | null;
+ zone: string | null;
+ services: Service[];
+ trustAccount: TrustAccount | null;
+ documents: DocumentRef[];
+}
+
+export interface Installment {
+ id: string;
+ sequence: number;
+ amount: string | null;
+ currency: string | null;
+ dueDate: string | null;
+ paidDate: string | null;
+ checkNumber: string | null;
+ isCash: boolean;
+}
+
+export interface Vehicle {
+ id: string;
+ make: string | null;
+ model: string | null;
+ modelYear: number | null;
+ licensePlate: string | null;
+ bodyType: string | null;
+ engineNumber?: string | null;
+ vinNumber?: string | null;
+}
+
+export interface InsuredDriver {
+ id: string;
+ fullName: string | null;
+ licenseNumber: string | null;
+}
+
+export interface Beneficiary {
+ id: string;
+ name: string | null;
+ phone: string | null;
+ email: string | null;
+ address?: string | null;
+}
+
+export interface NamedRef {
+ name: string | null;
+ shortDescription?: string | null;
+}
+
+export interface Policy {
+ id: string;
+ policyNumber: string | null;
+ policyType: NamedRef | null;
+ insuranceProvider: NamedRef | null;
+ agentName: string | null;
+ policyFrom: string | null;
+ policyTo: string | null;
+ netPremium: string | null;
+ total: string | null;
+ currency: string | null;
+ liquidated: boolean;
+ installments: Installment[];
+ vehicles: Vehicle[];
+ insuredDrivers: InsuredDriver[];
+ beneficiaries: Beneficiary[];
+ claims: unknown[];
+ documents: DocumentRef[];
+}
+
+export type TransactionDomain = "UTILITY" | "INSURANCE" | "TRUST" | string;
+
+export interface TransactionType {
+ nameEn: string | null;
+ nameEs: string | null;
+}
+
+export interface Transaction {
+ id: string;
+ transactionDate: string | null;
+ domain: TransactionDomain;
+ amount: string | null;
+ currency: string | null;
+ reference: string | null;
+ period?: string | null;
+ message: string | null;
+ checkNumber: string | null;
+ type: TransactionType | null;
+}
+
+export interface TransactionSummaryRow {
+ domain: TransactionDomain;
+ currency: string;
+ total: string;
+ count: number;
+}
+
+export interface CustomerDetail {
+ id: string;
+ name: string;
+ addressLine1: string | null;
+ addressLine2: string | null;
+ city: string | null;
+ state: string | null;
+ zipCode: string | null;
+ country: string | null;
+ phone: string | null;
+ mobile: string | null;
+ fax: string | null;
+ email: string | null;
+ notes: string | null;
+ identificationType: string | null;
+ identificationNumber: string | null;
+ identificationExpiration: string | null;
+ customerSince: string | null;
+ status: boolean;
+ feeAmount: string | number | null;
+ preferredCurrency: string | null;
+ legacyRefs: LegacyRef[];
+ properties: Property[];
+ policies: Policy[];
+ transactions: Transaction[];
+ transactionSummary: TransactionSummaryRow[];
+}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 65fb98c..cdf700c 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -2,19 +2,12 @@ packages:
- "apps/*"
- "packages/*"
-allowBuilds:
- '@nestjs/core': set this to true or false
- '@prisma/client': set this to true or false
- '@prisma/engines': set this to true or false
- argon2: set this to true or false
- prisma: set this to true or false
-
# Native / postinstall build scripts we trust (argon2 native addon, Prisma
# engine download + client generation, Nest core). pnpm blocks build scripts
# by default; these are required for the API to run.
-onlyBuiltDependencies:
- - "@nestjs/core"
- - "@prisma/client"
- - "@prisma/engines"
- - argon2
- - prisma
+allowBuilds:
+ '@nestjs/core': true
+ '@prisma/client': true
+ '@prisma/engines': true
+ argon2: true
+ prisma: true