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:
@@ -3,3 +3,16 @@ DATABASE_URL=mysql://jorgecuadros:jorgecuadros@localhost:3306/jorgecuadros
|
||||
SESSION_SECRET=change-me-to-a-random-string
|
||||
WEB_ORIGIN=http://localhost:3000
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:3001
|
||||
|
||||
# Company info — printed in the header of every report (PDF + browser
|
||||
# print). Leave blank to use the placeholders. COMPANY_LOGO_PATH is
|
||||
# optional; when unset the API falls back to apps/api/assets/company_logo.png.
|
||||
COMPANY_NAME=Jorge Cuadros & Asociados
|
||||
COMPANY_ADDRESS_LINE1=
|
||||
COMPANY_ADDRESS_LINE2=
|
||||
COMPANY_CITY_STATE=
|
||||
COMPANY_PHONE=
|
||||
COMPANY_EMAIL=
|
||||
COMPANY_TAX_ID=
|
||||
COMPANY_WEBSITE=
|
||||
COMPANY_LOGO_PATH=
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
@@ -21,7 +21,9 @@
|
||||
"argon2": "^0.41.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"express-session": "^1.18.0",
|
||||
"pdfkit": "^0.15.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@@ -32,6 +34,7 @@
|
||||
"@nestjs/testing": "^10.4.4",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/pdfkit": "^0.13.5",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/node": "^20.16.11",
|
||||
"@types/passport": "^1.0.17",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PropertiesModule } from "./properties/properties.module";
|
||||
import { BillingModule } from "./billing/billing.module";
|
||||
import { BankModule } from "./bank/bank.module";
|
||||
import { OpsModule } from "./ops/ops.module";
|
||||
import { ReportsModule } from "./reports/reports.module";
|
||||
import { AppController } from "./app.controller";
|
||||
|
||||
@Module({
|
||||
@@ -27,6 +28,7 @@ import { AppController } from "./app.controller";
|
||||
BillingModule,
|
||||
BankModule,
|
||||
OpsModule,
|
||||
ReportsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Company info used on every report header (PDF + print). Read from
|
||||
* the environment so the office can edit it without a code change —
|
||||
* the .env.example file lists the keys; defaults below are placeholders
|
||||
* the office should override for production.
|
||||
*
|
||||
* Single source of truth: the API renders the header. The web header
|
||||
* (login + AppShell) still reads the static "Jorge Cuadros & Asociados"
|
||||
* strings for now — those are visual brand, the API's COMPANY_INFO
|
||||
* block is the legal/locator block on printed documents.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export interface CompanyInfo {
|
||||
name: string;
|
||||
/** Street address — line 1. */
|
||||
addressLine1: string;
|
||||
/** Street address — line 2 (suite, floor, etc.). Optional. */
|
||||
addressLine2: string;
|
||||
/** "City, State, ZIP, Country" — single line. */
|
||||
cityState: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
/** Mexican tax ID ("RFC"). Optional. */
|
||||
taxId: string;
|
||||
website: string;
|
||||
/** Absolute path to the logo PNG. Null when missing — renderers fall
|
||||
* back to a text mark. */
|
||||
logoPath: string | null;
|
||||
/** Logo buffer + intrinsic size, eagerly loaded so the PDF renderer
|
||||
* doesn't do a sync read on every report. Null when no logo. */
|
||||
logo: { buffer: Buffer; width: number; height: number } | null;
|
||||
}
|
||||
|
||||
function envOr(key: string, fallback: string): string {
|
||||
const v = process.env[key];
|
||||
return v && v.trim() ? v : fallback;
|
||||
}
|
||||
|
||||
function resolveLogoPath(): string | null {
|
||||
const explicit = process.env.COMPANY_LOGO_PATH;
|
||||
if (explicit) {
|
||||
return fs.existsSync(explicit) ? explicit : null;
|
||||
}
|
||||
// Default: look in apps/api/assets/company_logo.png (copied from
|
||||
// apps/web/public/images/company_logo.png — single canonical image
|
||||
// kept in lock-step; see .env.example for the override path).
|
||||
const candidates = [
|
||||
path.resolve(__dirname, "..", "..", "assets", "company_logo.png"),
|
||||
path.resolve(__dirname, "..", "..", "..", "web", "public", "images", "company_logo.png"),
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let cached: CompanyInfo | null = null;
|
||||
|
||||
export function getCompanyInfo(): CompanyInfo {
|
||||
if (cached) return cached;
|
||||
const logoPath = resolveLogoPath();
|
||||
let logo: CompanyInfo["logo"] = null;
|
||||
if (logoPath) {
|
||||
try {
|
||||
const buf = fs.readFileSync(logoPath);
|
||||
// Intrinsic PNG size: read IHDR (bytes 16-23 of the file).
|
||||
// Width = BE uint32 at offset 16, height = BE uint32 at offset 20.
|
||||
const w =
|
||||
logoPath.endsWith(".png") && buf.length >= 24
|
||||
? buf.readUInt32BE(16)
|
||||
: 0;
|
||||
const h =
|
||||
logoPath.endsWith(".png") && buf.length >= 24
|
||||
? buf.readUInt32BE(20)
|
||||
: 0;
|
||||
logo = { buffer: buf, width: w, height: h };
|
||||
} catch {
|
||||
logo = null;
|
||||
}
|
||||
}
|
||||
cached = {
|
||||
name: envOr("COMPANY_NAME", "Jorge Cuadros & Asociados"),
|
||||
addressLine1: envOr(
|
||||
"COMPANY_ADDRESS_LINE1",
|
||||
"Av. Revolución 1234, Int. 5",
|
||||
),
|
||||
addressLine2: envOr("COMPANY_ADDRESS_LINE2", ""),
|
||||
cityState: envOr(
|
||||
"COMPANY_CITY_STATE",
|
||||
"Tijuana, Baja California 22000, México",
|
||||
),
|
||||
phone: envOr("COMPANY_PHONE", "(664) 000-0000"),
|
||||
email: envOr("COMPANY_EMAIL", "contacto@jorgecuadros.local"),
|
||||
taxId: envOr("COMPANY_TAX_ID", ""),
|
||||
website: envOr("COMPANY_WEBSITE", "jorgecuadros.local"),
|
||||
logoPath,
|
||||
logo,
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Output renderers for the reports module.
|
||||
*
|
||||
* Every report's `run` returns `{ columns, rows, totals?, subtitle? }`.
|
||||
* CSV/XLSX/PDF all derive from the same shape so adding a report = one
|
||||
* registry entry, no per-format template.
|
||||
*
|
||||
* PDF uses pdfkit. The statement format (edo-cuenta-datos) uses a
|
||||
* different layout than the tabular one — handled inline.
|
||||
*/
|
||||
|
||||
// pdfkit exports its constructor via `module.exports = PDFDocument`, so a
|
||||
// namespace import gets the type, and `import = require()` gets the value.
|
||||
import PDFDocument = require("pdfkit");
|
||||
import * as ExcelJS from "exceljs";
|
||||
import type { ColumnDef, ReportResult } from "./reports.types";
|
||||
import { getCompanyInfo } from "./company";
|
||||
|
||||
type Doc = PDFKit.PDFDocument;
|
||||
|
||||
/* ----------------------------------------------------------------- CSV */
|
||||
|
||||
function csvCell(v: unknown): string {
|
||||
if (v === null || v === undefined) return "";
|
||||
const s = String(v);
|
||||
if (s.includes(",") || s.includes('"') || s.includes("\n") || s.includes("\r")) {
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function renderCsv(columns: ColumnDef[], result: ReportResult): string {
|
||||
const headers = columns.map((c) => csvCell(c.label)).join(",");
|
||||
const lines = result.rows.map((r) =>
|
||||
columns
|
||||
.map((c) => {
|
||||
const v = r[c.key];
|
||||
if (typeof v === "number") return v;
|
||||
return csvCell(v);
|
||||
})
|
||||
.join(","),
|
||||
);
|
||||
const totals: string[] = [];
|
||||
if (result.totals) {
|
||||
for (const [k, v] of Object.entries(result.totals)) {
|
||||
totals.push(csvCell(k), csvCell(v));
|
||||
}
|
||||
}
|
||||
return [headers, ...lines, ...(totals.length ? [totals.join(",")] : [])].join(
|
||||
"\n",
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- XLSX */
|
||||
|
||||
export async function renderXlsx(
|
||||
columns: ColumnDef[],
|
||||
result: ReportResult,
|
||||
): Promise<Buffer> {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = "Jorge Cuadros & Asociados";
|
||||
const ws = wb.addWorksheet("Reporte", {
|
||||
views: [{ state: "frozen", ySplit: 1 }],
|
||||
});
|
||||
ws.columns = columns.map((c) => ({
|
||||
header: c.label,
|
||||
key: c.key,
|
||||
width: Math.max(10, Math.min(40, (c.label.length + 2) * 1.2)),
|
||||
}));
|
||||
ws.getRow(1).font = { bold: true };
|
||||
ws.getRow(1).fill = {
|
||||
type: "pattern",
|
||||
pattern: "solid",
|
||||
fgColor: { argb: "FFE2EDE9" }, // brand-tint
|
||||
};
|
||||
for (const row of result.rows) {
|
||||
ws.addRow(row);
|
||||
}
|
||||
// Number formatting for money columns.
|
||||
for (const col of columns) {
|
||||
if (col.type === "money" || col.type === "number") {
|
||||
ws.getColumn(col.key).numFmt =
|
||||
col.type === "money" ? "#,##0.00" : "#,##0";
|
||||
ws.getColumn(col.key).alignment = { horizontal: "right" };
|
||||
}
|
||||
}
|
||||
if (result.totals) {
|
||||
const last = ws.addRow({});
|
||||
let i = 1;
|
||||
for (const [k, v] of Object.entries(result.totals)) {
|
||||
const cell = ws.getCell(last.number, i);
|
||||
cell.value = `${k}: ${v}`;
|
||||
cell.font = { bold: true };
|
||||
i++;
|
||||
}
|
||||
}
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
return Buffer.from(buf);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- PDF */
|
||||
|
||||
const BRAND = "#0c322d";
|
||||
const ACCENT = "#bf5a34";
|
||||
const MUTED = "#756c5c";
|
||||
const LINE = "#e4dccb";
|
||||
|
||||
function fmtMoney(v: unknown): string {
|
||||
if (v === null || v === undefined || v === "") return "";
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return String(v);
|
||||
return n.toLocaleString("es-MX", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function pdfRow(
|
||||
doc: Doc,
|
||||
y: number,
|
||||
cols: Array<{ label: string; width: number; align?: "left" | "right" }>,
|
||||
values: Array<{ text: string; align?: "left" | "right" }>,
|
||||
x: number,
|
||||
): number {
|
||||
let cx = x;
|
||||
for (let i = 0; i < cols.length; i++) {
|
||||
const c = cols[i];
|
||||
const v = values[i] ?? { text: "" };
|
||||
const align = v.align ?? c.align ?? "left";
|
||||
const w = c.width;
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.fontSize(9)
|
||||
.fillColor("#211d17")
|
||||
.text(v.text, cx, y, {
|
||||
width: w - 4,
|
||||
align,
|
||||
ellipsis: true,
|
||||
lineBreak: false,
|
||||
height: 16,
|
||||
});
|
||||
cx += w;
|
||||
}
|
||||
return y + 18;
|
||||
}
|
||||
|
||||
export function renderPdf(
|
||||
columns: ColumnDef[],
|
||||
result: ReportResult,
|
||||
title: string,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const doc = new PDFDocument({
|
||||
size: "LETTER",
|
||||
layout: "landscape",
|
||||
margins: { top: 96, bottom: 56, left: 48, right: 48 },
|
||||
bufferPages: true,
|
||||
info: {
|
||||
Title: title,
|
||||
Author: "Jorge Cuadros & Asociados",
|
||||
Subject: "Reporte",
|
||||
Creator: "Jorge Cuadros Platform — Reports module",
|
||||
},
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on("data", (c: Buffer) => chunks.push(c));
|
||||
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
doc.on("error", reject);
|
||||
|
||||
const company = getCompanyInfo();
|
||||
const pageW = doc.page.width - 96;
|
||||
|
||||
/** The header is repeated on every page (via addPage + manual draw). */
|
||||
const drawHeader = () => {
|
||||
// Background bar (brand pine) for the masthead.
|
||||
doc.rect(0, 0, doc.page.width, 60).fill(BRAND);
|
||||
|
||||
// Logo, fitted to a 40px box, with 8px padding.
|
||||
let textX = 48;
|
||||
if (company.logo) {
|
||||
const targetH = 40;
|
||||
const scale = targetH / company.logo.height;
|
||||
const w = company.logo.width * scale;
|
||||
doc.image(company.logo.buffer, 48, 10, { height: targetH });
|
||||
textX = 48 + w + 14;
|
||||
}
|
||||
|
||||
// Company name (large) + "Reporte" tag below it.
|
||||
doc
|
||||
.fillColor("#f5f1e8")
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(15)
|
||||
.text(company.name, textX, 14, { width: pageW - (textX - 48), lineBreak: false });
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.fontSize(8)
|
||||
.fillColor("#cde0db")
|
||||
.text("Reporte", textX, 36, { lineBreak: false });
|
||||
|
||||
// Right-aligned company locator (address + phone + email).
|
||||
const rightLines = [
|
||||
company.addressLine1,
|
||||
company.addressLine2,
|
||||
[company.cityState].filter(Boolean).join(" · "),
|
||||
[company.phone, company.email].filter(Boolean).join(" · "),
|
||||
company.taxId ? `RFC: ${company.taxId}` : "",
|
||||
].filter(Boolean);
|
||||
doc.font("Helvetica").fontSize(8).fillColor("#cde0db");
|
||||
let ry = 12;
|
||||
for (const line of rightLines) {
|
||||
doc.text(line, 48, ry, {
|
||||
width: pageW,
|
||||
align: "right",
|
||||
lineBreak: false,
|
||||
ellipsis: true,
|
||||
});
|
||||
ry += 10;
|
||||
}
|
||||
|
||||
// Thin accent line under the masthead.
|
||||
doc.rect(0, 60, doc.page.width, 2).fill(ACCENT);
|
||||
|
||||
// Title + subtitle + printed-at.
|
||||
doc
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(15)
|
||||
.fillColor(BRAND)
|
||||
.text(title, 48, 72, { lineBreak: false });
|
||||
let metaY = 92;
|
||||
if (result.subtitle) {
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.fontSize(9)
|
||||
.fillColor(MUTED)
|
||||
.text(result.subtitle, 48, metaY, { lineBreak: false });
|
||||
metaY += 12;
|
||||
}
|
||||
const printedAt = new Date().toLocaleString("es-MX");
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.fontSize(8)
|
||||
.fillColor(MUTED)
|
||||
.text(`Impreso: ${printedAt}`, 48, metaY, { lineBreak: false });
|
||||
};
|
||||
|
||||
drawHeader();
|
||||
|
||||
// Column widths: distribute page width minus margins, weighted.
|
||||
const totalW = columns.reduce((s, c) => s + (c.width ?? 12), 0);
|
||||
const cols = columns.map((c) => ({
|
||||
label: c.label,
|
||||
width: ((c.width ?? 12) / totalW) * pageW,
|
||||
align: c.align,
|
||||
}));
|
||||
|
||||
let y = 130;
|
||||
const drawTableHeader = () => {
|
||||
doc.rect(48, y, pageW, 18).fill("#faf6ee");
|
||||
y = pdfRow(
|
||||
doc,
|
||||
y + 4,
|
||||
cols,
|
||||
cols.map((c) => ({ text: c.label, align: c.align })),
|
||||
48,
|
||||
);
|
||||
doc
|
||||
.moveTo(48, y)
|
||||
.lineTo(48 + pageW, y)
|
||||
.strokeColor(LINE)
|
||||
.lineWidth(0.5)
|
||||
.stroke();
|
||||
};
|
||||
drawTableHeader();
|
||||
|
||||
// Body rows.
|
||||
for (const r of result.rows) {
|
||||
if (y > doc.page.height - 64) {
|
||||
doc.addPage({ layout: "landscape", margins: { top: 96, bottom: 56, left: 48, right: 48 } });
|
||||
drawHeader();
|
||||
y = 130;
|
||||
drawTableHeader();
|
||||
}
|
||||
const vals = columns.map((c) => {
|
||||
const v = r[c.key];
|
||||
const text = c.type === "money" ? fmtMoney(v) : v == null ? "" : String(v);
|
||||
return { text, align: c.align };
|
||||
});
|
||||
y = pdfRow(doc, y + 4, cols, vals, 48);
|
||||
doc
|
||||
.moveTo(48, y)
|
||||
.lineTo(48 + pageW, y)
|
||||
.strokeColor("#e4dccb")
|
||||
.lineWidth(0.4)
|
||||
.stroke();
|
||||
}
|
||||
|
||||
// Totals.
|
||||
if (result.totals) {
|
||||
y += 6;
|
||||
doc.rect(48, y, pageW, 18).fill(ACCENT);
|
||||
doc
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(9)
|
||||
.fillColor("#f5f1e8")
|
||||
.text(
|
||||
Object.entries(result.totals)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(" · "),
|
||||
52,
|
||||
y + 5,
|
||||
{ width: pageW - 8, align: "left" },
|
||||
);
|
||||
}
|
||||
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- print (HTML) */
|
||||
|
||||
/**
|
||||
* Print-stylesheet-friendly HTML. The web app's print stylesheet hides
|
||||
* nav, but otherwise this is a plain table the browser paginates itself.
|
||||
*
|
||||
* The header carries the company info (logo + name + locator) so printed
|
||||
* pages stand alone — staff can hand one to a customer and the office
|
||||
* identification is on every sheet, not buried in the cover page.
|
||||
*/
|
||||
export function renderPrintHtml(
|
||||
columns: ColumnDef[],
|
||||
result: ReportResult,
|
||||
title: string,
|
||||
): string {
|
||||
const company = getCompanyInfo();
|
||||
const head = (label: string, align?: "left" | "right") =>
|
||||
`<th style="text-align:${align ?? "left"};padding:6px 8px;border-bottom:2px solid #0c322d;background:#faf6ee;font-size:11px">${escapeHtml(label)}</th>`;
|
||||
const cell = (v: unknown, c: ColumnDef) => {
|
||||
const text = c.type === "money" ? fmtMoney(v) : v == null ? "" : String(v);
|
||||
const align = c.align ?? "left";
|
||||
return `<td style="text-align:${align};padding:4px 8px;border-bottom:1px solid #e4dccb;font-size:11px;${c.type === "money" ? "font-variant-numeric:tabular-nums" : ""}">${escapeHtml(text)}</td>`;
|
||||
};
|
||||
const rows = result.rows
|
||||
.map(
|
||||
(r) =>
|
||||
`<tr>${columns
|
||||
.map((c) => cell(r[c.key], c))
|
||||
.join("")}</tr>`,
|
||||
)
|
||||
.join("");
|
||||
const totals = result.totals
|
||||
? `<tr><td colspan="${columns.length}" style="padding:8px;background:#bf5a34;color:#f5f1e8;font-weight:600;font-size:11px">${Object.entries(
|
||||
result.totals,
|
||||
)
|
||||
.map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(String(v))}`)
|
||||
.join(" · ")}</td></tr>`
|
||||
: "";
|
||||
|
||||
// Logo embedded as base64 data URL — the print page is opened as a
|
||||
// new tab and printed standalone, so a relative path to the web app
|
||||
// wouldn't resolve when launched outside the web's origin.
|
||||
const logoDataUrl = company.logo
|
||||
? `data:image/png;base64,${company.logo.buffer.toString("base64")}`
|
||||
: null;
|
||||
|
||||
const locatorLines = [
|
||||
company.addressLine1,
|
||||
company.addressLine2,
|
||||
company.cityState,
|
||||
[company.phone, company.email].filter(Boolean).join(" · "),
|
||||
company.taxId ? `RFC: ${company.taxId}` : "",
|
||||
].filter(Boolean);
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="es"><head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${escapeHtml(title)} — ${escapeHtml(company.name)}</title>
|
||||
<style>
|
||||
@page { size: letter landscape; margin: 0.5in; }
|
||||
body { font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif; color: #211d17; margin: 0; }
|
||||
.masthead { display: flex; align-items: flex-start; gap: 16px; padding: 12px 16px; background: #0c322d; color: #f5f1e8; border-radius: 6px 6px 0 0; }
|
||||
.masthead-logo { flex: 0 0 auto; }
|
||||
.masthead-logo img { display: block; height: 56px; width: auto; }
|
||||
.masthead-text { flex: 1; min-width: 0; }
|
||||
.masthead-name { font-family: Georgia, "Times New Roman", serif; font-size: 20px; font-weight: 600; line-height: 1.1; }
|
||||
.masthead-tag { font-size: 11px; color: #cde0db; margin-top: 2px; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.masthead-locator { font-size: 10px; color: #cde0db; text-align: right; line-height: 1.35; white-space: nowrap; }
|
||||
.accent { height: 3px; background: #bf5a34; }
|
||||
.head { padding: 12px 4px 8px; }
|
||||
.head h1 { font-family: Georgia, "Times New Roman", serif; font-size: 18px; margin: 0; color: #0c322d; }
|
||||
.head p { font-size: 11px; color: #756c5c; margin: 2px 0 0; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
@media print {
|
||||
.noprint { display: none; }
|
||||
.masthead { border-radius: 0; }
|
||||
}
|
||||
.noprint { padding: 8px 0; }
|
||||
.noprint button { padding: 6px 12px; background: #0c322d; color: #f5f1e8; border: 0; border-radius: 4px; cursor: pointer; font-size: 12px; }
|
||||
.footer { margin-top: 16px; font-size: 9px; color: #756c5c; border-top: 1px solid #e4dccb; padding-top: 6px; display: flex; justify-content: space-between; }
|
||||
</style>
|
||||
</head><body>
|
||||
<div class="noprint"><button onclick="window.print()">Imprimir / Guardar PDF</button></div>
|
||||
<div class="masthead">
|
||||
${logoDataUrl ? `<div class="masthead-logo"><img src="${logoDataUrl}" alt="" /></div>` : ""}
|
||||
<div class="masthead-text">
|
||||
<div class="masthead-name">${escapeHtml(company.name)}</div>
|
||||
<div class="masthead-tag">Reporte</div>
|
||||
</div>
|
||||
<div class="masthead-locator">
|
||||
${locatorLines.map((l) => escapeHtml(l)).join("<br/>")}
|
||||
${company.website ? `<br/>${escapeHtml(company.website)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="accent"></div>
|
||||
<div class="head">
|
||||
<h1>${escapeHtml(title)}</h1>
|
||||
${result.subtitle ? `<p>${escapeHtml(result.subtitle)}</p>` : ""}
|
||||
<p>Impreso: ${new Date().toLocaleString("es-MX")}</p>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr>${columns.map((c) => head(c.label, c.align)).join("")}</tr></thead>
|
||||
<tbody>${rows}${totals}</tbody>
|
||||
</table>
|
||||
<div class="footer">
|
||||
<span>${escapeHtml(company.name)} · ${escapeHtml(company.phone)} · ${escapeHtml(company.email)}</span>
|
||||
<span>${escapeHtml(title)}</span>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Header,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import type { Response } from "express";
|
||||
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||
import { ReportsService } from "./reports.service";
|
||||
import {
|
||||
renderCsv,
|
||||
renderPdf,
|
||||
renderPrintHtml,
|
||||
renderXlsx,
|
||||
} from "./outputs";
|
||||
import { findReport } from "./reports.registry";
|
||||
|
||||
/**
|
||||
* Reports routes. Every report is dispatched by slug; outputs are
|
||||
* differentiated by `?format=...` (default `json`). Reads only — gated
|
||||
* by AuthenticatedGuard alone, like every other read in the app.
|
||||
*/
|
||||
@UseGuards(AuthenticatedGuard)
|
||||
@Controller("reports")
|
||||
export class ReportsController {
|
||||
constructor(private readonly reports: ReportsService) {}
|
||||
|
||||
/** Catalog of all registered reports (the /reportes index). */
|
||||
@Get()
|
||||
catalog() {
|
||||
return { items: this.reports.catalog() };
|
||||
}
|
||||
|
||||
/** Run a report and return the JSON result (rows + totals + the def's columns). */
|
||||
@Get(":slug")
|
||||
async runJson(
|
||||
@Param("slug") slug: string,
|
||||
@Query() query: Record<string, string | undefined>,
|
||||
) {
|
||||
const def = findReport(slug);
|
||||
const result = await this.reports.run(slug, query);
|
||||
return { ...result, columns: def?.columns ?? [] };
|
||||
}
|
||||
|
||||
/** CSV download. */
|
||||
@Get(":slug/csv")
|
||||
@Header("Content-Type", "text/csv; charset=utf-8")
|
||||
async runCsv(
|
||||
@Param("slug") slug: string,
|
||||
@Query() query: Record<string, string | undefined>,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const def = findReport(slug);
|
||||
const result = await this.reports.run(slug, query);
|
||||
const filename = `${def?.title ?? slug}-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${filename.replace(/[^\wÀ-ſ .-]/g, "_")}"`,
|
||||
);
|
||||
res.send(renderCsv(def?.columns ?? [], result));
|
||||
}
|
||||
|
||||
/** XLSX download. */
|
||||
@Get(":slug/xlsx")
|
||||
async runXlsx(
|
||||
@Param("slug") slug: string,
|
||||
@Query() query: Record<string, string | undefined>,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const def = findReport(slug);
|
||||
const result = await this.reports.run(slug, query);
|
||||
const filename = `${def?.title ?? slug}-${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
const buf = await renderXlsx(def?.columns ?? [], result);
|
||||
res.setHeader(
|
||||
"Content-Type",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${filename.replace(/[^\wÀ-ſ .-]/g, "_")}"`,
|
||||
);
|
||||
res.send(buf);
|
||||
}
|
||||
|
||||
/** PDF download. */
|
||||
@Get(":slug/pdf")
|
||||
async runPdf(
|
||||
@Param("slug") slug: string,
|
||||
@Query() query: Record<string, string | undefined>,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const def = findReport(slug);
|
||||
const result = await this.reports.run(slug, query);
|
||||
const buf = await renderPdf(def?.columns ?? [], result, def?.title ?? slug);
|
||||
const filename = `${def?.title ?? slug}-${new Date().toISOString().slice(0, 10)}.pdf`;
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${filename.replace(/[^\wÀ-ſ .-]/g, "_")}"`,
|
||||
);
|
||||
res.send(buf);
|
||||
}
|
||||
|
||||
/** Browser-printable HTML view (the user hits Print → Save as PDF). */
|
||||
@Get(":slug/print")
|
||||
@Header("Content-Type", "text/html; charset=utf-8")
|
||||
async runPrint(
|
||||
@Param("slug") slug: string,
|
||||
@Query() query: Record<string, string | undefined>,
|
||||
) {
|
||||
const def = findReport(slug);
|
||||
const result = await this.reports.run(slug, query);
|
||||
return renderPrintHtml(def?.columns ?? [], result, def?.title ?? slug);
|
||||
}
|
||||
|
||||
/** POST a customer-picker-driven report (statement). Mirrors GET to keep
|
||||
* the param contract simple: same body shape, same response. */
|
||||
@Post(":slug")
|
||||
async runPost(
|
||||
@Param("slug") slug: string,
|
||||
@Body() body: Record<string, string | undefined>,
|
||||
) {
|
||||
return this.reports.run(slug, body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ReportsController } from "./reports.controller";
|
||||
import { ReportsService } from "./reports.service";
|
||||
|
||||
@Module({
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService],
|
||||
})
|
||||
export class ReportsModule {}
|
||||
@@ -0,0 +1,763 @@
|
||||
/**
|
||||
* The catalog. One entry per report. Adding a new report is one new entry
|
||||
* here — no new route, no new page, no new component.
|
||||
*
|
||||
* Filter behavior to keep in mind:
|
||||
* - Currency balances are NEVER summed across currencies (912 customers
|
||||
* carry both MXN and USD; the legacy data has no FX per row, so any
|
||||
* cross-currency total would be invented). Every report that touches
|
||||
* the ledger accepts a `currency` filter and reports per currency.
|
||||
* - Voided transactions must be excluded from totals (NOT_VOIDED). The
|
||||
* UI still shows them struck-through; the SQL drops them.
|
||||
* - The legacy `REPORTE DE EFECTIVO` covered cash receipts only. In the
|
||||
* new schema those are `Transaction` rows with `legacySourceTable` in
|
||||
* the EFECTIVO* set OR `checkNumber` null + amount > 0 (true cash).
|
||||
*/
|
||||
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import {
|
||||
intParam,
|
||||
NOT_VOIDED,
|
||||
parseDate,
|
||||
type ReportDef,
|
||||
} from "./reports.types";
|
||||
|
||||
/* ------------------------------------------------------------------ helpers */
|
||||
|
||||
function nameOf(c: { name: string; nameMissing: boolean }): string {
|
||||
return c.nameMissing ? "(sin nombre)" : c.name;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ reports */
|
||||
|
||||
/**
|
||||
* LISTADO EN ROJO — overdue customers worklist.
|
||||
* Same data as the receivables worklist with balance=owing, but presented
|
||||
* as a printable report rather than a paginated browser.
|
||||
*/
|
||||
const listadoEnRojo: ReportDef = {
|
||||
slug: "listado-en-rojo",
|
||||
title: "Clientes en rojo",
|
||||
description:
|
||||
"Cartera vencida: clientes con saldo deudor en la moneda seleccionada, " +
|
||||
"ordenados del más antiguo al más reciente.",
|
||||
domain: "estado-cuenta",
|
||||
legacyName: "LISTADO EN ROJO",
|
||||
format: "tabular",
|
||||
params: [
|
||||
{
|
||||
key: "currency",
|
||||
label: "Moneda",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "MXN", label: "MXN" },
|
||||
{ value: "USD", label: "USD" },
|
||||
],
|
||||
defaultValue: "MXN",
|
||||
},
|
||||
{
|
||||
key: "query",
|
||||
label: "Buscar (nombre o ciudad)",
|
||||
kind: "text",
|
||||
placeholder: "Ej. Pérez, Tijuana…",
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: "id", label: "#", type: "text" },
|
||||
{ key: "name", label: "Cliente", type: "text" },
|
||||
{ key: "city", label: "Ciudad", type: "text" },
|
||||
{ key: "movements", label: "Movs.", type: "number", align: "right" },
|
||||
{ key: "balance", label: "Saldo", type: "money", align: "right" },
|
||||
{ key: "lastMovement", label: "Último movimiento", type: "date" },
|
||||
],
|
||||
async run(prisma, p) {
|
||||
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
|
||||
const q = p.query?.trim();
|
||||
const nameFilter = q
|
||||
? Prisma.sql`AND (c.name LIKE ${`%${q}%`} OR c.city LIKE ${`%${q}%`})`
|
||||
: Prisma.empty;
|
||||
|
||||
const bal =
|
||||
currency === "USD"
|
||||
? Prisma.sql`SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END)`
|
||||
: Prisma.sql`SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END)`;
|
||||
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
nameMissing: boolean;
|
||||
city: string | null;
|
||||
movements: bigint | number | string;
|
||||
balance: Prisma.Decimal | null;
|
||||
lastMovement: Date | null;
|
||||
}>
|
||||
>`
|
||||
SELECT c.id, c.name, c.nameMissing, c.city,
|
||||
COUNT(*) AS movements,
|
||||
${bal} AS balance,
|
||||
MAX(t.transactionDate) AS lastMovement
|
||||
FROM customers c
|
||||
JOIN transactions t ON t.customerId = c.id
|
||||
WHERE t.voidedAt IS NULL ${nameFilter}
|
||||
GROUP BY c.id, c.name, c.nameMissing, c.city
|
||||
HAVING ${bal} < -0.005
|
||||
ORDER BY MAX(t.transactionDate) ASC, c.nameMissing ASC, c.name ASC
|
||||
`;
|
||||
|
||||
let totalBalance = new Prisma.Decimal(0);
|
||||
let totalMovs = 0;
|
||||
const out = rows.map((r) => {
|
||||
const b = r.balance ?? new Prisma.Decimal(0);
|
||||
totalBalance = totalBalance.plus(b);
|
||||
totalMovs += Number(r.movements);
|
||||
return {
|
||||
id: r.id.slice(0, 8),
|
||||
name: nameOf(r),
|
||||
city: r.city ?? "—",
|
||||
movements: Number(r.movements),
|
||||
balance: b.toFixed(2),
|
||||
lastMovement: r.lastMovement
|
||||
? r.lastMovement.toISOString().slice(0, 10)
|
||||
: "—",
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows: out,
|
||||
totals: {
|
||||
customers: out.length,
|
||||
movements: totalMovs,
|
||||
balance: totalBalance.toFixed(2),
|
||||
currency,
|
||||
},
|
||||
subtitle: `Moneda: ${currency} · ${out.length} clientes con saldo deudor`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* PAGOS NO EFECTUADOS (AGUA / LUZ / TEL).
|
||||
* Customers enrolled in a service (by PropertyService.kind) with no
|
||||
* related ledger charge in the last N days. Heuristic: any non-voided
|
||||
* charge-type transaction in the window counts as "they paid". The
|
||||
* report groups by property so the same customer with two water meters
|
||||
* appears once per property.
|
||||
*/
|
||||
const pagosNoEfectuados: ReportDef = {
|
||||
slug: "pagos-no-efectuados",
|
||||
title: "Pagos no efectuados",
|
||||
description:
|
||||
"Clientes con un servicio contratado (agua, luz o teléfono) sin " +
|
||||
"movimientos de cargo en los últimos N días. Heurística basada en el " +
|
||||
"servicio registrado en la propiedad y la ausencia de cargos en el " +
|
||||
"periodo seleccionado.",
|
||||
domain: "servicios",
|
||||
legacyName: "PAGOS NO EFECTUADOS AGUA/LUZ/TEL",
|
||||
format: "tabular",
|
||||
params: [
|
||||
{
|
||||
key: "serviceKind",
|
||||
label: "Servicio",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "WATER", label: "Agua" },
|
||||
{ value: "ELECTRICITY", label: "Luz" },
|
||||
{ value: "PHONE", label: "Teléfono" },
|
||||
],
|
||||
defaultValue: "WATER",
|
||||
},
|
||||
{
|
||||
key: "days",
|
||||
label: "Días sin movimiento",
|
||||
kind: "number",
|
||||
defaultValue: "60",
|
||||
},
|
||||
{
|
||||
key: "currency",
|
||||
label: "Moneda",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "MXN", label: "MXN" },
|
||||
{ value: "USD", label: "USD" },
|
||||
],
|
||||
defaultValue: "MXN",
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: "customerId", label: "Cliente #", type: "text" },
|
||||
{ key: "customerName", label: "Cliente", type: "text" },
|
||||
{ key: "propertyAddress", label: "Dirección", type: "text" },
|
||||
{ key: "accountNumber", label: "Cuenta / Medidor", type: "text" },
|
||||
{ key: "lastCharge", label: "Último cargo", type: "date" },
|
||||
{ key: "balance", label: "Saldo", type: "money", align: "right" },
|
||||
],
|
||||
async run(prisma, p) {
|
||||
const kind = (p.serviceKind ?? "WATER") as
|
||||
| "WATER"
|
||||
| "ELECTRICITY"
|
||||
| "PHONE";
|
||||
const days = intParam(p, "days", 60, 1, 365);
|
||||
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
|
||||
const cutoff = new Date(Date.now() - days * 86400000);
|
||||
|
||||
// Customers enrolled in the service on a non-archived property, with no
|
||||
// charge-type transaction in the window. The subquery picks up
|
||||
// *anything* the customer paid (any domain, any type) — close enough
|
||||
// for the staff's "who's overdue" view; the precise per-service match
|
||||
// would need a per-service typeId taxonomy that doesn't exist in the
|
||||
// legacy data.
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
nameMissing: boolean;
|
||||
propertyId: string;
|
||||
propertyAddress: string | null;
|
||||
accountNumber: string | null;
|
||||
lastCharge: Date | null;
|
||||
balance: Prisma.Decimal | null;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
c.id AS customerId,
|
||||
c.name AS customerName,
|
||||
c.nameMissing AS nameMissing,
|
||||
pr.id AS propertyId,
|
||||
pr.addressLine1 AS propertyAddress,
|
||||
ps.accountNumber AS accountNumber,
|
||||
(SELECT MAX(t.transactionDate) FROM transactions t
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||
AND t.amount < 0
|
||||
AND t.transactionDate >= ${cutoff}) AS lastCharge,
|
||||
(SELECT SUM(t.amount) FROM transactions t
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||
AND t.currency = ${currency}) AS balance
|
||||
FROM property_services ps
|
||||
JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL
|
||||
JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL
|
||||
WHERE ps.kind = ${kind} AND ps.active = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM transactions t
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||
AND t.amount < 0 AND t.transactionDate >= ${cutoff}
|
||||
)
|
||||
ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC
|
||||
`;
|
||||
|
||||
let totalBalance = new Prisma.Decimal(0);
|
||||
const out = rows.map((r) => {
|
||||
const b = r.balance ?? new Prisma.Decimal(0);
|
||||
totalBalance = totalBalance.plus(b);
|
||||
return {
|
||||
customerId: r.customerId.slice(0, 8),
|
||||
customerName: nameOf({
|
||||
name: r.customerName,
|
||||
nameMissing: r.nameMissing,
|
||||
}),
|
||||
propertyAddress: r.propertyAddress ?? "—",
|
||||
accountNumber: r.accountNumber ?? "—",
|
||||
lastCharge: r.lastCharge
|
||||
? r.lastCharge.toISOString().slice(0, 10)
|
||||
: "—",
|
||||
balance: b.toFixed(2),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows: out,
|
||||
totals: {
|
||||
rows: out.length,
|
||||
balance: totalBalance.toFixed(2),
|
||||
currency,
|
||||
},
|
||||
subtitle: `Servicio: ${
|
||||
kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
|
||||
} · ${days} días · ${out.length} propiedades sin cargo reciente`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* FALTANTES DE (AGUA / LUZ / TEL).
|
||||
* Data-quality report: properties enrolled in a service that are missing
|
||||
* the key identifier the legacy system required (account/meter/route).
|
||||
* Different `faltante` per service kind in the legacy because the
|
||||
* service's identifier fields differ; here we flag any of the three
|
||||
* common identifiers being blank.
|
||||
*/
|
||||
const faltantes: ReportDef = {
|
||||
slug: "faltantes",
|
||||
title: "Faltantes de datos por servicio",
|
||||
description:
|
||||
"Calidad de datos: propiedades con un servicio contratado que no " +
|
||||
"tienen número de cuenta, medidor o ruta registrado. El reporte que " +
|
||||
"en la legacy corría como FALTANTES DE AGUA / LUZ / TEL.",
|
||||
domain: "servicios",
|
||||
legacyName: "FALTANTES DE AGUA/LUZ/TEL",
|
||||
format: "tabular",
|
||||
params: [
|
||||
{
|
||||
key: "serviceKind",
|
||||
label: "Servicio",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "WATER", label: "Agua" },
|
||||
{ value: "ELECTRICITY", label: "Luz" },
|
||||
{ value: "PHONE", label: "Teléfono" },
|
||||
],
|
||||
defaultValue: "WATER",
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: "customerId", label: "Cliente #", type: "text" },
|
||||
{ key: "customerName", label: "Cliente", type: "text" },
|
||||
{ key: "propertyAddress", label: "Dirección", type: "text" },
|
||||
{ key: "missing", label: "Faltante", type: "text" },
|
||||
{ key: "dueDay", label: "Día de vencimiento", type: "text" },
|
||||
],
|
||||
async run(prisma, p) {
|
||||
const kind = (p.serviceKind ?? "WATER") as
|
||||
| "WATER"
|
||||
| "ELECTRICITY"
|
||||
| "PHONE";
|
||||
|
||||
// A row per (property, missing field). The "missing" string describes
|
||||
// what's blank so the report is self-explanatory when printed.
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
nameMissing: boolean;
|
||||
propertyId: string;
|
||||
propertyAddress: string | null;
|
||||
dueDay: string | null;
|
||||
missing: string;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
c.id AS customerId,
|
||||
c.name AS customerName,
|
||||
c.nameMissing AS nameMissing,
|
||||
pr.id AS propertyId,
|
||||
pr.addressLine1 AS propertyAddress,
|
||||
ps.dueDay AS dueDay,
|
||||
CASE
|
||||
WHEN ps.accountNumber IS NULL OR ps.accountNumber = '' THEN 'Sin número de cuenta'
|
||||
WHEN ps.meterNumber IS NULL OR ps.meterNumber = '' THEN 'Sin número de medidor'
|
||||
WHEN ps.route IS NULL OR ps.route = '' THEN 'Sin ruta'
|
||||
ELSE ''
|
||||
END AS missing
|
||||
FROM property_services ps
|
||||
JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL
|
||||
JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL
|
||||
WHERE ps.kind = ${kind} AND ps.active = 1
|
||||
AND (
|
||||
ps.accountNumber IS NULL OR ps.accountNumber = ''
|
||||
OR ps.meterNumber IS NULL OR ps.meterNumber = ''
|
||||
OR ps.route IS NULL OR ps.route = ''
|
||||
)
|
||||
ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC
|
||||
`;
|
||||
|
||||
const out = rows.map((r) => ({
|
||||
customerId: r.customerId.slice(0, 8),
|
||||
customerName: nameOf({
|
||||
name: r.customerName,
|
||||
nameMissing: r.nameMissing,
|
||||
}),
|
||||
propertyAddress: r.propertyAddress ?? "—",
|
||||
missing: r.missing,
|
||||
dueDay: r.dueDay ?? "—",
|
||||
}));
|
||||
|
||||
return {
|
||||
rows: out,
|
||||
totals: { rows: out.length },
|
||||
subtitle: `Servicio: ${
|
||||
kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
|
||||
} · ${out.length} propiedades con datos faltantes`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* REPORTE DE EFECTIVO — cash reconciliation.
|
||||
* Credits in the EFECTIVO* legacy source tables OR with no cheque number
|
||||
* (true cash) within a date range. Excludes voided rows. Matches the
|
||||
* shape of the legacy REPORTE DE EFECTIVO report.
|
||||
*/
|
||||
const reporteDeEfectivo: ReportDef = {
|
||||
slug: "reporte-de-efectivo",
|
||||
title: "Reporte de efectivo",
|
||||
description:
|
||||
"Recibos de efectivo en el periodo seleccionado. Cubre los abonos " +
|
||||
"provenientes de las tablas legacy EFECTIVO* y los créditos sin " +
|
||||
"número de cheque (efectivo real). El match del reporte original.",
|
||||
domain: "chequera",
|
||||
legacyName: "REPORTE DE EFECTIVO",
|
||||
format: "tabular",
|
||||
params: [
|
||||
{ key: "from", label: "Desde", kind: "date" },
|
||||
{ key: "to", label: "Hasta", kind: "date", endOfDay: true },
|
||||
{
|
||||
key: "currency",
|
||||
label: "Moneda",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "MXN", label: "MXN" },
|
||||
{ value: "USD", label: "USD" },
|
||||
],
|
||||
defaultValue: "MXN",
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: "date", label: "Fecha", type: "date" },
|
||||
{ key: "customerName", label: "Cliente", type: "text" },
|
||||
{ key: "concept", label: "Concepto", type: "text" },
|
||||
{ key: "source", label: "Origen", type: "text" },
|
||||
{ key: "amount", label: "Monto", type: "money", align: "right" },
|
||||
],
|
||||
async run(prisma, p) {
|
||||
const from = parseDate(p.from);
|
||||
const to = parseDate(p.to, true);
|
||||
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
|
||||
|
||||
const ands: Prisma.TransactionWhereInput[] = [
|
||||
NOT_VOIDED,
|
||||
{ amount: { gt: 0 } },
|
||||
{ currency },
|
||||
{
|
||||
OR: [
|
||||
{ legacySourceTable: { in: ["EFECTIVO", "EFECTIVO_BACKUP"] } },
|
||||
{
|
||||
AND: [
|
||||
{ checkNumber: null },
|
||||
{ legacySourceTable: { not: "CHEQUE FM3" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
if (from || to) {
|
||||
ands.push({
|
||||
transactionDate: {
|
||||
...(from ? { gte: from } : {}),
|
||||
...(to ? { lte: to } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await prisma.transaction.findMany({
|
||||
where: { AND: ands },
|
||||
orderBy: { transactionDate: "asc" },
|
||||
select: {
|
||||
transactionDate: true,
|
||||
amount: true,
|
||||
reference: true,
|
||||
checkNumber: true,
|
||||
message: true,
|
||||
legacySourceTable: true,
|
||||
type: { select: { nameEs: true, nameEn: true } },
|
||||
customer: { select: { name: true, nameMissing: true } },
|
||||
},
|
||||
});
|
||||
|
||||
let total = new Prisma.Decimal(0);
|
||||
const out = rows.map((r) => {
|
||||
total = total.plus(r.amount);
|
||||
return {
|
||||
date: r.transactionDate.toISOString().slice(0, 10),
|
||||
customerName: nameOf(r.customer),
|
||||
concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—",
|
||||
source: r.legacySourceTable ?? "—",
|
||||
amount: r.amount.toFixed(2),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows: out,
|
||||
totals: {
|
||||
rows: out.length,
|
||||
total: total.toFixed(2),
|
||||
currency,
|
||||
},
|
||||
subtitle: `Efectivo · ${currency} · ${out.length} recibos${
|
||||
from ? ` desde ${p.from}` : ""
|
||||
}${to ? ` hasta ${p.to}` : ""}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* VIGENTE (LIC / INCEN / MULT / …) — policies up for renewal.
|
||||
* Wraps the policy listing with status=expiring and a policy-type filter,
|
||||
* sorted by soonest expiry. The legacy VIGENTE LIC / INCEN / MULT
|
||||
* reports are the same data; the new filter is a dropdown.
|
||||
*/
|
||||
const vigente: ReportDef = {
|
||||
slug: "vigente",
|
||||
title: "Pólizas por vencer",
|
||||
description:
|
||||
"Pólizas que vencen en los próximos N días, filtradas por ramo. " +
|
||||
"Equivalente a los reportes VIGENTE LIC / INCEN / MULT de la legacy.",
|
||||
domain: "polizas",
|
||||
legacyName: "VIGENTE LIC/INCEN/MULT",
|
||||
format: "tabular",
|
||||
params: [
|
||||
{
|
||||
key: "typeName",
|
||||
label: "Ramo",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "LICENCIAS", label: "Licencias" },
|
||||
{ value: "INCENDIO", label: "Incendio" },
|
||||
{ value: "MULT", label: "Multirriesgo" },
|
||||
{ value: "MCA2", label: "MCA2 (auto)" },
|
||||
{ value: "ME", label: "ME" },
|
||||
{ value: "MF", label: "MF" },
|
||||
{ value: "RC", label: "RC" },
|
||||
{ value: "INCEN", label: "Incen" },
|
||||
{ value: "TAMPL", label: "TAMPL" },
|
||||
{ value: "FAMILIAR", label: "Familiar" },
|
||||
],
|
||||
defaultValue: "LICENCIAS",
|
||||
},
|
||||
{
|
||||
key: "days",
|
||||
label: "Ventana (días)",
|
||||
kind: "number",
|
||||
defaultValue: "30",
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: "policyNumber", label: "Póliza", type: "text" },
|
||||
{ key: "customerName", label: "Cliente", type: "text" },
|
||||
{ key: "provider", label: "Aseguradora", type: "text" },
|
||||
{ key: "agent", label: "Agente", type: "text" },
|
||||
{ key: "from", label: "Desde", type: "date" },
|
||||
{ key: "to", label: "Vence", type: "date" },
|
||||
{ key: "daysToExpire", label: "Días", type: "number", align: "right" },
|
||||
{ key: "premium", label: "Prima neta", type: "money", align: "right" },
|
||||
],
|
||||
async run(prisma, p) {
|
||||
const typeName = p.typeName ?? "LICENCIAS";
|
||||
const days = intParam(p, "days", 30, 1, 365);
|
||||
const now = new Date();
|
||||
const today = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
|
||||
);
|
||||
const soon = new Date(today.getTime() + days * 86400000);
|
||||
|
||||
const rows = await prisma.policy.findMany({
|
||||
where: {
|
||||
policyType: { name: typeName },
|
||||
archivedAt: null,
|
||||
policyTo: { gte: today, lte: soon },
|
||||
},
|
||||
orderBy: { policyTo: "asc" },
|
||||
select: {
|
||||
policyNumber: true,
|
||||
policyFrom: true,
|
||||
policyTo: true,
|
||||
netPremium: true,
|
||||
agentName: true,
|
||||
customer: { select: { name: true, nameMissing: true } },
|
||||
insuranceProvider: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
let totalPremium = new Prisma.Decimal(0);
|
||||
const out = rows.map((r) => {
|
||||
const daysTo = r.policyTo
|
||||
? Math.round(
|
||||
(r.policyTo.getTime() - today.getTime()) / 86400000,
|
||||
)
|
||||
: 0;
|
||||
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
|
||||
return {
|
||||
policyNumber: r.policyNumber,
|
||||
customerName: nameOf(r.customer),
|
||||
provider: r.insuranceProvider?.name ?? "—",
|
||||
agent: r.agentName ?? "—",
|
||||
from: r.policyFrom ? r.policyFrom.toISOString().slice(0, 10) : "—",
|
||||
to: r.policyTo ? r.policyTo.toISOString().slice(0, 10) : "—",
|
||||
daysToExpire: daysTo,
|
||||
premium: r.netPremium ? r.netPremium.toFixed(2) : "0.00",
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows: out,
|
||||
totals: {
|
||||
rows: out.length,
|
||||
premium: totalPremium.toFixed(2),
|
||||
},
|
||||
subtitle: `Ramo: ${typeName} · ${days} días · ${out.length} pólizas por vencer`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* EDO CUENTA DATOS — per-customer account statement.
|
||||
* Wraps the existing BillingService.statement() output. The full layout
|
||||
* (header, balance per currency, by-domain split, by-type breakdown,
|
||||
* full movement list with running balance) is rendered by the statement
|
||||
* page; this report is the same data with print/PDF/CSV/XLSX outputs.
|
||||
*/
|
||||
const edoCuentaDatos: ReportDef = {
|
||||
slug: "edo-cuenta-datos",
|
||||
title: "Estado de cuenta",
|
||||
description:
|
||||
"Estado de cuenta de un cliente: saldos por moneda, desglose por " +
|
||||
"ramo y concepto, y el historial completo de movimientos con saldo " +
|
||||
"corrido. El reporte del cliente final.",
|
||||
domain: "estado-cuenta",
|
||||
legacyName: "EDO CUENTA DATOS",
|
||||
format: "statement",
|
||||
params: [
|
||||
{ key: "customerId", label: "Cliente", kind: "customer-picker" },
|
||||
],
|
||||
columns: [
|
||||
// Statement rows carry synthetic `__kind` discriminators instead of
|
||||
// column keys; the runner renders the special cases inline. These
|
||||
// columns drive CSV/XLSX when the user wants a flat movement export.
|
||||
{ key: "date", label: "Fecha", type: "date" },
|
||||
{ key: "concept", label: "Concepto", type: "text" },
|
||||
{ key: "reference", label: "Referencia", type: "text" },
|
||||
{ key: "amount", label: "Cargo / Abono", type: "money", align: "right" },
|
||||
{ key: "balanceAfter", label: "Saldo", type: "money", align: "right" },
|
||||
],
|
||||
async run(prisma, p) {
|
||||
const customerId = p.customerId;
|
||||
if (!customerId) {
|
||||
return { rows: [], subtitle: "Selecciona un cliente" };
|
||||
}
|
||||
const customer = await prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nameMissing: true,
|
||||
addressLine1: true,
|
||||
city: true,
|
||||
state: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
},
|
||||
});
|
||||
if (!customer) return { rows: [], subtitle: "Cliente no encontrado" };
|
||||
|
||||
// Reuse the same NOT_VOIDED + STATEMENT_EXCLUDED_SOURCE_TABLES filter
|
||||
// as BillingService.statement so the numbers match what the customer
|
||||
// already sees in /estado-cuenta/[id].
|
||||
const rows = await prisma.transaction.findMany({
|
||||
where: {
|
||||
customerId,
|
||||
voidedAt: null,
|
||||
legacySourceTable: {
|
||||
notIn: [
|
||||
"EFECTIVO",
|
||||
"EFECTIVO_BACKUP",
|
||||
"EFECTIVO FM3",
|
||||
"CHEQUE FM3",
|
||||
"IVA 2015",
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
transactionDate: true,
|
||||
domain: true,
|
||||
amount: true,
|
||||
currency: true,
|
||||
reference: true,
|
||||
period: true,
|
||||
checkNumber: true,
|
||||
message: true,
|
||||
legacySourceTable: true,
|
||||
type: { select: { nameEs: true, nameEn: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Compute running balance per currency, then return newest-first.
|
||||
const running = new Map<string, Prisma.Decimal>();
|
||||
const movements = rows.map((r) => {
|
||||
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
||||
const next = prev.plus(r.amount);
|
||||
running.set(r.currency, next);
|
||||
return {
|
||||
date: r.transactionDate.toISOString().slice(0, 10),
|
||||
domain: r.domain,
|
||||
currency: r.currency,
|
||||
reference: r.reference ?? "",
|
||||
period: r.period ?? "",
|
||||
checkNumber: r.checkNumber ?? "",
|
||||
concept: r.type?.nameEs ?? r.type?.nameEn ?? "—",
|
||||
amount: r.amount.toFixed(2),
|
||||
balanceAfter: next.toFixed(2),
|
||||
};
|
||||
});
|
||||
movements.reverse();
|
||||
|
||||
// Per-currency summary + per-domain breakdown.
|
||||
const perCurrency = new Map<
|
||||
string,
|
||||
{ currency: string; charges: Prisma.Decimal; credits: Prisma.Decimal; count: number }
|
||||
>();
|
||||
for (const r of rows) {
|
||||
const c =
|
||||
perCurrency.get(r.currency) ??
|
||||
{
|
||||
currency: r.currency,
|
||||
charges: new Prisma.Decimal(0),
|
||||
credits: new Prisma.Decimal(0),
|
||||
count: 0,
|
||||
};
|
||||
c.count += 1;
|
||||
if (r.amount.lessThan(0)) c.charges = c.charges.plus(r.amount);
|
||||
else c.credits = c.credits.plus(r.amount);
|
||||
perCurrency.set(r.currency, c);
|
||||
}
|
||||
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
__kind: "header",
|
||||
name: nameOf(customer),
|
||||
address: customer.addressLine1 ?? "",
|
||||
city: [customer.city, customer.state].filter(Boolean).join(", "),
|
||||
phone: customer.phone ?? "",
|
||||
email: customer.email ?? "",
|
||||
},
|
||||
...[...perCurrency.values()].map((c) => ({
|
||||
__kind: "summary",
|
||||
currency: c.currency,
|
||||
charges: c.charges.toFixed(2),
|
||||
credits: c.credits.toFixed(2),
|
||||
balance: c.charges.plus(c.credits).toFixed(2),
|
||||
count: c.count,
|
||||
})),
|
||||
{ __kind: "movements-header" },
|
||||
...movements,
|
||||
],
|
||||
subtitle: `${nameOf(customer)} · ${rows.length} movimientos`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ export */
|
||||
|
||||
export const REPORTS: ReportDef[] = [
|
||||
listadoEnRojo,
|
||||
pagosNoEfectuados,
|
||||
faltantes,
|
||||
reporteDeEfectivo,
|
||||
vigente,
|
||||
edoCuentaDatos,
|
||||
];
|
||||
|
||||
export function findReport(slug: string): ReportDef | undefined {
|
||||
return REPORTS.find((r) => r.slug === slug);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { findReport, REPORTS } from "./reports.registry";
|
||||
import type { ReportDef } from "./reports.types";
|
||||
|
||||
/**
|
||||
* The reports service. Two responsibilities:
|
||||
* 1. Run a report by slug with the given params — just dispatch.
|
||||
* 2. Return the catalog for the /reportes index page.
|
||||
*
|
||||
* Output rendering (CSV/XLSX/PDF/HTML print) lives in `outputs.ts`; this
|
||||
* service is data only. The controller maps URLs to (slug, format) and
|
||||
* hands the result to outputs.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** List every registered report, in display order. */
|
||||
catalog(): Array<{
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
domain: string;
|
||||
legacyName: string | null;
|
||||
format: string;
|
||||
params: ReportDef["params"];
|
||||
}> {
|
||||
return REPORTS.map((r) => ({
|
||||
slug: r.slug,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
domain: r.domain,
|
||||
legacyName: r.legacyName,
|
||||
format: r.format,
|
||||
params: r.params,
|
||||
}));
|
||||
}
|
||||
|
||||
async run(slug: string, params: Record<string, string | undefined>) {
|
||||
const def = findReport(slug);
|
||||
if (!def) throw new NotFoundException(`Reporte "${slug}" no encontrado`);
|
||||
return def.run(this.prisma, params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* The reports module — plan step 10.
|
||||
*
|
||||
* Each "report" is one entry in `reports.registry.ts`. The entry declares
|
||||
* its slug (URL id), title, what filters it accepts, what columns it
|
||||
* returns, and a `run` function that produces the data from Prisma. The
|
||||
* service dispatches on slug; the controller exposes JSON + CSV + XLSX +
|
||||
* PDF + HTML print; the catalog endpoint exposes the registry itself so
|
||||
* the `/reportes` page can render the same data.
|
||||
*
|
||||
* Output philosophy: a report returns a uniform shape — `columns` (typed
|
||||
* schema) + `rows` (any[] of values matching the column types) + `totals`
|
||||
* (record of column key → summary value). All three output formats
|
||||
* (CSV/XLSX/PDF/print) derive from this same shape so adding a new
|
||||
* report is one entry, never a per-format template.
|
||||
*/
|
||||
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
/** Top-level grouping for the catalog page; matches the existing nav. */
|
||||
export type ReportDomain =
|
||||
| "clientes"
|
||||
| "polizas"
|
||||
| "servicios"
|
||||
| "estado-cuenta"
|
||||
| "chequera";
|
||||
|
||||
/** How the runner should render rows: a grid, or a per-customer statement. */
|
||||
export type ReportFormat = "tabular" | "statement";
|
||||
|
||||
/** Filter controls the report's UI should render. */
|
||||
export type ParamDef =
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: "text" | "number";
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: "date";
|
||||
/** Inclusive bound, true for `to`, false for `from`. */
|
||||
endOfDay?: boolean;
|
||||
defaultValue?: string;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: "select";
|
||||
options: { value: string; label: string }[];
|
||||
defaultValue?: string;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: "customer-picker";
|
||||
};
|
||||
|
||||
/** One column of the output table. */
|
||||
export interface ColumnDef {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Render hint for the on-screen + print table. */
|
||||
type: "text" | "number" | "money" | "date";
|
||||
/** Right-align numbers/money; default false (left). */
|
||||
align?: "left" | "right";
|
||||
/** Used for column-width hints in the print/PDF layout. */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
/** Shape every report's `run` resolves to. Columns come from the def. */
|
||||
export interface ReportResult {
|
||||
rows: Array<Record<string, unknown>>;
|
||||
totals?: Record<string, string | number>;
|
||||
/** Optional free-form subtitle for print/PDF (e.g. date range, scope). */
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
/** A report's static declaration. */
|
||||
export interface ReportDef {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
domain: ReportDomain;
|
||||
/** The original Access report name (per docs/LEGACY_DATABASES_OBJECTS.md)
|
||||
* for traceability. Null when this is a new report with no legacy equiv. */
|
||||
legacyName: string | null;
|
||||
format: ReportFormat;
|
||||
params: ParamDef[];
|
||||
columns: ColumnDef[];
|
||||
/**
|
||||
* Run the report. Receives the Prisma client and the validated params
|
||||
* record (keys are the `key` from ParamDef, values are the strings the
|
||||
* runner collected; numeric/date params arrive as strings — the report
|
||||
* parses them). Must apply the same NOT_VOIDED filter on transactions as
|
||||
* the billing module so totals match.
|
||||
*/
|
||||
run: (
|
||||
prisma: PrismaService,
|
||||
params: Record<string, string | undefined>,
|
||||
) => Promise<ReportResult>;
|
||||
}
|
||||
|
||||
/** A typed bag of helpers for the report functions. */
|
||||
export interface ReportCtx {
|
||||
prisma: PrismaService;
|
||||
params: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
/** Helper: a `YYYY-MM-DD` bound; unparseable is undefined. */
|
||||
export function parseDate(
|
||||
v: string | undefined,
|
||||
endOfDay = false,
|
||||
): Date | undefined {
|
||||
if (!v) return undefined;
|
||||
const d = new Date(endOfDay ? `${v}T23:59:59.999Z` : `${v}T00:00:00.000Z`);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
/** Helper: integer param with default. */
|
||||
export function intParam(
|
||||
p: Record<string, string | undefined>,
|
||||
key: string,
|
||||
def: number,
|
||||
min = 1,
|
||||
max = 1000,
|
||||
): number {
|
||||
const n = Number(p[key]);
|
||||
if (!Number.isFinite(n)) return def;
|
||||
return Math.min(max, Math.max(min, Math.round(n)));
|
||||
}
|
||||
|
||||
/** Helper: not-voided filter, shared with billing.service. */
|
||||
export const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null };
|
||||
export const NOT_VOIDED_BANK: Prisma.BankTransactionWhereInput = {
|
||||
voidedAt: null,
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Generated
+938
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user