feat(reports): reports module + /inicio + edo-cuenta-datos prefill
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m35s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m11s

- New reports backend (registry, service, controller, outputs, types)
  with catalog endpoint + slug/CSV/XLSX/PDF/print outputs.
- /reportes catalog + /reportes/[slug] runner; ReportRunner + ContextReports
  components wire pre-filtered links from domain pages.
- Fix: /reportes/[slug] now reads searchParams and forwards initialParams to
  ReportRunner so /reportes/edo-cuenta-datos?customerId=... auto-runs
  instead of dropping the id and forcing a manual customer search.
- /inicio landing page; root + login redirect to /inicio.
- Company header env vars + logo asset for PDF/print rendering.
- exceljs + pdfkit deps.
This commit is contained in:
2026-07-23 23:20:41 -07:00
parent 921a47cbaa
commit 8802f08d4f
30 changed files with 4424 additions and 6 deletions
+8
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { ContextReports } from "@/components/ContextReports";
import {
createBankMovement,
getBankFacets,
@@ -208,6 +209,13 @@ function BankBrowser() {
parte del estado de cuenta de los clientes y sus cifras no se suman
con las de ellos.
</p>
<div style={{ marginTop: 8 }}>
<ContextReports
entries={[
{ slug: "reporte-de-efectivo", label: "Reporte de efectivo" },
]}
/>
</div>
</div>
<div className="toolbar">
+10
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { ContextReports } from "@/components/ContextReports";
import {
archiveCustomer,
getCustomer,
@@ -95,6 +96,15 @@ function Detail({ id }: { id: string }) {
<div className="rise">
<div className="detail-actionbar">
<BackLink />
<ContextReports
entries={[
{
slug: "edo-cuenta-datos",
label: "Estado de cuenta (reporte)",
params: { customerId: data.id },
},
]}
/>
<CustomerActions
customer={data}
onChange={() => getCustomer(id).then(setData).catch(() => {})}
+7
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { ContextReports } from "@/components/ContextReports";
import { getStats, listCustomers } from "@/lib/api";
import { useCan } from "@/lib/abilities";
import { formatNumber, SIN_NOMBRE } from "@/lib/labels";
@@ -99,6 +100,12 @@ function ClientesBrowser() {
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<h1 className="page-title" style={{ margin: 0 }}>Clientes</h1>
<span style={{ flex: 1 }} />
<ContextReports
entries={[
{ slug: "listado-en-rojo", label: "En rojo" },
{ slug: "pagos-no-efectuados", label: "Sin pagos (agua)" },
]}
/>
{canCreate && (
<Link href="/clientes/nuevo" className="btn btn-primary">
+ Nuevo cliente
+10
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { ContextReports } from "@/components/ContextReports";
import { MovementForm } from "@/components/MovementForm";
import {
getBillingFacets,
@@ -256,6 +257,15 @@ function BillingBrowser() {
<LedgerTotalsStrip stats={stats} />
</div>
<div style={{ marginTop: 12 }}>
<ContextReports
entries={[
{ slug: "listado-en-rojo", label: "En rojo" },
{ slug: "reporte-de-efectivo", label: "Reporte de efectivo" },
]}
/>
</div>
<div className="toolbar">
<div className="search-box">
<span className="search-icon" aria-hidden>
+485
View File
@@ -2205,3 +2205,488 @@ button {
padding: 20px;
z-index: 50;
}
/* ============================================================================
Home dashboard (/inicio)
========================================================================== */
.home {
display: flex;
flex-direction: column;
gap: 36px;
padding-top: 8px;
}
.home-last-seen {
margin-top: 14px;
font-size: 13px;
}
.home-section {
display: flex;
flex-direction: column;
gap: 14px;
}
.section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.section-title {
font-family: var(--font-display);
font-size: 20px;
font-weight: 560;
margin: 0;
color: var(--ink);
letter-spacing: -0.005em;
}
.section-sub {
font-size: 12.5px;
color: var(--muted);
}
/* KPI cards row — 4 across on wide screens, 2 on tablet, 1 on mobile */
.home-section.kpi-row {
display: grid;
}
.home > .home-section:first-of-type {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
@media (max-width: 1080px) {
.home > .home-section:first-of-type {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 560px) {
.home > .home-section:first-of-type {
grid-template-columns: 1fr;
}
}
.kpi-card {
display: flex;
flex-direction: column;
padding: 18px 18px 16px;
text-decoration: none;
color: inherit;
transition: transform 0.18s var(--ease-out-expo), box-shadow 0.18s;
position: relative;
}
.kpi-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.kpi-label {
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
font-weight: 700;
}
.kpi-primary {
font-family: var(--font-display);
font-size: 32px;
font-weight: 560;
color: var(--ink);
margin-top: 8px;
font-feature-settings: "tnum" 1;
letter-spacing: -0.015em;
line-height: 1.1;
min-height: 36px;
}
.kpi-sub {
list-style: none;
padding: 0;
margin: 12px 0 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12.5px;
color: var(--ink-soft);
}
.kpi-sub li {
line-height: 1.35;
}
.kpi-cta {
margin-top: 14px;
font-size: 12px;
font-weight: 700;
color: var(--brand-700);
letter-spacing: 0.02em;
text-transform: uppercase;
}
.kpi-card:hover .kpi-cta {
color: var(--brand-800);
}
/* Attention grid — auto-fill so the row of cards stays balanced */
.attention-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px;
}
.attention-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 16px 18px 18px;
text-decoration: none;
color: inherit;
border-left: 3px solid var(--line-strong);
transition: transform 0.18s var(--ease-out-expo), box-shadow 0.18s;
position: relative;
}
.attention-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.attention-card.tone-warn {
border-left-color: var(--seguros);
}
.attention-card.tone-info {
border-left-color: var(--brand-600);
}
.attention-card.tone-muted {
border-left-color: var(--muted-2);
}
.attention-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.attention-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
font-weight: 700;
}
.attention-arrow {
color: var(--muted-2);
font-size: 14px;
transition: transform 0.18s var(--ease-out-expo), color 0.18s;
}
.attention-card:hover .attention-arrow {
color: var(--brand-700);
transform: translateX(3px);
}
.attention-primary {
font-family: var(--font-display);
font-size: 26px;
font-weight: 560;
color: var(--ink);
font-feature-settings: "tnum" 1;
letter-spacing: -0.01em;
line-height: 1.1;
min-height: 30px;
margin-top: 2px;
}
.attention-sub {
font-size: 12.5px;
color: var(--ink-soft);
line-height: 1.4;
}
.attention-meta {
font-size: 11.5px;
color: var(--muted);
margin-top: 4px;
}
/* Currency totals inside an attention card */
.home-currency-totals {
display: inline-flex;
flex-wrap: wrap;
gap: 8px 14px;
align-items: baseline;
}
.home-currency-totals-row {
display: inline-flex;
align-items: baseline;
gap: 6px;
}
.home-currency-totals-num {
font-family: var(--font-display);
font-feature-settings: "tnum" 1;
font-size: 20px;
font-weight: 560;
}
/* Quick links row */
.quick-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.quick-link {
display: flex;
flex-direction: column;
padding: 14px 16px;
text-decoration: none;
color: inherit;
position: relative;
transition: transform 0.18s var(--ease-out-expo), box-shadow 0.18s;
}
.quick-link:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.quick-label {
font-family: var(--font-display);
font-size: 17px;
font-weight: 560;
color: var(--ink);
}
.quick-sub {
font-size: 12px;
color: var(--muted);
margin-top: 2px;
}
.quick-arrow {
position: absolute;
top: 14px;
right: 16px;
color: var(--muted-2);
font-size: 14px;
transition: transform 0.18s var(--ease-out-expo), color 0.18s;
}
.quick-link:hover .quick-arrow {
color: var(--brand-700);
transform: translateX(3px);
}
/* Money tone helpers (used in chequera card) */
.money-pos { color: var(--positive); }
.money-neg { color: var(--negative); }
/* ============================================================================
Reports module
========================================================================== */
.report-catalog {
display: flex;
flex-direction: column;
gap: 28px;
margin-top: 20px;
}
.report-catalog-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.report-catalog-title {
font-family: var(--font-display);
font-weight: 500;
font-size: 20px;
color: var(--brand-800);
margin: 0;
padding-bottom: 6px;
border-bottom: 1px solid var(--line);
}
.report-catalog-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
}
.report-catalog-item {
margin: 0;
}
.report-catalog-link {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
color: inherit;
text-decoration: none;
transition: border-color 0.18s, transform 0.18s var(--ease-out-expo);
}
.report-catalog-link:hover {
border-color: var(--brand-500);
transform: translateY(-1px);
}
.report-catalog-item-title {
font-weight: 600;
font-size: 15px;
color: var(--ink);
}
.report-catalog-item-desc {
font-size: 13px;
color: var(--muted);
line-height: 1.4;
}
.report-catalog-item-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.report-catalog-tag {
font-size: 11px;
background: var(--brand-tint);
color: var(--brand-800);
padding: 2px 8px;
border-radius: 999px;
font-family: var(--font-mono);
}
.report-catalog-tag.muted {
background: var(--paper-2);
color: var(--muted);
}
/* Runner */
.report-runner {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 20px;
}
.report-filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-end;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px 16px;
}
.report-filters-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.report-error {
background: #fbeae3;
color: var(--negative);
border: 1px solid #e6b6a4;
border-radius: 6px;
padding: 10px 14px;
font-size: 13px;
}
.report-loading {
font-size: 12px;
color: var(--muted);
padding: 4px 0;
}
.report-result {
display: flex;
flex-direction: column;
gap: 12px;
}
.report-result-head {
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
}
.report-result-meta {
flex: 1;
min-width: 200px;
}
.report-output-buttons {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}
.report-table-wrap {
overflow-x: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
}
.report-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.report-table th,
.report-table td {
padding: 8px 12px;
border-bottom: 1px solid var(--line);
}
.report-table th {
background: var(--paper-2);
color: var(--ink-soft);
font-weight: 600;
text-align: left;
position: sticky;
top: 0;
z-index: 1;
}
.report-table tbody tr:hover {
background: var(--paper-2);
}
.report-totals {
background: var(--accent);
color: #f5f1e8;
font-weight: 600;
padding: 10px 14px;
}
.statement {
display: flex;
flex-direction: column;
gap: 20px;
}
.statement-head {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px 18px;
}
.statement-name {
font-family: var(--font-display);
font-size: 22px;
font-weight: 600;
color: var(--ink);
margin: 0 0 4px;
}
.statement-section-title {
font-family: var(--font-display);
font-size: 16px;
font-weight: 500;
color: var(--brand-800);
margin: 0 0 8px;
}
/* Context buttons (the inline shortcut on existing pages) */
.context-reports {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-left: auto;
}
.context-reports-label {
font-size: 11px;
color: var(--muted-2);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-right: 4px;
font-weight: 500;
}
.context-report-link {
font-size: 12px;
padding: 4px 10px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink);
border-radius: 999px;
text-decoration: none;
transition: border-color 0.15s, color 0.15s;
}
.context-report-link:hover {
border-color: var(--brand-500);
color: var(--brand-700);
}
+455
View File
@@ -0,0 +1,455 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import {
EXPIRY_WINDOW_DAYS,
getBankStats,
getBillingStats,
getPolicyStats,
getPropertyStats,
getStats,
} from "@/lib/api";
import { useAuth } from "@/lib/abilities";
import {
balancePhrase,
formatDate,
formatMoney,
formatNumber,
policyStatusLabel,
trustStatusLabel,
} from "@/lib/labels";
import type {
BankStats,
BillingStats,
CustomerStats,
PolicyStats,
PropertyStats,
} from "@/lib/types";
export default function InicioPage() {
return (
<AppShell>
<HomeDashboard />
</AppShell>
);
}
interface DashboardData {
customers: CustomerStats | null;
policies: PolicyStats | null;
properties: PropertyStats | null;
billing: BillingStats | null;
bank: BankStats | null;
}
function HomeDashboard() {
const user = useAuth();
const [data, setData] = useState<DashboardData>({
customers: null,
policies: null,
properties: null,
billing: null,
bank: null,
});
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
Promise.allSettled([
getStats(),
getPolicyStats(),
getPropertyStats(),
getBillingStats(),
getBankStats(),
]).then((results) => {
if (!alive) return;
setData({
customers: results[0].status === "fulfilled" ? results[0].value : null,
policies: results[1].status === "fulfilled" ? results[1].value : null,
properties: results[2].status === "fulfilled" ? results[2].value : null,
billing: results[3].status === "fulfilled" ? results[3].value : null,
bank: results[4].status === "fulfilled" ? results[4].value : null,
});
setLoading(false);
});
return () => {
alive = false;
};
}, []);
const greeting = greetingFor(user?.name);
const lastBillingMovement = data.billing?.lastMovement ?? null;
const lastBankMovement = data.bank?.lastMovement ?? null;
return (
<div className="home rise">
<div className="page-head">
<p className="eyebrow">Resumen general</p>
<h1 className="page-title">{greeting}</h1>
<p className="muted" style={{ marginTop: 6, maxWidth: 640 }}>
Vista rápida del estado de la cartera de clientes, las pólizas
activas, los fideicomisos y los movimientos recientes.
</p>
<LastSeenLine
billing={lastBillingMovement}
bank={lastBankMovement}
loading={loading}
/>
</div>
<section aria-label="Indicadores principales" className="home-section">
<KpiCard
href="/clientes"
label="Clientes"
loading={loading}
primary={data.customers ? formatNumber(data.customers.customers) : "—"}
sub={
data.customers
? [
`${formatNumber(data.customers.withUtilities)} con servicios`,
`${formatNumber(data.customers.withInsurance)} con seguros`,
`${formatNumber(data.customers.bothLines)} en ambos ramos`,
]
: []
}
/>
<KpiCard
href="/servicios"
label="Propiedades"
loading={loading}
primary={data.properties ? formatNumber(data.properties.properties) : "—"}
sub={
data.properties
? [
`${formatNumber(data.properties.services)} servicios`,
`${formatNumber(data.properties.trusts)} fideicomisos`,
`${formatNumber(data.properties.trustExpiring)} por vencer`,
]
: []
}
/>
<KpiCard
href="/polizas"
label="Pólizas"
loading={loading}
primary={data.policies ? formatNumber(data.policies.total) : "—"}
sub={
data.policies
? [
`${formatNumber(data.policies.active)} vigentes`,
`${formatNumber(data.policies.expiring)} por vencer (${EXPIRY_WINDOW_DAYS} d)`,
`${formatNumber(data.policies.expired + data.policies.undated)} vencidas o sin fecha`,
]
: []
}
/>
<KpiCard
href="/estado-cuenta"
label="Movimientos"
loading={loading}
primary={data.billing ? formatNumber(data.billing.movements) : "—"}
sub={
data.billing
? [
`${formatNumber(data.billing.ledgerCustomers)} clientes con cargo`,
`${formatNumber(data.billing.crossLineCustomers)} con ambos ramos`,
lastBillingMovement
? `Último: ${formatDate(lastBillingMovement)}`
: "Sin movimientos",
]
: []
}
/>
</section>
<section aria-label="Atención" className="home-section">
<div className="section-head">
<h2 className="section-title">Atención</h2>
<span className="section-sub">
Lo que conviene revisar antes de cerrar el día
</span>
</div>
<div className="attention-grid">
<AttentionCard
href="/polizas?status=expiring"
tone="warn"
loading={loading}
title="Pólizas por vencer"
primary={
data.policies ? formatNumber(data.policies.expiring) : "—"
}
sub={
data.policies
? `En los próximos ${EXPIRY_WINDOW_DAYS} días — ventana: ${policyStatusLabel("expiring")}`
: undefined
}
meta={
data.policies
? `${formatNumber(data.policies.active)} vigentes · ${formatNumber(data.policies.expired)} vencidas`
: undefined
}
/>
<AttentionCard
href="/servicios?trust=expiring"
tone="warn"
loading={loading}
title="Fideicomisos por vencer"
primary={
data.properties ? formatNumber(data.properties.trustExpiring) : "—"
}
sub={
data.properties
? `Renueva en los próximos ${EXPIRY_WINDOW_DAYS} días (${trustStatusLabel("expiring")})`
: undefined
}
meta={
data.properties
? `${formatNumber(data.properties.trusts)} fideicomisos en cartera`
: undefined
}
/>
<AttentionCard
href="/polizas?status=undated"
tone="muted"
loading={loading}
title="Pólizas sin vigencia"
primary={data.policies ? formatNumber(data.policies.undated) : "—"}
sub={
data.policies
? "Sin fecha de fin registrada — revisar y completar"
: undefined
}
meta={
data.policies
? `${formatNumber(data.policies.liquidated)} liquidadas`
: undefined
}
/>
<AttentionCard
href="/estado-cuenta"
tone="info"
loading={loading}
title="Clientes con adeudo"
primary={
data.billing ? (
<CurrencyTotals
totals={data.billing.byCurrency}
field="owing"
/>
) : (
"—"
)
}
sub={
data.billing
? `En ${formatNumber(data.billing.ledgerCustomers)} expedientes con cargo`
: undefined
}
meta={
data.billing
? `${formatNumber(data.billing.crossLineCustomers)} clientes con cargo en ambos ramos`
: undefined
}
/>
<AttentionCard
href="/banco"
tone="info"
loading={loading}
title="Chequera del despacho"
primary={
data.bank ? (
<span className={data.bank.net.startsWith("-") ? "money-neg" : "money-pos"}>
{formatMoney(data.bank.net, "MXN")}
</span>
) : (
"—"
)
}
sub={
data.bank
? balancePhrase(data.bank.net)
: undefined
}
meta={
data.bank
? `${formatNumber(data.bank.movements)} movimientos · ${formatNumber(data.bank.pending)} pendientes`
: undefined
}
/>
</div>
</section>
<section aria-label="Accesos rápidos" className="home-section">
<div className="section-head">
<h2 className="section-title">Accesos rápidos</h2>
<span className="section-sub">
Ir directo a cada módulo
</span>
</div>
<div className="quick-grid">
<QuickLink href="/clientes" label="Clientes" sub="Directorio unificado" />
<QuickLink href="/servicios" label="Propiedades" sub="Servicios y fideicomisos" />
<QuickLink href="/polizas" label="Pólizas" sub="Vigencias y liquidaciones" />
<QuickLink href="/estado-cuenta" label="Estado de cuenta" sub="Cargos y abonos" />
<QuickLink href="/banco" label="Chequera" sub="Ingresos y egresos" />
</div>
</section>
</div>
);
}
function greetingFor(name?: string): string {
const hour = new Date().getHours();
const partOfDay =
hour < 12 ? "Buenos días" : hour < 19 ? "Buenas tardes" : "Buenas noches";
const display = (name ?? "").trim().split(/\s+/)[0];
return display ? `${partOfDay}, ${display}` : partOfDay;
}
function LastSeenLine({
billing,
bank,
loading,
}: {
billing: string | null;
bank: string | null;
loading: boolean;
}) {
if (loading) {
return (
<p className="muted home-last-seen" aria-hidden="true">
<span className="skeleton" style={{ display: "inline-block", width: 220, height: 12 }} />
</p>
);
}
if (!billing && !bank) return null;
return (
<p className="muted home-last-seen">
{billing && <>Último movimiento de cartera: <strong>{formatDate(billing)}</strong></>}
{billing && bank && <span aria-hidden="true"> · </span>}
{bank && <>Último movimiento de chequera: <strong>{formatDate(bank)}</strong></>}
</p>
);
}
function KpiCard({
href,
label,
primary,
sub,
loading,
}: {
href: string;
label: string;
primary: React.ReactNode;
sub: string[];
loading: boolean;
}) {
return (
<Link href={href} className="kpi-card card">
<div className="kpi-label">{label}</div>
<div className="kpi-primary">
{loading ? (
<span
className="skeleton"
style={{ display: "inline-block", width: 90, height: 30 }}
/>
) : (
primary
)}
</div>
<ul className="kpi-sub">
{loading
? Array.from({ length: 3 }).map((_, i) => (
<li key={i} className="skeleton" style={{ height: 10 }} />
))
: sub.map((line) => <li key={line}>{line}</li>)}
</ul>
<span className="kpi-cta" aria-hidden="true">
Ver módulo
</span>
</Link>
);
}
function AttentionCard({
href,
tone,
title,
primary,
sub,
meta,
loading,
}: {
href: string;
tone: "warn" | "info" | "muted";
title: string;
primary: React.ReactNode;
sub?: string;
meta?: string;
loading: boolean;
}) {
return (
<Link href={href} className={`attention-card tone-${tone} card`}>
<div className="attention-head">
<span className="attention-title">{title}</span>
<span className="attention-arrow" aria-hidden="true"></span>
</div>
<div className="attention-primary">
{loading ? (
<span
className="skeleton"
style={{ display: "inline-block", width: 70, height: 28 }}
/>
) : (
primary
)}
</div>
{sub && !loading && <div className="attention-sub">{sub}</div>}
{meta && !loading && <div className="attention-meta">{meta}</div>}
</Link>
);
}
function QuickLink({
href,
label,
sub,
}: {
href: string;
label: string;
sub: string;
}) {
return (
<Link href={href} className="quick-link card">
<span className="quick-label">{label}</span>
<span className="quick-sub">{sub}</span>
<span className="quick-arrow" aria-hidden="true"></span>
</Link>
);
}
function CurrencyTotals({
totals,
field,
}: {
totals: BillingStats["byCurrency"];
field: "owing" | "inCredit";
}) {
if (!totals || totals.length === 0) return <></>;
return (
<span className="home-currency-totals">
{totals.map((c) => (
<span key={c.currency} className="home-currency-totals-row">
<span className="badge badge-count">{c.currency}</span>
<span className="home-currency-totals-num">
{formatNumber(c[field])}
</span>
</span>
))}
</span>
);
}
+2 -2
View File
@@ -16,7 +16,7 @@ export default function LoginPage() {
useEffect(() => {
let alive = true;
me()
.then(() => router.replace("/clientes"))
.then(() => router.replace("/inicio"))
.catch(() => {
if (alive) setBootChecking(false);
});
@@ -31,7 +31,7 @@ export default function LoginPage() {
setSubmitting(true);
try {
await login(email.trim(), password);
router.replace("/clientes");
router.replace("/inicio");
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
setError("Correo o contraseña incorrectos");
+1 -1
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/clientes");
redirect("/inicio");
}
+10
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { ContextReports } from "@/components/ContextReports";
import {
addPolicyChild,
archivePolicy,
@@ -92,6 +93,15 @@ function Detail({ id }: { id: string }) {
<div className="rise">
<div className="detail-actionbar">
<BackLink />
<ContextReports
entries={[
{
slug: "edo-cuenta-datos",
label: "Estado de cuenta del cliente",
params: { customerId: data.customer.id },
},
]}
/>
<PolicyActions data={data} onChange={reload} />
</div>
<Hero data={data} />
+8
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { ContextReports } from "@/components/ContextReports";
import { useCan } from "@/lib/abilities";
import {
EXPIRY_WINDOW_DAYS,
@@ -127,6 +128,13 @@ function PolizasBrowser() {
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<h1 className="page-title" style={{ margin: 0 }}>Pólizas</h1>
<span style={{ flex: 1 }} />
<ContextReports
entries={[
{ slug: "vigente", label: "Por vencer (Lic.)", params: { typeName: "LICENCIAS" } },
{ slug: "vigente", label: "Por vencer (Mult.)", params: { typeName: "MULT" } },
{ slug: "vigente", label: "Por vencer (Incen.)", params: { typeName: "INCEN" } },
]}
/>
{canCreate && (
<Link href="/polizas/nuevo" className="btn btn-primary">+ Nueva póliza</Link>
)}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { ReportRunner } from "@/components/ReportRunner";
import { getReportCatalog } from "@/lib/api";
import type { ReportDef } from "@/lib/types";
/**
* /reportes/[slug] — one report's runner page. The whole page is
* data-driven from the catalog; if a slug isn't in the registry the
* page shows a "not found" inline message.
*/
export default function ReporteRunnerPage({
params,
searchParams,
}: {
params: { slug: string };
searchParams?: Record<string, string | string[] | undefined>;
}) {
return (
<AppShell>
<Runner slug={params.slug} searchParams={searchParams} />
</AppShell>
);
}
function Runner({
slug,
searchParams,
}: {
slug: string;
searchParams?: Record<string, string | string[] | undefined>;
}) {
const [def, setDef] = useState<ReportDef | null>(null);
const [missing, setMissing] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getReportCatalog()
.then((c) => {
const found = c.items.find((r) => r.slug === slug);
if (found) setDef(found);
else setMissing(true);
})
.catch((e) => setError(e?.message ?? "No se pudo cargar el reporte."));
}, [slug]);
if (error) {
return (
<>
<BackLink />
<div className="report-error">{error}</div>
</>
);
}
if (missing) {
return (
<>
<BackLink />
<div className="empty-inline">Reporte "{slug}" no encontrado.</div>
</>
);
}
if (!def) {
return (
<>
<BackLink />
<div className="empty-inline">Cargando reporte</div>
</>
);
}
const initialParams: Record<string, string> = {};
if (searchParams) {
const allowed = new Set(def.params.map((p) => p.key));
for (const [k, v] of Object.entries(searchParams)) {
if (!allowed.has(k)) continue;
const s = Array.isArray(v) ? v[0] : v;
if (s) initialParams[k] = s;
}
}
return (
<>
<div className="page-head rise">
<p className="eyebrow">Reportes</p>
<h1 className="page-title">{def.title}</h1>
<p className="muted" style={{ marginTop: 6, maxWidth: 720 }}>
{def.description}
</p>
{def.legacyName && (
<p className="muted small" style={{ marginTop: 4 }}>
Equivalente en Access: <code>{def.legacyName}</code>
</p>
)}
</div>
<ReportRunner def={def} initialParams={initialParams} />
</>
);
}
function BackLink() {
return (
<Link href="/reportes" className="back-link">
Reportes
</Link>
);
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { getReportCatalog } from "@/lib/api";
import type { ReportCatalog, ReportDef, ReportDomain } from "@/lib/types";
/**
* The /reportes catalog. Index of every registered report, grouped by
* domain (matches the main nav). Discovery layer for the 280+ reports
* the system will eventually surface; for now we ship 6 in v1.
*/
export default function ReportesPage() {
return (
<AppShell>
<ReportesCatalog />
</AppShell>
);
}
const DOMAIN_LABEL: Record<ReportDomain, string> = {
clientes: "Clientes",
polizas: "Pólizas",
servicios: "Servicios",
"estado-cuenta": "Estado de cuenta",
chequera: "Chequera",
};
const DOMAIN_ORDER: ReportDomain[] = [
"clientes",
"estado-cuenta",
"polizas",
"servicios",
"chequera",
];
function ReportesCatalog() {
const [catalog, setCatalog] = useState<ReportCatalog | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getReportCatalog()
.then(setCatalog)
.catch((e) => setError(e?.message ?? "No se pudo cargar el catálogo."));
}, []);
const grouped = new Map<ReportDomain, ReportDef[]>();
if (catalog) {
for (const r of catalog.items) {
const list = grouped.get(r.domain) ?? [];
list.push(r);
grouped.set(r.domain, list);
}
}
return (
<>
<div className="page-head rise">
<p className="eyebrow">Reportes</p>
<h1 className="page-title">Catálogo de reportes</h1>
<p className="muted" style={{ marginTop: 6, maxWidth: 640 }}>
{catalog
? `${catalog.items.length} reportes disponibles. Cada uno corre como consulta sobre el esquema actual — los filtros y totales se recalculan en vivo.`
: "Cargando…"}
</p>
</div>
{error && <div className="report-error">{error}</div>}
<div className="report-catalog">
{DOMAIN_ORDER.filter((d) => grouped.has(d)).map((d) => (
<section key={d} className="report-catalog-group">
<h2 className="report-catalog-title">{DOMAIN_LABEL[d]}</h2>
<ul className="report-catalog-list">
{(grouped.get(d) ?? []).map((r) => (
<li key={r.slug} className="report-catalog-item">
<Link href={`/reportes/${r.slug}`} className="report-catalog-link">
<div className="report-catalog-item-title">{r.title}</div>
<div className="report-catalog-item-desc">{r.description}</div>
<div className="report-catalog-item-meta">
{r.legacyName && (
<span className="report-catalog-tag">
Legacy: {r.legacyName}
</span>
)}
<span className="report-catalog-tag muted">
{r.format === "statement" ? "Estado de cuenta" : "Tabular"}
</span>
</div>
</Link>
</li>
))}
</ul>
</section>
))}
</div>
</>
);
}
+8
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { ContextReports } from "@/components/ContextReports";
import { useCan } from "@/lib/abilities";
import {
EXPIRY_WINDOW_DAYS,
@@ -169,6 +170,13 @@ function ServiciosBrowser() {
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<h1 className="page-title" style={{ margin: 0 }}>Propiedades</h1>
<span style={{ flex: 1 }} />
<ContextReports
entries={[
{ slug: "faltantes", label: "Faltantes agua", params: { serviceKind: "WATER" } },
{ slug: "faltantes", label: "Faltantes luz", params: { serviceKind: "ELECTRICITY" } },
{ slug: "faltantes", label: "Faltantes tel.", params: { serviceKind: "PHONE" } },
]}
/>
{canCreate && (
<Link href="/servicios/nuevo" className="btn btn-primary">+ Nueva propiedad</Link>
)}