diff --git a/frontend/package.json b/frontend/package.json
index 090a9a4..2dd19a4 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -17,6 +17,7 @@
"react-router-dom": "^7.15.1",
"react-toastify": "^11.1.0",
"tw-animate-css": "^1.4.0",
+ "xlsx": "^0.18.5",
"zustand": "^5.0.13"
},
"devDependencies": {
@@ -25,6 +26,7 @@
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+ "@types/xlsx": "^0.0.36",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 03a0a39..c8bc538 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -29,6 +29,9 @@ importers:
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
+ xlsx:
+ specifier: ^0.18.5
+ version: 0.18.5
zustand:
specifier: ^5.0.13
version: 5.0.13(@types/react@19.2.15)(react@19.2.6)
@@ -48,6 +51,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.15)
+ '@types/xlsx':
+ specifier: ^0.0.36
+ version: 0.0.36
'@vitejs/plugin-react':
specifier: ^6.0.1
version: 6.0.2(vite@8.0.14(@types/node@24.12.4)(jiti@2.7.0))
@@ -533,6 +539,10 @@ packages:
'@types/react@19.2.15':
resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==}
+ '@types/xlsx@0.0.36':
+ resolution: {integrity: sha512-mvfrKiKKMErQzLMF8ElYEH21qxWCZtN59pHhWGmWCWFJStYdMWjkDSAy6mGowFxHXaXZWe5/TW7pBUiWclIVOw==}
+ deprecated: This is a stub types definition for xlsx (https://github.com/sheetjs/js-xlsx). xlsx provides its own type definitions, so you don't need @types/xlsx installed!
+
'@typescript-eslint/eslint-plugin@8.60.0':
resolution: {integrity: sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -615,6 +625,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ adler-32@1.3.1:
+ resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
+ engines: {node: '>=0.8'}
+
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
@@ -664,10 +678,18 @@ packages:
caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
+ cfb@1.2.2:
+ resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
+ engines: {node: '>=0.8'}
+
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
+ codepage@1.15.0:
+ resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
+ engines: {node: '>=0.8'}
+
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
@@ -688,6 +710,11 @@ packages:
typescript:
optional: true
+ crc-32@1.2.2:
+ resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
+ engines: {node: '>=0.8'}
+ hasBin: true
+
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -862,6 +889,10 @@ packages:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
+ frac@1.1.2:
+ resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
+ engines: {node: '>=0.8'}
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1244,6 +1275,10 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ ssf@0.11.2:
+ resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
+ engines: {node: '>=0.8'}
+
svg-parser@2.0.4:
resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==}
@@ -1351,10 +1386,23 @@ packages:
engines: {node: '>= 8'}
hasBin: true
+ wmf@1.0.2:
+ resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
+ engines: {node: '>=0.8'}
+
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
+ word@0.3.0:
+ resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
+ engines: {node: '>=0.8'}
+
+ xlsx@0.18.5:
+ resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
+ engines: {node: '>=0.8'}
+ hasBin: true
+
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -1803,6 +1851,10 @@ snapshots:
dependencies:
csstype: 3.2.3
+ '@types/xlsx@0.0.36':
+ dependencies:
+ xlsx: 0.18.5
+
'@typescript-eslint/eslint-plugin@8.60.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -1905,6 +1957,8 @@ snapshots:
acorn@8.16.0: {}
+ adler-32@1.3.1: {}
+
agent-base@6.0.2:
dependencies:
debug: 4.4.3
@@ -1959,8 +2013,15 @@ snapshots:
caniuse-lite@1.0.30001793: {}
+ cfb@1.2.2:
+ dependencies:
+ adler-32: 1.3.1
+ crc-32: 1.2.2
+
clsx@2.1.1: {}
+ codepage@1.15.0: {}
+
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
@@ -1978,6 +2039,8 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
+ crc-32@1.2.2: {}
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -2158,6 +2221,8 @@ snapshots:
hasown: 2.0.4
mime-types: 2.1.35
+ frac@1.1.2: {}
+
fsevents@2.3.3:
optional: true
@@ -2483,6 +2548,10 @@ snapshots:
source-map-js@1.2.1: {}
+ ssf@0.11.2:
+ dependencies:
+ frac: 1.1.2
+
svg-parser@2.0.4: {}
tailwindcss@4.3.0: {}
@@ -2558,8 +2627,22 @@ snapshots:
dependencies:
isexe: 2.0.0
+ wmf@1.0.2: {}
+
word-wrap@1.2.5: {}
+ word@0.3.0: {}
+
+ xlsx@0.18.5:
+ dependencies:
+ adler-32: 1.3.1
+ cfb: 1.2.2
+ codepage: 1.15.0
+ crc-32: 1.2.2
+ ssf: 0.11.2
+ wmf: 1.0.2
+ word: 0.3.0
+
yallist@3.1.1: {}
yocto-queue@0.1.0: {}
diff --git a/frontend/src/components/reportes/BalanceTable.tsx b/frontend/src/components/reportes/BalanceTable.tsx
new file mode 100644
index 0000000..ad9136e
--- /dev/null
+++ b/frontend/src/components/reportes/BalanceTable.tsx
@@ -0,0 +1,89 @@
+import type { BalanceRecord } from './typesReportes';
+import { fmtLps, fmtDate } from './utilsReportes';
+import { PAGE_SIZE, ROW_H, MIN_W, PaginationFooter } from './tableUtils';
+
+const COLS = '100px 120px 130px 1.2fr 2fr 130px';
+const HEADERS = ['Código', 'Fecha', 'Movimiento', 'Categoría', 'Descripción', 'Monto'];
+
+interface Props {
+ records: BalanceRecord[];
+ page: number;
+ onPage: (p: number) => void;
+}
+
+export function BalanceTable({ records, page, onPage }: Props) {
+ const totalPages = Math.max(1, Math.ceil(records.length / PAGE_SIZE));
+ const safe = Math.min(page - 1, totalPages - 1);
+ const pageItems = records.slice(safe * PAGE_SIZE, (safe + 1) * PAGE_SIZE);
+ const slots = Array.from({ length: PAGE_SIZE }).map((_, i) => pageItems[i] ?? null);
+
+ return (
+
+
+
+ {/* Encabezados */}
+
+ {HEADERS.map((h, i) => (
+
+
+ {h}
+
+
+ ))}
+
+
+ {/* Filas */}
+
+ {slots.map((r, i) => {
+ const isIngreso = r?.tipo === 'Ingreso';
+ return (
+
+ {r ? (
+ <>
+ {/* Código */}
+
{r.codigo}
+
+ {/* Fecha */}
+
{fmtDate(r.fecha)}
+
+ {/* Movimiento — solo color, sin ícono */}
+
+
+ {r.tipo}
+
+
+
+ {/* Categoría */}
+
{r.categoria}
+
+ {/* Descripción */}
+
{r.descripcion}
+
+ {/* Monto */}
+
+ {isIngreso ? '+' : '-'} {fmtLps(r.monto)}
+
+ >
+ ) : (
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/reportes/MorososTable.tsx b/frontend/src/components/reportes/MorososTable.tsx
new file mode 100644
index 0000000..8cbf273
--- /dev/null
+++ b/frontend/src/components/reportes/MorososTable.tsx
@@ -0,0 +1,86 @@
+import type { MorosoRecord } from './typesReportes';
+import { fmtLps } from './utilsReportes';
+import { PAGE_SIZE, ROW_H, MIN_W, PaginationFooter } from './tableUtils';
+
+const COLS = '1.4fr 100px 1fr 110px 1.4fr 120px';
+const HEADERS = ['Residente', 'DNI', 'Ubicación', 'Atraso', 'Detalle de Deuda', 'Monto Total'];
+
+interface Props {
+ records: MorosoRecord[];
+ page: number;
+ onPage: (p: number) => void;
+}
+
+export function MorososTable({ records, page, onPage }: Props) {
+ const totalPages = Math.max(1, Math.ceil(records.length / PAGE_SIZE));
+ const safe = Math.min(page - 1, totalPages - 1);
+ const pageItems = records.slice(safe * PAGE_SIZE, (safe + 1) * PAGE_SIZE);
+ const slots = Array.from({ length: PAGE_SIZE }).map((_, i) => pageItems[i] ?? null);
+
+ return (
+
+
+
+ {/* Encabezados */}
+
+ {HEADERS.map((h, i) => (
+
+
+ {h}
+
+
+ ))}
+
+
+ {/* Filas */}
+
+ {slots.map((r, i) => (
+
+ {r ? (
+ <>
+ {/* Residente */}
+
{r.residente}
+
+ {/* DNI */}
+
{r.dni}
+
+ {/* Ubicación */}
+
+ Blq. {r.ubicacion.bloque}, Lte. {r.ubicacion.lote}
+
+
+ {/* Atraso */}
+
+ = 3 ? '#FEE2E2' : '#FEF3C7',
+ color: r.mesesAtraso >= 3 ? '#c0392b' : '#b7791f',
+ }}
+ >
+ {r.mesesAtraso} {r.mesesAtraso === 1 ? 'mes' : 'meses'}
+
+
+
+ {/* Detalle */}
+
{r.detalleDeuda}
+
+ {/* Monto */}
+
{fmtLps(r.montoTotal)}
+ >
+ ) : (
+
+ )}
+
+ ))}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/reportes/ReportesFilters.tsx b/frontend/src/components/reportes/ReportesFilters.tsx
new file mode 100644
index 0000000..29ebc3f
--- /dev/null
+++ b/frontend/src/components/reportes/ReportesFilters.tsx
@@ -0,0 +1,139 @@
+import { useRef } from 'react';
+import { Calendar, X } from 'lucide-react';
+import type { ReportesFilterValues } from './useReportes';
+import { DEFAULT_REPORTES_FILTERS } from './useReportes';
+import type { ReporteTab, TipoBalance } from './typesReportes';
+import { fmtDate } from './utilsReportes';
+
+interface Props {
+ filters: ReportesFilterValues;
+ onChange: (f: ReportesFilterValues) => void;
+ activeTab: ReporteTab;
+}
+
+export function ReportesFilters({ filters, onChange, activeTab }: Props) {
+ const set = (key: keyof ReportesFilterValues, value: ReportesFilterValues[typeof key]) =>
+ onChange({ ...filters, [key]: value });
+
+ const toggleTipo = (tipo: TipoBalance) => {
+ const next = filters.tipos.includes(tipo)
+ ? filters.tipos.filter(t => t !== tipo)
+ : [...filters.tipos, tipo];
+ set('tipos', next);
+ };
+
+ const hasAny = filters.search || filters.dateFrom || filters.dateTo || filters.tipos.length > 0;
+
+ return (
+
+
+
+ Filtros
+ {hasAny && (
+
+ )}
+
+
+
+
+
+ {activeTab === 'Balance' && (
+ <>
+
+ set('dateFrom', v)} />
+ set('dateTo', v)} />
+
+
+
+ {(['Ingreso', 'Egreso'] as TipoBalance[]).map(tipo => {
+ const checked = filters.tipos.includes(tipo);
+ const color = tipo === 'Ingreso' ? '#308C58' : '#c0392b';
+ return (
+
+ );
+ })}
+
+ >
+ )}
+
+ );
+}
+
+function FilterGroup({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+function DateInput({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
+ const inputRef = useRef(null);
+ const openPicker = () => { try { inputRef.current?.showPicker(); } catch { /* unsupported */ } };
+
+ return (
+
+
+ {label}
+
+
+
onChange(e.target.value)}
+ onClick={openPicker}
+ style={{ position: 'absolute', inset: 0, opacity: 0, cursor: 'pointer', zIndex: 10 }}
+ />
+
+
+
+ {value ? fmtDate(value) : 'Seleccionar...'}
+
+
+ {value && (
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/reportes/ReportesTabs.tsx b/frontend/src/components/reportes/ReportesTabs.tsx
new file mode 100644
index 0000000..b493789
--- /dev/null
+++ b/frontend/src/components/reportes/ReportesTabs.tsx
@@ -0,0 +1,42 @@
+import type { ReporteTab } from './typesReportes';
+
+const TABS: { id: ReporteTab; label: string; description: string }[] = [
+ { id: 'Morosos', label: 'Control de Morosidad', description: 'Listado de residentes con deudas pendientes.' },
+ { id: 'Balance', label: 'Balance General', description: 'Libro mayor de ingresos y egresos aprobados.' },
+];
+
+interface Props {
+ active: ReporteTab;
+ onSelect: (tab: ReporteTab) => void;
+}
+
+export function ReportesTabs({ active, onSelect }: Props) {
+ const current = TABS.find(t => t.id === active)!;
+
+ return (
+
+
+ {TABS.map(t => {
+ const isActive = t.id === active;
+ return (
+
+ );
+ })}
+
+
{current.description}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/reportes/exportReportes.ts b/frontend/src/components/reportes/exportReportes.ts
new file mode 100644
index 0000000..c9e925b
--- /dev/null
+++ b/frontend/src/components/reportes/exportReportes.ts
@@ -0,0 +1,55 @@
+import * as XLSX from 'xlsx';
+import type { MorosoRecord, BalanceRecord } from './typesReportes';
+
+function download(wb: XLSX.WorkBook, fileName: string) {
+ XLSX.writeFile(wb, fileName);
+}
+
+export function exportMorosos(records: MorosoRecord[]) {
+ const rows = records.map(r => ({
+ 'Residente': r.residente,
+ 'DNI': r.dni,
+ 'Bloque': r.ubicacion.bloque,
+ 'Lote': r.ubicacion.lote,
+ 'Calle': r.ubicacion.calle,
+ 'Meses de Atraso': r.mesesAtraso,
+ 'Detalle de Deuda': r.detalleDeuda,
+ 'Monto Total (L.)': r.montoTotal,
+ }));
+
+ const ws = XLSX.utils.json_to_sheet(rows);
+ const wb = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(wb, ws, 'Morosidad');
+
+ // Anchos de columna aproximados
+ ws['!cols'] = [
+ { wch: 28 }, { wch: 18 }, { wch: 8 }, { wch: 8 },
+ { wch: 16 }, { wch: 16 }, { wch: 28 }, { wch: 16 },
+ ];
+
+ const date = new Date().toISOString().split('T')[0];
+ download(wb, `Reporte_Morosidad_${date}.xlsx`);
+}
+
+export function exportBalance(records: BalanceRecord[]) {
+ const rows = records.map(r => ({
+ 'Código': r.codigo,
+ 'Fecha': r.fecha,
+ 'Tipo': r.tipo,
+ 'Categoría': r.categoria,
+ 'Descripción': r.descripcion,
+ 'Monto (L.)': r.monto,
+ }));
+
+ const ws = XLSX.utils.json_to_sheet(rows);
+ const wb = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(wb, ws, 'Balance General');
+
+ ws['!cols'] = [
+ { wch: 12 }, { wch: 12 }, { wch: 10 },
+ { wch: 18 }, { wch: 36 }, { wch: 14 },
+ ];
+
+ const date = new Date().toISOString().split('T')[0];
+ download(wb, `Reporte_Balance_${date}.xlsx`);
+}
\ No newline at end of file
diff --git a/frontend/src/components/reportes/mockReportes.ts b/frontend/src/components/reportes/mockReportes.ts
new file mode 100644
index 0000000..54ea24f
--- /dev/null
+++ b/frontend/src/components/reportes/mockReportes.ts
@@ -0,0 +1,13 @@
+import type { MorosoRecord, BalanceRecord } from './typesReportes';
+
+export const MOCK_MOROSOS: MorosoRecord[] = [
+ { id: '1', residente: 'Carlos Fuentes Mejía', dni: '0501-1980-12345', ubicacion: { bloque: 'B', lote: '14', calle: 'Principal' }, mesesAtraso: 3, detalleDeuda: '3 Mensualidades', montoTotal: 450.00 },
+ { id: '2', residente: 'Ana Lidia Reyes', dni: '0501-1992-54321', ubicacion: { bloque: 'A', lote: '05', calle: 'Los Pinos' }, mesesAtraso: 1, detalleDeuda: '1 Mensualidad, 1 Multa', montoTotal: 450.00 },
+ { id: '3', residente: 'Jorge Alberto Santos', dni: '0511-1975-98765', ubicacion: { bloque: 'C', lote: '22', calle: 'Las Acacias' }, mesesAtraso: 5, detalleDeuda: '5 Mensualidades', montoTotal: 750.00 },
+ { id: '4', residente: 'María Fernanda Cruz', dni: '0501-1988-11223', ubicacion: { bloque: 'B', lote: '02', calle: 'Principal' }, mesesAtraso: 2, detalleDeuda: '2 Mensualidades', montoTotal: 300.00 },
+];
+
+export const MOCK_BALANCE: BalanceRecord[] = [
+ { id: '1', codigo: 'ING-001', fecha: '2026-06-15', tipo: 'Ingreso', categoria: 'Mensualidad', descripcion: 'Pago Mensualidad - Bloque A Lote 5', monto: 150.00 },
+ { id: '2', codigo: 'EGR-001', fecha: '2026-06-19', tipo: 'Egreso', categoria: 'Mantenimiento', descripcion: 'Compra de Tubos PVC', monto: 850.00 },
+];
\ No newline at end of file
diff --git a/frontend/src/components/reportes/tableUtils.tsx b/frontend/src/components/reportes/tableUtils.tsx
new file mode 100644
index 0000000..a08054d
--- /dev/null
+++ b/frontend/src/components/reportes/tableUtils.tsx
@@ -0,0 +1,65 @@
+import React from 'react';
+
+export const PAGE_SIZE = 9;
+export const ROW_H = 55;
+export const MIN_W = 900;
+
+interface PageBtnProps {
+ onClick: () => void;
+ disabled: boolean;
+ active?: boolean;
+ children: React.ReactNode;
+}
+
+export function PageBtn({ onClick, disabled, active, children }: PageBtnProps) {
+ return (
+
+ );
+}
+
+interface PaginationFooterProps {
+ page: number; // 1-based current page
+ total: number; // total record count
+ onPage: (p: number) => void;
+}
+
+export function PaginationFooter({ page, total, onPage }: PaginationFooterProps) {
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
+ const safe = Math.min(page - 1, totalPages - 1); // 0-based
+
+ return (
+
+
+ {total === 0
+ ? 'Sin resultados'
+ : `${safe * PAGE_SIZE + 1}–${Math.min((safe + 1) * PAGE_SIZE, total)} de ${total} registros`}
+
+
+
onPage(1)} disabled={safe === 0}>«
+
onPage(safe)} disabled={safe === 0}>‹
+ {Array.from({ length: totalPages }).map((_, i) => (
+
onPage(i + 1)} disabled={false} active={i === safe}>
+ {i + 1}
+
+ ))}
+
onPage(safe + 2)} disabled={safe === totalPages - 1}>›
+
onPage(totalPages)} disabled={safe === totalPages - 1}>»
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/reportes/typesReportes.ts b/frontend/src/components/reportes/typesReportes.ts
new file mode 100644
index 0000000..3d08efb
--- /dev/null
+++ b/frontend/src/components/reportes/typesReportes.ts
@@ -0,0 +1,22 @@
+export type ReporteTab = 'Morosos' | 'Balance';
+export type TipoBalance = 'Ingreso' | 'Egreso';
+
+export interface MorosoRecord {
+ id: string;
+ residente: string;
+ dni: string;
+ ubicacion: { bloque: string; lote: string; calle: string };
+ mesesAtraso: number;
+ detalleDeuda: string;
+ montoTotal: number;
+}
+
+export interface BalanceRecord {
+ id: string;
+ codigo: string;
+ fecha: string;
+ tipo: TipoBalance;
+ categoria: string;
+ descripcion: string;
+ monto: number;
+}
\ No newline at end of file
diff --git a/frontend/src/components/reportes/useReportes.ts b/frontend/src/components/reportes/useReportes.ts
new file mode 100644
index 0000000..fdea5b9
--- /dev/null
+++ b/frontend/src/components/reportes/useReportes.ts
@@ -0,0 +1,78 @@
+import { useState, useMemo } from 'react';
+import type { ReporteTab, TipoBalance } from './typesReportes';
+import { MOCK_MOROSOS, MOCK_BALANCE } from './mockReportes';
+import { exportMorosos, exportBalance } from './exportReportes';
+
+export interface ReportesFilterValues {
+ search: string;
+ dateFrom: string;
+ dateTo: string;
+ tipos: TipoBalance[];
+}
+
+export const DEFAULT_REPORTES_FILTERS: ReportesFilterValues = {
+ search: '',
+ dateFrom: '',
+ dateTo: '',
+ tipos: [],
+};
+
+export function useReportes() {
+ const [activeTab, setActiveTab] = useState('Morosos');
+ const [page, setPage] = useState(1);
+ const [filters, setFilters] = useState(DEFAULT_REPORTES_FILTERS);
+
+ const filteredMorosos = useMemo(() => {
+ const q = filters.search.toLowerCase().trim();
+ const list = q
+ ? MOCK_MOROSOS.filter(r =>
+ r.residente.toLowerCase().includes(q) ||
+ r.dni.toLowerCase().includes(q) ||
+ r.ubicacion.bloque.toLowerCase().includes(q)
+ )
+ : [...MOCK_MOROSOS];
+ return list.sort((a, b) => a.mesesAtraso - b.mesesAtraso);
+ }, [filters.search]);
+
+ const filteredBalance = useMemo(() => {
+ return MOCK_BALANCE.filter(r => {
+ const q = filters.search.toLowerCase().trim();
+ if (q && !r.descripcion.toLowerCase().includes(q) && !r.categoria.toLowerCase().includes(q)) return false;
+ if (filters.dateFrom && r.fecha < filters.dateFrom) return false;
+ if (filters.dateTo && r.fecha > filters.dateTo) return false;
+ if (filters.tipos.length > 0 && !filters.tipos.includes(r.tipo)) return false;
+ return true;
+ });
+ }, [filters]);
+
+ const kpisMorosos = useMemo(() => ({
+ totalMora: filteredMorosos.reduce((acc, r) => acc + r.montoTotal, 0),
+ deudores: filteredMorosos.length,
+ }), [filteredMorosos]);
+
+ const handleTabChange = (tab: ReporteTab) => {
+ setActiveTab(tab);
+ setPage(1);
+ };
+
+ const handleExportExcel = () => {
+ if (activeTab === 'Morosos') {
+ exportMorosos(filteredMorosos);
+ } else {
+ exportBalance(filteredBalance);
+ }
+ };
+
+ return {
+ activeTab,
+ page,
+ filters,
+ filteredMorosos,
+ filteredBalance,
+ kpisMorosos,
+ setPage,
+ setFilters,
+ handleTabChange,
+ handleExportExcel,
+ };
+}
\ No newline at end of file
diff --git a/frontend/src/components/reportes/utilsReportes.ts b/frontend/src/components/reportes/utilsReportes.ts
new file mode 100644
index 0000000..96db973
--- /dev/null
+++ b/frontend/src/components/reportes/utilsReportes.ts
@@ -0,0 +1,9 @@
+export const fmtLps = (n: number) =>
+ `L. ${n.toLocaleString('es-HN', { minimumFractionDigits: 2 })}`;
+
+export const fmtDate = (iso: string) => {
+ if (!iso) return '';
+ const [y, m, d] = iso.split('-');
+ const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
+ return `${d} ${meses[parseInt(m) - 1]} ${y}`;
+};
\ No newline at end of file
diff --git a/frontend/src/features/reportes/ReportesFinancieros.tsx b/frontend/src/features/reportes/ReportesFinancieros.tsx
new file mode 100644
index 0000000..fd78ea9
--- /dev/null
+++ b/frontend/src/features/reportes/ReportesFinancieros.tsx
@@ -0,0 +1,86 @@
+import { Download } from 'lucide-react';
+import { useReportes } from '../../components/reportes/useReportes';
+import { ReportesTabs } from '../../components/reportes/ReportesTabs';
+import { ReportesFilters } from '../../components/reportes/ReportesFilters';
+import { MorososTable } from '../../components/reportes/MorososTable';
+import { BalanceTable } from '../../components/reportes/BalanceTable';
+import { fmtLps } from '../../components/reportes/utilsReportes';
+
+export default function ReportesFinancieros() {
+ const {
+ activeTab,
+ page,
+ filters,
+ filteredMorosos,
+ filteredBalance,
+ kpisMorosos,
+ setPage,
+ setFilters,
+ handleTabChange,
+ handleExportExcel,
+ } = useReportes();
+
+ return (
+
+
+
+
+
+
+ {/* Izquierda: Tabla + KPIs */}
+
+
+ {activeTab === 'Morosos' && (
+
+
+
+ )}
+
+ {activeTab === 'Balance' && (
+
+
+
+ )}
+
+
+ {/* Derecha: Exportar + Filtros sticky */}
+
+
+
+ {activeTab === 'Morosos' && (
+
+
+
+ Cantidad Deudores Identificados
+
+
+ {kpisMorosos.deudores} usuarios
+
+
+
+
+ Total en Mora Estimado
+
+
+ {fmtLps(kpisMorosos.totalMora)}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/router/AppRouter.tsx b/frontend/src/router/AppRouter.tsx
index be3a9db..915907a 100644
--- a/frontend/src/router/AppRouter.tsx
+++ b/frontend/src/router/AppRouter.tsx
@@ -12,6 +12,7 @@ import { ConfiguracionCobros } from "../features/configuracion/ConfiguracionCobr
import GestionarUsuarios from "../features/gestionUsuarios/GestionarUsuarios";
import Multas from "../features/multas/HistorialMultas";
import Dashboard from "../features/dashbaord/Dashboard";
+import ReportesFinancieros from "../features/reportes/ReportesFinancieros";
export const AppRouter = () => (
@@ -60,7 +61,7 @@ export const AppRouter = () => (
} />
{/* Reportes: Solo el financiero activo actualmente */}
- >} />
+ } />
{/* Comentados temporalmente por desarrollo */}
{/* >} /> */}
{/* >} /> */}