feat(reports): reports module + /inicio + edo-cuenta-datos prefill
- 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:
@@ -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">
|
||||
|
||||
@@ -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(() => {})}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/clientes");
|
||||
redirect("/inicio");
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -14,12 +14,14 @@ import type { AuthUser, Ability } from "@/lib/types";
|
||||
* content. Provides the AuthContext so any page can read the user's
|
||||
* abilities. Used by every authenticated page.
|
||||
*/
|
||||
const NAV: { href: string; label: string; ability?: Ability }[] = [
|
||||
const NAV: { href: string; label: string; ability?: Ability; exact?: boolean }[] = [
|
||||
{ href: "/inicio", label: "Inicio", exact: true },
|
||||
{ href: "/clientes", label: "Clientes" },
|
||||
{ href: "/servicios", label: "Propiedades" },
|
||||
{ href: "/polizas", label: "Pólizas" },
|
||||
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
||||
{ href: "/banco", label: "Chequera" },
|
||||
{ href: "/reportes", label: "Reportes" },
|
||||
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
|
||||
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
||||
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
||||
@@ -78,7 +80,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<AuthContext.Provider value={user}>
|
||||
<header className="appbar">
|
||||
<div className="appbar-inner">
|
||||
<Link href="/clientes" className="brand">
|
||||
<Link href="/inicio" className="brand">
|
||||
<img
|
||||
src="/images/company_logo.png"
|
||||
alt=""
|
||||
@@ -92,7 +94,9 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<nav className="appbar-nav" aria-label="Principal">
|
||||
{NAV.filter((item) => !item.ability || can(user, item.ability)).map(
|
||||
(item) => {
|
||||
const active = pathname?.startsWith(item.href) ?? false;
|
||||
const active = item.exact
|
||||
? pathname === item.href
|
||||
: pathname?.startsWith(item.href) ?? false;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* The context-report shortcut. One row of pill buttons that link to
|
||||
* pre-filtered /reportes/[slug] pages. Used in the page header of
|
||||
* domain pages (clientes, polizas, servicios, estado-cuenta, banco).
|
||||
*
|
||||
* `entries` accepts both static links (slug + label) and pre-filtered
|
||||
* links (slug + params object). Pre-filtered ones build the query
|
||||
* string automatically; the runner pre-fills the form.
|
||||
*/
|
||||
export interface ReportLink {
|
||||
slug: string;
|
||||
label: string;
|
||||
/** Optional pre-fill for the report's filter form. */
|
||||
params?: Record<string, string>;
|
||||
/** When true, opens the report in a new tab (for "see the catalog"
|
||||
* style entries where the user is going to look around). */
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export function ContextReports({
|
||||
label = "Reportes",
|
||||
entries,
|
||||
}: {
|
||||
label?: string;
|
||||
entries: ReportLink[];
|
||||
}) {
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<div className="context-reports" aria-label={label}>
|
||||
<span className="context-reports-label">{label}</span>
|
||||
{entries.map((e) => {
|
||||
const qs = e.params
|
||||
? "?" +
|
||||
new URLSearchParams(
|
||||
Object.entries(e.params).filter(([, v]) => v != null && v !== ""),
|
||||
).toString()
|
||||
: "";
|
||||
const href = `/reportes/${e.slug}${qs}`;
|
||||
return (
|
||||
<Link
|
||||
key={`${e.slug}-${JSON.stringify(e.params ?? {})}`}
|
||||
href={href}
|
||||
className="context-report-link"
|
||||
target={e.external ? "_blank" : undefined}
|
||||
rel={e.external ? "noopener" : undefined}
|
||||
>
|
||||
{e.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
API_ORIGIN,
|
||||
reportDownloadUrl,
|
||||
runReport,
|
||||
} from "@/lib/api";
|
||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||
import { formatMoney, formatNumber } from "@/lib/labels";
|
||||
import type {
|
||||
ReportDef,
|
||||
ReportParam,
|
||||
ReportRunResult,
|
||||
} from "@/lib/types";
|
||||
|
||||
/**
|
||||
* The shared runner. Renders the filter form, fetches the result, and
|
||||
* shows the table + output buttons. One component, every report — the
|
||||
* per-report shape comes entirely from the def the API returns.
|
||||
*/
|
||||
export function ReportRunner({
|
||||
def,
|
||||
initialParams,
|
||||
}: {
|
||||
def: ReportDef;
|
||||
/** Pre-filled param values (e.g. when launched with a customerId from
|
||||
* a context button on a customer detail page). */
|
||||
initialParams?: Record<string, string>;
|
||||
}) {
|
||||
// The form state, keyed by param.key. Initialised from defaults +
|
||||
// initialParams (initialParams wins for explicitly-set keys).
|
||||
const [params, setParams] = useState<Record<string, string>>(() => {
|
||||
const seed: Record<string, string> = {};
|
||||
for (const p of def.params) {
|
||||
if (p.kind === "select" || p.kind === "text" || p.kind === "number" || p.kind === "date") {
|
||||
if (p.defaultValue != null) seed[p.key] = p.defaultValue;
|
||||
}
|
||||
}
|
||||
if (initialParams) Object.assign(seed, initialParams);
|
||||
return seed;
|
||||
});
|
||||
|
||||
const [result, setResult] = useState<ReportRunResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const run = useCallback(
|
||||
(p: Record<string, string>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
runReport(def.slug, p)
|
||||
.then(setResult)
|
||||
.catch((e) => {
|
||||
setError(e?.message ?? "No se pudo correr el reporte.");
|
||||
setResult(null);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
[def.slug],
|
||||
);
|
||||
|
||||
// Auto-run on mount so the runner opens with results, not blank.
|
||||
useEffect(() => {
|
||||
run(params);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function updateParam(key: string, value: string) {
|
||||
setParams((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function applyFilters(e?: React.FormEvent) {
|
||||
e?.preventDefault();
|
||||
run(params);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="report-runner">
|
||||
<form className="report-filters" onSubmit={applyFilters}>
|
||||
{def.params.map((p) => (
|
||||
<ParamField
|
||||
key={p.key}
|
||||
param={p}
|
||||
value={params[p.key] ?? ""}
|
||||
onChange={(v) => updateParam(p.key, v)}
|
||||
/>
|
||||
))}
|
||||
<div className="report-filters-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Corriendo…" : "Correr reporte"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{error && <div className="report-error">{error}</div>}
|
||||
|
||||
{result && (
|
||||
<ResultBlock def={def} result={result} params={params} loading={loading} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- one param field */
|
||||
|
||||
function ParamField({
|
||||
param,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
param: ReportParam;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const label = (
|
||||
<span className="filter-label">
|
||||
{param.label}
|
||||
{param.kind === "customer-picker" && !value && (
|
||||
<span className="muted" style={{ marginLeft: 6, fontWeight: 400 }}>
|
||||
(requerido)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (param.kind === "select") {
|
||||
return (
|
||||
<label className="filter-field">
|
||||
{label}
|
||||
<select
|
||||
className="input select"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
{!param.defaultValue && <option value="">—</option>}
|
||||
{param.options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (param.kind === "date") {
|
||||
return (
|
||||
<label className="filter-field">
|
||||
{label}
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (param.kind === "number") {
|
||||
return (
|
||||
<label className="filter-field">
|
||||
{label}
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={param.defaultValue}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (param.kind === "customer-picker") {
|
||||
return (
|
||||
<div className="filter-field">
|
||||
{label}
|
||||
<CustomerPicker
|
||||
value={value}
|
||||
onPick={(id) => onChange(id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<label className="filter-field">
|
||||
{label}
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={param.placeholder ?? ""}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- results block */
|
||||
|
||||
function ResultBlock({
|
||||
def,
|
||||
result,
|
||||
params,
|
||||
loading,
|
||||
}: {
|
||||
def: ReportDef;
|
||||
result: ReportRunResult;
|
||||
params: Record<string, string>;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="report-result">
|
||||
<div className="report-result-head">
|
||||
<div className="report-result-meta">
|
||||
{result.subtitle && <p className="muted">{result.subtitle}</p>}
|
||||
<p className="muted small">
|
||||
{formatNumber(result.rows.length)} fila
|
||||
{result.rows.length === 1 ? "" : "s"}
|
||||
{result.totals && (
|
||||
<>
|
||||
{" "}·{" "}
|
||||
{Object.entries(result.totals)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(" · ")}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="report-output-buttons">
|
||||
<a
|
||||
className="btn btn-outline btn-sm"
|
||||
href={reportDownloadUrl(def.slug, "print", params)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Imprimir
|
||||
</a>
|
||||
<a
|
||||
className="btn btn-outline btn-sm"
|
||||
href={reportDownloadUrl(def.slug, "csv", params)}
|
||||
download
|
||||
>
|
||||
CSV
|
||||
</a>
|
||||
<a
|
||||
className="btn btn-outline btn-sm"
|
||||
href={reportDownloadUrl(def.slug, "xlsx", params)}
|
||||
download
|
||||
>
|
||||
Excel
|
||||
</a>
|
||||
<a
|
||||
className="btn btn-outline btn-sm"
|
||||
href={reportDownloadUrl(def.slug, "pdf", params)}
|
||||
download
|
||||
>
|
||||
PDF
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <div className="report-loading">Actualizando…</div>}
|
||||
|
||||
{def.format === "statement" ? (
|
||||
<StatementLayout result={result} />
|
||||
) : (
|
||||
<TabularLayout result={result} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- tabular layout */
|
||||
|
||||
function TabularLayout({ result }: { result: ReportRunResult }) {
|
||||
if (result.rows.length === 0) {
|
||||
return (
|
||||
<div className="empty-inline">
|
||||
No se encontraron filas con los filtros actuales.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="report-table-wrap">
|
||||
<table className="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{result.columns.map((c) => (
|
||||
<th
|
||||
key={c.key}
|
||||
style={{
|
||||
textAlign: c.align ?? "left",
|
||||
width: c.width ? `${c.width * 6}px` : undefined,
|
||||
}}
|
||||
>
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.rows.map((r, i) => (
|
||||
<tr key={i}>
|
||||
{result.columns.map((c) => {
|
||||
const v = r[c.key];
|
||||
return (
|
||||
<td
|
||||
key={c.key}
|
||||
style={{
|
||||
textAlign: c.align ?? "left",
|
||||
fontVariantNumeric:
|
||||
c.type === "money" || c.type === "number"
|
||||
? "tabular-nums"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{formatCell(v, c.type)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{result.totals && (
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td
|
||||
colSpan={result.columns.length}
|
||||
className="report-totals"
|
||||
>
|
||||
{Object.entries(result.totals)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(" · ")}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCell(v: unknown, type: string): string {
|
||||
if (v === null || v === undefined || v === "") return "—";
|
||||
if (type === "money") {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n)
|
||||
? new Intl.NumberFormat("es-MX", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(n)
|
||||
: String(v);
|
||||
}
|
||||
if (type === "number") {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? formatNumber(n) : String(v);
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- statement layout */
|
||||
|
||||
function StatementLayout({ result }: { result: ReportRunResult }) {
|
||||
// The edo-cuenta-datos report synthesises a list with __kind
|
||||
// discriminators (header, summary, movements-header, plain movement).
|
||||
// Group by kind and render each block inline.
|
||||
const header = result.rows.find((r) => r.__kind === "header") as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const summaries = result.rows.filter((r) => r.__kind === "summary");
|
||||
const movements = result.rows.filter(
|
||||
(r) => r.__kind !== "header" && r.__kind !== "summary" && r.__kind !== "movements-header",
|
||||
);
|
||||
|
||||
if (!header) {
|
||||
return (
|
||||
<div className="empty-inline">
|
||||
Selecciona un cliente y corre el reporte para ver el estado de cuenta.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="statement">
|
||||
<header className="statement-head">
|
||||
<h2 className="statement-name">{String(header.name ?? "—")}</h2>
|
||||
{Boolean(header.address) && (
|
||||
<p className="muted">{String(header.address)}</p>
|
||||
)}
|
||||
{Boolean(header.city) && <p className="muted">{String(header.city)}</p>}
|
||||
{Boolean(header.phone || header.email) && (
|
||||
<p className="muted small">
|
||||
{String(header.phone ?? "")}
|
||||
{header.phone && header.email ? " · " : ""}
|
||||
{String(header.email ?? "")}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{summaries.length > 0 && (
|
||||
<section className="statement-summary">
|
||||
<h3 className="statement-section-title">Resumen por moneda</h3>
|
||||
<table className="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Moneda</th>
|
||||
<th style={{ textAlign: "right" }}>Cargos</th>
|
||||
<th style={{ textAlign: "right" }}>Abonos</th>
|
||||
<th style={{ textAlign: "right" }}>Saldo</th>
|
||||
<th style={{ textAlign: "right" }}>Movs.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summaries.map((s, i) => (
|
||||
<tr key={i}>
|
||||
<td>{String(s.currency)}</td>
|
||||
<td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums" }}>
|
||||
{formatCell(s.charges, "money")}
|
||||
</td>
|
||||
<td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums" }}>
|
||||
{formatCell(s.credits, "money")}
|
||||
</td>
|
||||
<td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums" }}>
|
||||
{formatCell(s.balance, "money")}
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>{formatCell(s.count, "number")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{movements.length > 0 && (
|
||||
<section className="statement-movements">
|
||||
<h3 className="statement-section-title">Movimientos</h3>
|
||||
<div className="report-table-wrap">
|
||||
<table className="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Concepto</th>
|
||||
<th>Referencia</th>
|
||||
<th style={{ textAlign: "right" }}>Cargo / Abono</th>
|
||||
<th style={{ textAlign: "right" }}>Saldo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movements.map((m, i) => (
|
||||
<tr key={i}>
|
||||
<td>{String(m.date ?? "")}</td>
|
||||
<td>{String(m.concept ?? "")}</td>
|
||||
<td>{String(m.reference ?? "")}</td>
|
||||
<td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums" }}>
|
||||
{formatCell(m.amount, "money")}
|
||||
</td>
|
||||
<td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums" }}>
|
||||
{formatCell(m.balanceAfter, "money")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Hint to the bundler that API_ORIGIN is part of the API surface used here.
|
||||
void API_ORIGIN;
|
||||
@@ -44,6 +44,8 @@ import type {
|
||||
PropertyListResponse,
|
||||
PropertySort,
|
||||
PropertyStats,
|
||||
ReportCatalog,
|
||||
ReportRunResult,
|
||||
ServiceInput,
|
||||
TrustInput,
|
||||
Role,
|
||||
@@ -730,3 +732,37 @@ export function startOpsJob(kind: OpsJobKind, file?: string): Promise<OpsJob> {
|
||||
body: JSON.stringify({ kind, file }),
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- Reports module */
|
||||
|
||||
export function getReportCatalog(): Promise<ReportCatalog> {
|
||||
return apiFetch<ReportCatalog>("/reports");
|
||||
}
|
||||
|
||||
export function runReport(
|
||||
slug: string,
|
||||
params: Record<string, string | undefined>,
|
||||
): Promise<ReportRunResult> {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v != null && v !== "") qs.set(k, v);
|
||||
}
|
||||
const tail = qs.toString();
|
||||
return apiFetch<ReportRunResult>(`/reports/${slug}${tail ? `?${tail}` : ""}`);
|
||||
}
|
||||
|
||||
/** Build a download URL for a report's file output. The session cookie
|
||||
* travels with the browser's same-origin navigation, so a plain `href`
|
||||
* is enough — no fetch-with-credentials dance. */
|
||||
export function reportDownloadUrl(
|
||||
slug: string,
|
||||
format: "csv" | "xlsx" | "pdf" | "print",
|
||||
params: Record<string, string | undefined>,
|
||||
): string {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v != null && v !== "") qs.set(k, v);
|
||||
}
|
||||
const tail = qs.toString();
|
||||
return `${API_ORIGIN}/reports/${slug}/${format}${tail ? `?${tail}` : ""}`;
|
||||
}
|
||||
|
||||
@@ -1025,3 +1025,58 @@ export interface BankSummary {
|
||||
/** Cumulative figure the selected year opened on. */
|
||||
opening: string;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- Reports module */
|
||||
|
||||
export type ReportDomain =
|
||||
| "clientes"
|
||||
| "polizas"
|
||||
| "servicios"
|
||||
| "estado-cuenta"
|
||||
| "chequera";
|
||||
|
||||
export type ReportFormat = "tabular" | "statement";
|
||||
|
||||
/** Param declaration a report exposes to its filter form. */
|
||||
export type ReportParam =
|
||||
| { key: string; label: string; kind: "text"; placeholder?: string; defaultValue?: string }
|
||||
| { key: string; label: string; kind: "number"; defaultValue?: string }
|
||||
| { key: string; label: string; kind: "date"; endOfDay?: boolean; defaultValue?: string }
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: "select";
|
||||
options: { value: string; label: string }[];
|
||||
defaultValue?: string;
|
||||
}
|
||||
| { key: string; label: string; kind: "customer-picker" };
|
||||
|
||||
export interface ReportColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "text" | "number" | "money" | "date";
|
||||
align?: "left" | "right";
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export interface ReportDef {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
domain: ReportDomain;
|
||||
legacyName: string | null;
|
||||
format: ReportFormat;
|
||||
params: ReportParam[];
|
||||
columns: ReportColumn[];
|
||||
}
|
||||
|
||||
export interface ReportRunResult {
|
||||
columns: ReportColumn[];
|
||||
rows: Array<Record<string, unknown>>;
|
||||
totals?: Record<string, string | number>;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export interface ReportCatalog {
|
||||
items: ReportDef[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user