diff --git a/.gitignore b/.gitignore index 23695e8..351b166 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,6 @@ __pycache__/ *.pyc packages/database/generated/ .DS_Store +.idea/ +*.tsbuildinfo diff --git a/apps/web/src/app/clientes/[id]/page.tsx b/apps/web/src/app/clientes/[id]/page.tsx new file mode 100644 index 0000000..170209a --- /dev/null +++ b/apps/web/src/app/clientes/[id]/page.tsx @@ -0,0 +1,765 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { AppShell } from "@/components/AppShell"; +import { getCustomer } from "@/lib/api"; +import { + domainLabel, + formatDate, + formatMoney, + serviceKindGlyph, + serviceKindLabel, + sourceSystemLabel, +} from "@/lib/labels"; +import type { + CustomerDetail, + Installment, + Policy, + Property, + Transaction, + TransactionSummaryRow, +} from "@/lib/types"; + +export default function ClienteDetailPage({ + params, +}: { + params: { id: string }; +}) { + const { id } = params; + return ( + + + + ); +} + +function Detail({ id }: { id: string }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let alive = true; + setLoading(true); + setError(null); + getCustomer(id) + .then((d) => { + if (alive) { + setData(d); + setLoading(false); + } + }) + .catch((e) => { + if (alive) { + setError( + e?.status === 404 + ? "No encontramos este cliente." + : e?.message ?? "No se pudo cargar el cliente.", + ); + setLoading(false); + } + }); + return () => { + alive = false; + }; + }, [id]); + + if (loading) return ; + + if (error) + return ( + <> + +
+ {error} +
+ + ); + + if (!data) return null; + + const hasUtilities = data.properties.length > 0; + const hasInsurance = data.policies.length > 0; + + return ( +
+ + + + + + + + +
+ ); +} + +function BackLink() { + return ( + + ← Volver a Clientes + + ); +} + +/* ------------------------------------------------------------------ Hero */ +function Hero({ + data, + hasUtilities, + hasInsurance, +}: { + data: CustomerDetail; + hasUtilities: boolean; + hasInsurance: boolean; +}) { + const provenance = data.legacyRefs + .map((r) => `${sourceSystemLabel(r.sourceSystem)} #${r.legacyId}`) + .join(" · "); + + const facts: { label: string; value: string }[] = [ + { label: "Cliente desde", value: formatDate(data.customerSince) }, + { + label: "Cuota", + value: + data.feeAmount != null && data.feeAmount !== "" + ? formatMoney(data.feeAmount, data.preferredCurrency) + : "—", + }, + { label: "Propiedades", value: String(data.properties.length) }, + { label: "Pólizas", value: String(data.policies.length) }, + { label: "Moneda", value: data.preferredCurrency ?? "—" }, + ]; + + return ( +
+
+
+

{data.name}

+ {provenance && ( +
Origen: {provenance}
+ )} +
+
+ {hasUtilities && ( + + Servicios + + )} + {hasInsurance && ( + + Seguros + + )} + + {data.status ? "Activo" : "Inactivo"} + +
+
+
+ {facts.map((f) => ( +
+
{f.label}
+
{f.value}
+
+ ))} +
+
+ ); +} + +/* ------------------------------------------------------- Datos del cliente */ +function DatosSection({ data }: { data: CustomerDetail }) { + const mxAddress = [data.addressLine1, data.addressLine2] + .filter(Boolean) + .join(", "); + const cityLine = [ + data.city?.replace(/,\s*$/, ""), + data.state, + data.zipCode, + data.country, + ] + .filter(Boolean) + .join(", "); + + const idLine = + data.identificationNumber || data.identificationType + ? [ + data.identificationType, + data.identificationNumber, + data.identificationExpiration + ? `vence ${formatDate(data.identificationExpiration)}` + : null, + ] + .filter(Boolean) + .join(" · ") + : null; + + return ( +
+ +
+
+ + + + + + + {(mxAddress || cityLine) && ( +
+
Domicilio
+
+ {mxAddress &&
{mxAddress}
} + {cityLine &&
{cityLine}
} + {!mxAddress && !cityLine && "—"} +
+
+ )} + {data.notes && ( +
+
Notas
+
{data.notes}
+
+ )} +
+
+
+ ); +} + +function KV({ + label, + value, + mono, +}: { + label: string; + value: string | null | undefined; + mono?: boolean; +}) { + return ( +
+
{label}
+
+ {value || "—"} +
+
+ ); +} + +/* ----------------------------------------------- Propiedades y servicios */ +function PropiedadesSection({ properties }: { properties: Property[] }) { + return ( +
+ +
+ {properties.length === 0 ? ( +
+ Este cliente no tiene propiedades registradas. +
+ ) : ( + properties.map((p) => ) + )} +
+
+ ); +} + +function PropertyCard({ p }: { p: Property }) { + const addr = [p.addressLine1, p.addressLine2].filter(Boolean).join(", "); + const phones = [p.phone1, p.phone2, p.phone3].filter(Boolean); + + return ( +
+
{addr || "Propiedad"}
+
+ {p.zone && Zona: {p.zone}} + {phones.length > 0 && ( + {phones.join(" · ")} + )} +
+ + {p.services.length > 0 && ( +
+ {p.services.map((s) => ( +
+
+ + + {serviceKindGlyph(s.kind)} + + {serviceKindLabel(s.kind)} + + {!s.active && ( + Inactivo + )} +
+
+ {s.accountNumber && ( + + Cuenta: {s.accountNumber} + + )} + {s.meterNumber && ( + + Medidor: {s.meterNumber} + + )} + {s.route && ( + + Ruta: {s.route} + + )} + {s.dueDay && Día de pago: {s.dueDay}} + {s.notes && {s.notes}} +
+
+ ))} +
+ )} + + {p.trustAccount && ( +
+
Fideicomiso
+
+ {p.trustAccount.bankName && ( + + Banco: {p.trustAccount.bankName} + + )} + {p.trustAccount.trustNumber && ( + + No.:{" "} + {p.trustAccount.trustNumber} + + )} + {p.trustAccount.bankFee && ( + + Comisión:{" "} + {formatMoney(p.trustAccount.bankFee, "MXN")} + + )} + {p.trustAccount.dueDate1 && ( + + Vigencia:{" "} + {formatDate(p.trustAccount.dueDate1)} + {p.trustAccount.dueDate2 + ? ` – ${formatDate(p.trustAccount.dueDate2)}` + : ""} + + )} +
+
+ )} +
+ ); +} + +/* ------------------------------------------------------ Pólizas de seguro */ +function PolizasSection({ policies }: { policies: Policy[] }) { + return ( +
+ +
+ {policies.length === 0 ? ( +
+ Este cliente no tiene pólizas registradas. +
+ ) : ( + policies.map((p) => ) + )} +
+
+ ); +} + +function PolicyCard({ p }: { p: Policy }) { + const headline = p.total ?? p.netPremium; + const headlineLabel = p.total ? "Total" : "Prima neta"; + + return ( +
+
+
+
{p.policyNumber || "—"}
+
+ {p.policyType?.name && ( + + {p.policyType.name} + + )} + {p.insuranceProvider?.name && ( + {p.insuranceProvider.name} + )} + {p.agentName && ( + <> + · + Agente: {p.agentName} + + )} + + {p.liquidated ? "Liquidada" : "Pendiente"} + +
+
+ + Vigencia: + + + {formatDate(p.policyFrom)} – {formatDate(p.policyTo)} + +
+
+
+
+ {formatMoney(headline, p.currency)} +
+
{headlineLabel}
+ {p.total && p.netPremium && ( +
+ Prima neta {formatMoney(p.netPremium, p.currency)} +
+ )} +
+
+ +
+ {p.installments.length > 0 && ( +
+
+ Pagos + {p.installments.length} +
+ {p.installments.map((inst) => ( + + ))} +
+ )} + + {p.vehicles.length > 0 && ( +
+
+ Vehículos + {p.vehicles.length} +
+
+ {p.vehicles.map((v) => ( +
+ {[v.make, v.model, v.modelYear].filter(Boolean).join(" ") || + "Vehículo"} +
+ {[ + v.bodyType, + v.licensePlate ? `Placa ${v.licensePlate}` : null, + ] + .filter(Boolean) + .join(" · ")} +
+
+ ))} +
+
+ )} + + {p.insuredDrivers.length > 0 && ( +
+
+ Asegurados + {p.insuredDrivers.length} +
+
+ {p.insuredDrivers.map((d) => ( +
+ {d.fullName || "—"} + {d.licenseNumber && ( +
Lic. {d.licenseNumber}
+ )} +
+ ))} +
+
+ )} + + {p.beneficiaries.length > 0 && ( +
+
+ Beneficiarios + {p.beneficiaries.length} +
+
+ {p.beneficiaries.map((b) => ( +
+ {b.name || "—"} + {(b.phone || b.email) && ( +
+ {[b.phone, b.email].filter(Boolean).join(" · ")} +
+ )} +
+ ))} +
+
+ )} +
+
+ ); +} + +function InstallmentRow({ inst }: { inst: Installment }) { + const method = inst.isCash + ? "Efectivo" + : inst.checkNumber + ? `Ref. ${inst.checkNumber}` + : null; + return ( +
+ + {inst.sequence} + + {inst.paidDate ? formatDate(inst.paidDate) : "Sin pagar"} + {method && ( + + {" "} + · {method} + + )} + + + + {formatMoney(inst.amount, inst.currency)} + +
+ ); +} + +/* ------------------------------------------------------- Estado de cuenta */ +function EstadoCuentaSection({ + summary, + transactions, +}: { + summary: TransactionSummaryRow[]; + transactions: Transaction[]; +}) { + return ( +
+ + + {summary.length > 0 && ( +
+ {summary.map((row, i) => ( +
+
+ + {domainLabel(row.domain)} · {row.currency} +
+
+ {formatMoney(row.total, row.currency)} +
+
+ {row.count}{" "} + {row.count === 1 ? "movimiento" : "movimientos"} +
+
+ ))} +
+ )} + +
+ {transactions.length === 0 ? ( +
Sin movimientos registrados.
+ ) : ( +
+ + + + + + + + + + + + + {transactions.map((t) => ( + + ))} + +
FechaLíneaTipoReferenciaConceptoMonto
+
+ )} + {transactions.length >= 100 && ( +
+ Mostrando los 100 movimientos más recientes. +
+ )} +
+
+ ); +} + +function TxRow({ t }: { t: Transaction }) { + const num = t.amount != null ? Number(t.amount) : NaN; + const sign = !Number.isNaN(num) && num < 0 ? "neg" : "pos"; + const tipo = + t.type?.nameEs || t.type?.nameEn || "—"; + const concept = t.message || t.period || "—"; + + return ( + + + {formatDate(t.transactionDate)} + + + + {domainLabel(t.domain)} + + {tipo} + {t.reference || "—"} + {concept} + + + {formatMoney(t.amount, t.currency)} + {" "} + {t.currency} + + + ); +} + +/* ----------------------------------------------------------- Documentos */ +function DocumentosSection({ data }: { data: CustomerDetail }) { + type Doc = { type: string; key: string | null; scope: string }; + const docs: Doc[] = []; + data.properties.forEach((p) => { + const label = [p.addressLine1].filter(Boolean).join("") || "Propiedad"; + p.documents.forEach((d) => + docs.push({ + type: d.documentType || "Documento", + key: d.storageKey, + scope: label, + }), + ); + }); + data.policies.forEach((p) => { + p.documents.forEach((d) => + docs.push({ + type: d.documentType || "Documento", + key: d.storageKey, + scope: `Póliza ${p.policyNumber ?? ""}`.trim(), + }), + ); + }); + + return ( +
+ +
+ {docs.length === 0 ? ( +
+ No hay documentos registrados para este cliente. +
+ ) : ( + <> +
+ {docs.map((d, i) => ( +
+ + ▤ + +
+
{d.type}
+
{d.scope}
+
+
+ ))} +
+
+ Los archivos se almacenan en el object storage + (storageKey); no se descargan desde esta vista. +
+ + )} +
+
+ ); +} + +/* -------------------------------------------------------------- helpers */ +function SectionHead({ + rule, + title, + count, + countSuffix, +}: { + rule: string; + title: string; + count?: number; + countSuffix?: string; +}) { + return ( +
+ +

{title}

+ {count != null && ( + + {count} {countSuffix ?? ""} + + )} +
+ ); +} + +function DetailSkeleton() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/clientes/page.tsx b/apps/web/src/app/clientes/page.tsx new file mode 100644 index 0000000..caae065 --- /dev/null +++ b/apps/web/src/app/clientes/page.tsx @@ -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 ( + + + + ); +} + +function ClientesBrowser() { + const [stats, setStats] = useState(null); + const [query, setQuery] = useState(""); + const [filter, setFilter] = useState("all"); + const [page, setPage] = useState(1); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const debounceRef = useRef>(); + + 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. +

+ +
+ + + + + {error && ( +
+ {error} +
+ )} + + +
+
+
+
+ ); +} 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 ( + <> +
+
+ + + JC + + + Jorge Cuadros + & Asociados + + + + +
+ {user && ( + {user.name} + )} + +
+
+
+
{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