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