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
+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>
);
}