Compare commits

..

57 Commits

Author SHA1 Message Date
Exteban08
da976b9003 Update all documentation for 3-level roles, organismos, and Histórico
Reflect current project state across all 8 docs: ADMIN/ORGANISMO_OPERADOR/OPERATOR
role hierarchy, scope filtering, organismos_operadores table, Histórico de Tomas
page, new SQL migrations, and updated API endpoints with auth requirements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 10:44:16 +00:00
Exteban08
613fb2d787 Add 3-level role permissions, organismos operadores, and Histórico de Tomas page
Implements the full ADMIN → ORGANISMO_OPERADOR → OPERATOR permission hierarchy
with scope-filtered data access across all backend services. Adds organismos
operadores management (ADMIN only) and a new Histórico page for viewing
per-meter reading history with chart, consumption stats, and CSV export.

Key changes:
- Backend: 3-level scope filtering on all services (meters, readings, projects, users)
- Backend: Protect GET /meters routes with authenticateToken for role-based filtering
- Backend: Pass requestingUser to reading service for scoped meter readings
- Frontend: New HistoricoPage with meter selector, AreaChart, paginated table
- Frontend: Consumption cards (Actual, Pasado, Diferencial) above date filters
- Frontend: Meter search by name, serial, location, CESPT account, cadastral key
- Frontend: OrganismosPage, updated Sidebar with 3-level visibility
- SQL migrations for organismos_operadores table and FK columns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 10:21:33 +00:00
Exteban08
61dafa83ac Update all project documentation to reflect current state
Rewrite README.md, DOCUMENTATION.md, ESTADO_ACTUAL.md and CAMBIOS_SESION.md
to accurately document the full-stack architecture, all modules, API endpoints,
JWT auth, database schema, and features added in February 2026.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 07:48:54 +00:00
Exteban08
e1d4db96fe Change connector sync time from 2:00 AM to 9:00 AM
Updated SHMetersPage and XMetersPage to reflect new daily
sync schedule at 9:00 AM instead of 2:00 AM.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 22:16:11 +00:00
Exteban08
14e7f8d743 Update favicon and connector pages last connection time
- Replace Vite favicon with GRH logo (white background)
- Update SHMetersPage and XMetersPage to show dynamic last connection time
  - Today (Feb 4, 2026): 2:32 PM
  - After today: 2:00 AM

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:59:25 +00:00
Exteban08
a79dcc82ea Add implementation plan for ORGANISMOS_OPERADORES role
New role that sits between ADMIN and OPERATOR, allowing users
to be assigned multiple projects instead of just one.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:38:48 +00:00
Exteban08
9f1ab4115e Add Analytics section and improve Connectors pages
- Add Analytics pages: Map (Leaflet), Reports, and Server metrics
- Add Analytics section to sidebar (Admin only)
- Improve SHMetersPage and XMetersPage with real API data
- Add analytics API service for connector stats and server metrics
- Register system routes in backend

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:07:00 +00:00
Exteban08
6487e9105e Fix connector start dates for SH-Meters and XMeters
Updated hardcoded dates from 2025 to 2026:
- SH-Meters: 2026-01-12
- XMeters: 2026-01-25

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:06:19 +00:00
Exteban08
27494e7868 Fix dark mode for ConsumptionPage cards and AuditoriaPage table
- ConsumptionPage: Add dark mode to StatCard, filters panel, pagination,
  TypeBadge, BatteryIndicator, and SignalIndicator components
- AuditoriaPage: Add dark mode to table tbody, details modal, action
  badges, success/failure badges, and pagination elements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 12:07:01 +00:00
Exteban08
3087af11e1 Add dark mode styling to modals and form elements
- Add global CSS overrides for input, select, textarea in dark mode
- Update MetersModal with dark mode classes
- Update ConcentratorsModal with dark mode classes
- Update ProjectsPage modal with dark mode classes
- Update RolesPage modal with dark mode classes
- Update ConfirmModal with dark mode styling
- Update ProfileModal with dark mode styling
- All form labels, inputs, selects, and buttons now properly styled

Dark mode elements:
- Modal backgrounds: zinc-900 with zinc-700 border
- Inputs/selects: zinc-800 background, zinc-700 border
- Labels: zinc-400 text color
- Cancel buttons: zinc-800 hover background

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 12:01:08 +00:00
Exteban08
0142ba740f Add dark mode support for tables and data pages
- Add CSS overrides for MaterialTable in dark mode
- Update page containers with dark:bg-zinc-950
- Update sidebars with dark mode (MetersSidebar, ConcentratorsSidebar)
- Update tables in AuditoriaPage, UsersPage, RolesPage
- Update ConsumptionPage with dark gradient background
- Update search inputs, select elements, and modals
- Add dark borders for card separation

Affected pages:
- MeterPage, ConcentratorsPage, ProjectsPage
- UsersPage, RolesPage, AuditoriaPage
- ConsumptionPage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 11:58:10 +00:00
Exteban08
c741b697d9 Improve dark mode with Zinc color palette
- Change from gray to zinc colors for a neutral cool aesthetic
- Use zinc-950 for main background, zinc-900 for cards
- Add subtle borders to cards in dark mode for better separation
- Update all components: Home, TopMenu, connector pages
- More elegant and minimalist dark mode appearance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 11:51:03 +00:00
Exteban08
9b8d6c4e45 Add dark/light theme toggle and Conectores section
- Add theme toggle button in TopMenu with Sun/Moon icons
- Save theme preference to localStorage
- Add dark mode CSS configuration with Tailwind @custom-variant
- Apply dark mode classes to Home.tsx, TopMenu, and connector pages
- Add new Conectores section in sidebar with Cable icon
- Create placeholder pages for SH-METERS, XMETERS, and TTS connectors
- Update App.tsx page types and routing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 11:47:42 +00:00
Exteban08
71623d667f Add complete project documentation
Documentation includes:
- README.md: Project overview and architecture
- API.md: Complete API reference with endpoints
- MANUAL_USUARIO.md: User manual in Spanish
- INSTALACION.md: Installation and deployment guide
- ARQUITECTURA.md: Architecture and database schema
- UPLOAD_PANEL.md: CSV upload panel documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 11:27:51 +00:00
Exteban08
6118ec2813 Add LORA, LORAWAN, GRANDES CONSUMIDORES meter types
- Set LORA as default meter type
- Add LORAWAN and GRANDES CONSUMIDORES as valid types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 11:19:01 +00:00
Exteban08
71db4219ca Add CSV upload panel for meters and readings
- Add CSV upload service with upsert logic for meters
- Add CSV upload routes (POST /csv-upload/meters, POST /csv-upload/readings)
- Add template download endpoints for CSV format
- Create standalone upload-panel React app on port 5174
- Support concentrator_serial lookup for meter creation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 11:08:30 +00:00
7301be7544 Meter fix 2026-02-03 03:02:19 -06:00
ddf98af78a Fix 2026-02-03 02:58:02 -06:00
34e6ef14df Project id fix for meter edit 2026-02-03 02:52:43 -06:00
8e1eefd400 meter route patch method 2026-02-03 02:47:33 -06:00
e99d9c1a7d Relation meter with project 2026-02-03 02:43:55 -06:00
4b977f3eae route patch fix 2026-02-03 02:36:11 -06:00
dda69a59ba Concentrator edit fix 2026-02-03 02:33:21 -06:00
e6df3acad5 middleware fix 2026-02-03 02:26:22 -06:00
0f8f73ea2a Delete project logic 2026-02-03 02:17:14 -06:00
5f51d25bc1 Fix project service 2026-02-03 02:05:10 -06:00
35c44ed13c fix 2026-02-03 01:56:44 -06:00
040f3f97dd Project deleting 2026-02-03 01:53:49 -06:00
5529739749 project service 2026-02-03 01:39:48 -06:00
31ab977f97 Rows per page standarized 2026-02-03 01:26:59 -06:00
d1770b550a Users filter in dashboard as organismos operadores 2026-02-03 01:06:40 -06:00
23c3a19209 Operator project fix 2026-02-03 00:39:08 -06:00
6124bedb8a Operator permissions 2026-02-03 00:28:58 -06:00
5a062ce3a1 Project id for user 2026-02-02 23:55:41 -06:00
9ab1beeef7 Audito dashboard and OPERATOR permissions 2026-02-02 23:23:45 -06:00
b273003366 Meter tyoe logic for projects in concentrators page 2026-02-02 19:01:02 -06:00
f921b20e15 meter type id 2026-02-02 18:00:46 -06:00
6cc4ee0901 meter types 2026-02-02 17:54:01 -06:00
6c5323906d Tipos de toma backend logic 2026-02-02 17:37:10 -06:00
e06941fd02 prueba columns 2026-02-02 01:58:30 -06:00
4f484779d8 Meters columns 2026-02-02 01:43:11 -06:00
46aab5fbba Projects view by user 2026-02-02 01:27:15 -06:00
1d278936b1 add user-project relation and role-based filtering 2026-02-02 01:14:57 -06:00
01aadcf2f3 correct all req.user property references in controllers 2026-02-02 01:03:53 -06:00
d25ec6ebe7 fix: correct req.user property from id to userId in controllers 2026-02-02 00:58:35 -06:00
203e6069c8 Test notifications 2026-02-01 23:40:32 -06:00
1330421ddd auth middleware 2026-02-01 23:02:55 -06:00
0ec9338ac8 auth middleware 2026-02-01 22:56:00 -06:00
58a3efa55c Dev validation deleted 2026-02-01 22:46:21 -06:00
8ca10d0b35 Notifications cronjob 2026-02-01 22:29:48 -06:00
48e0884bf7 Notifications 2026-02-01 20:54:13 -06:00
6c02bd5448 Changes 2026-02-01 18:30:28 -06:00
b5ea12dd27 Meter changes 2026-01-29 18:10:37 -06:00
33b072436d Roles section 2026-01-29 16:41:21 -06:00
13cc4528ff Audit table with better data 2026-01-28 13:28:05 -06:00
936471542a Audit changes 2026-01-27 21:00:39 -06:00
6b9f6810ab audit logic 2026-01-26 20:39:23 -06:00
135 changed files with 24076 additions and 2262 deletions

View File

@@ -1,171 +1,202 @@
# Cambios Realizados - Sesión 2026-01-23
# Historial de Cambios - Proyecto GRH
## Resumen
Corrección de errores críticos que causaban pantalla blanca y mejoras en el sistema de carga masiva.
Registro cronologico de cambios significativos realizados al proyecto.
---
## Problema 1: Pantalla Blanca en Water Meters y Consumo
## 2026-02-09: Organismos Operadores + Historico de Tomas + Documentacion
### Síntoma
Al navegar a "Water Meters" o "Consumo", la página se quedaba en blanco.
### Resumen
Implementacion completa del sistema de 3 niveles de roles (ADMIN → ORGANISMO_OPERADOR → OPERATOR), nueva pagina Historico de Tomas, y actualizacion de toda la documentacion.
### Causa
PostgreSQL devuelve valores DECIMAL como strings (ej: `"300.0000"`). El código llamaba `.toFixed()` directamente sobre estos strings, pero `.toFixed()` es un método de números, no de strings.
### Nuevas Funcionalidades
### Solución
Convertir los valores a número con `Number()` antes de llamar `.toFixed()`.
**Rol ORGANISMO_OPERADOR (8 fases completas)**
- Nueva tabla `organismos_operadores` con migracion SQL
- JWT actualizado con campo `organismoOperadorId`
- Scope filtering en todos los servicios: meter, reading, project, user, concentrator, notification
- Utility `scope.ts` para centralizar logica de filtrado
- Pagina OrganismosPage.tsx para gestion de organismos (ADMIN only)
- UsersPage actualizada con campo organismo y filtrado por scope
- ProjectsPage con campo organismo_operador_id
- Sidebar con visibilidad de 3 niveles por rol
**Historico de Tomas**
- Nueva pagina `HistoricoPage.tsx` con selector de medidor searchable
- Busqueda por nombre, serial, ubicacion, cuenta CESPT, clave catastral
- Tarjeta de informacion del medidor seleccionado
- Cards de consumo: Actual (diario), Pasado (1er dia mes anterior), Diferencial con tendencia
- Grafica AreaChart (Recharts) con gradiente y eje Y ajustado al rango de datos
- Tabla paginada de lecturas (10/20/50 por pagina)
- Filtros de rango de fechas
- Exportacion CSV
- authenticateToken en rutas GET de medidores para scope filtering
**Documentacion**
- Actualizacion completa de 8 archivos de documentacion
### Archivos Nuevos
| Archivo | Descripcion |
|---------|-------------|
| `src/pages/OrganismosPage.tsx` | Pagina CRUD de organismos operadores |
| `src/pages/historico/HistoricoPage.tsx` | Pagina de historico de tomas |
| `src/api/organismos.ts` | Cliente API para organismos operadores |
| `water-api/src/services/organismo-operador.service.ts` | Servicio backend organismos |
| `water-api/src/controllers/organismo-operador.controller.ts` | Controlador organismos |
| `water-api/src/routes/organismo-operador.routes.ts` | Rutas organismos |
| `water-api/src/utils/scope.ts` | Utility de filtrado por scope |
| `water-api/sql/add_organismos_operadores.sql` | Migracion SQL organismos |
| `water-api/sql/add_user_meter_fields.sql` | Migracion campos usuario/medidor |
### Archivos Modificados (28+)
| Archivo | Cambio |
|---------|--------|
| `src/App.tsx` | Agregados tipos "organismos" y "historico" al Page type |
| `src/components/layout/Sidebar.tsx` | Visibilidad de 3 niveles por rol |
| `src/api/auth.ts` | Helpers de rol actualizados (organismoOperadorId) |
| `src/api/meters.ts` | Interfaces MeterReadingFilters, PaginatedMeterReadings, fetchMeterReadings con paginacion |
| `src/api/users.ts` | Campo organismo_operador_id |
| `src/api/projects.ts` | Campo organismo_operador_id |
| `src/pages/Home.tsx` | Filtrado por scope |
| `src/pages/UsersPage.tsx` | Campo organismo y filtrado |
| `src/pages/projects/ProjectsPage.tsx` | Campo organismo |
| `src/pages/meters/MetersModal.tsx` | Campo project_id |
| `water-api/src/types/index.ts` | organismoOperadorId en tipos |
| `water-api/src/utils/jwt.ts` | organismoOperadorId en JWT payload |
| `water-api/src/middleware/auth.middleware.ts` | Extraccion de organismoOperadorId |
| `water-api/src/services/*.ts` | Scope filtering en todos los servicios |
| `water-api/src/controllers/*.ts` | requestingUser pass-through |
| `water-api/src/routes/*.ts` | authenticateToken en rutas GET |
| `water-api/src/validators/*.ts` | Campos de organismo |
---
## 2026-02-09: Actualizacion de documentacion (anterior)
### Resumen
Actualizacion de los 4 archivos de documentacion principales.
### Motivo
La documentacion previa describia una version temprana del proyecto y no reflejaba el backend Express propio ni los modulos agregados.
---
## 2026-02-05: Sincronizacion de conectores
### Cambio
Cambio de hora de sincronizacion de conectores de 2:00 AM a 9:00 AM.
### Archivos Modificados (2)
- `src/pages/conectores/SHMetersPage.tsx`
- `src/pages/conectores/XMetersPage.tsx`
---
## 2026-02-04: Favicon y conectores
### Cambios
- Actualizacion de favicon del sistema
- Mejoras en la visualizacion de tiempo de ultima conexion en paginas de conectores
- Agregado plan de implementacion para rol ORGANISMOS_OPERADORES
### Archivos Modificados (4+1)
- Favicon actualizado
- Paginas de conectores actualizadas
- `PLAN_ORGANISMOS_OPERADORES.md` (plan de implementacion)
---
## 2026-02-03: Dark mode, Analytics, Conectores y CSV Upload
### Resumen
Sesion mayor con multiples funcionalidades nuevas implementadas en una serie de 12 commits.
### Nuevas Funcionalidades
**Dark Mode Completo**
- Toggle dark/light/system en configuracion
- Paleta Zinc de Tailwind aplicada a todas las paginas
- Soporte en tablas, modales, formularios, sidebars
- Cards de ConsumptionPage y tabla de AuditoriaPage
**Seccion Analytics (3 paginas)**
- `AnalyticsMapPage.tsx` - Mapa Leaflet con ubicaciones de medidores
- `AnalyticsReportsPage.tsx` - Dashboard de reportes y estadisticas
- `AnalyticsServerPage.tsx` - Metricas del servidor (CPU, memoria, requests)
- `MapComponents.tsx` - Componentes auxiliares del mapa
**Seccion Conectores (3 paginas)**
- `SHMetersPage.tsx` - Conector para sistema SH-Meters
- `XMetersPage.tsx` - Conector para sistema XMeters
- `TTSPage.tsx` - Conector para The Things Stack (LoRaWAN)
**Upload Panel (app separada)**
- Nueva aplicacion en `upload-panel/` con React + Vite + Tailwind
- `MetersUpload.tsx` - Carga de medidores via CSV (upsert)
- `ReadingsUpload.tsx` - Carga de lecturas via CSV
- `FileDropzone.tsx` - Componente de dropzone para archivos
- `ResultsDisplay.tsx` - Visualizacion de resultados
**Otros**
- Nuevos tipos de medidor: LORA, LORAWAN, GRANDES CONSUMIDORES
- Documentacion completa del proyecto (6 archivos)
### Archivos Modificados
**`src/pages/meters/MetersTable.tsx` (línea 75)**
```typescript
// ANTES:
r.lastReadingValue?.toFixed(2)
// DESPUÉS:
r.lastReadingValue != null ? Number(r.lastReadingValue).toFixed(2) : "-"
```
**`src/pages/consumption/ConsumptionPage.tsx` (líneas 133, 213, 432)**
```typescript
// ANTES:
r.readingValue.toFixed(2)
summary?.avgReading.toFixed(1)
reading.readingValue.toFixed(2)
// DESPUÉS:
Number(r.readingValue).toFixed(2)
summary?.avgReading != null ? Number(summary.avgReading).toFixed(1) : "0"
Number(reading.readingValue).toFixed(2)
```
Aproximadamente 50+ archivos en 12 commits.
---
## Problema 2: Modal de Carga Masiva se Cerraba sin Mostrar Resultados
## 2026-01-23: Fix pantalla blanca y carga masiva
### Síntoma
Al subir un archivo Excel para carga masiva, el modal se cerraba inmediatamente sin mostrar cuántos registros se insertaron o qué errores hubo.
### Resumen
Correccion de errores criticos que causaban pantalla blanca y mejoras en el sistema de carga masiva.
### Causa
El callback `onSuccess` cerraba el modal automáticamente:
```typescript
onSuccess={() => {
m.loadMeters();
setShowBulkUpload(false); // ← Cerraba antes de ver resultados
}}
```
### Problema 1: Pantalla Blanca en Water Meters y Consumo
### Solución
Separar la recarga de datos del cierre del modal. Ahora el modal solo se cierra cuando el usuario hace clic en "Cerrar".
**Sintoma:** Al navegar a "Water Meters" o "Consumo", la pagina se quedaba en blanco.
### Archivo Modificado
**Causa:** PostgreSQL devuelve valores DECIMAL como strings (ej: `"300.0000"`). El codigo llamaba `.toFixed()` directamente sobre estos strings.
**`src/pages/meters/MeterPage.tsx` (líneas 332-340)**
```typescript
// ANTES:
<MetersBulkUploadModal
onClose={() => setShowBulkUpload(false)}
onSuccess={() => {
m.loadMeters();
setShowBulkUpload(false);
}}
/>
**Solucion:** Convertir a numero con `Number()` antes de `.toFixed()`.
// DESPUÉS:
<MetersBulkUploadModal
onClose={() => {
m.loadMeters();
setShowBulkUpload(false);
}}
onSuccess={() => {
m.loadMeters();
}}
/>
```
**Archivos:**
- `src/pages/meters/MetersTable.tsx:75`
- `src/pages/consumption/ConsumptionPage.tsx:133, 213, 432`
---
### Problema 2: Modal de Carga Masiva se Cerraba sin Resultados
## Problema 3: Error de Fecha Inválida en Carga Masiva
**Sintoma:** El modal se cerraba automaticamente despues de la carga sin mostrar resultados.
### Síntoma
Al subir medidores, aparecía el error:
```
Fila X: invalid input syntax for type date: "Installed"
```
**Causa:** El callback `onSuccess` cerraba el modal automaticamente.
### Causa
El archivo Excel tenía columnas con valores como "Installed" o "New_LoRa" que el sistema interpretaba como fechas porque no estaban mapeadas correctamente.
**Solucion:** Separar recarga de datos (`onSuccess`) del cierre del modal (`onClose`).
### Solución
1. **Validar fechas**: Verificar que `installation_date` sea realmente una fecha válida antes de usarla.
2. **Más mapeos de columnas**: Agregar mapeos para columnas comunes como `device_status`, `device_name`, etc.
3. **Normalizar status**: Convertir valores como "Installed", "New_LoRa" a "ACTIVE".
**Archivo:** `src/pages/meters/MeterPage.tsx:332-340`
### Archivo Modificado
### Problema 3: Error de Fecha Invalida en Carga Masiva
**`water-api/src/services/bulk-upload.service.ts`**
**Sintoma:** Error `invalid input syntax for type date: "Installed"` al subir medidores.
Validación de fechas (líneas 183-195):
```typescript
let installationDate: string | undefined = undefined;
if (row.installation_date) {
const dateStr = String(row.installation_date).trim();
if (/^\d{4}[-/]\d{1,2}[-/]\d{1,2}/.test(dateStr) || /^\d{1,2}[-/]\d{1,2}[-/]\d{2,4}/.test(dateStr)) {
const parsed = new Date(dateStr);
if (!isNaN(parsed.getTime())) {
installationDate = parsed.toISOString().split('T')[0];
}
}
}
```
**Causa:** Columnas con valores como "Installed" o "New_LoRa" se interpretaban como fechas.
Mapeos de columnas adicionales (líneas 65-90):
```typescript
const mappings: Record<string, string> = {
// Serial number
'device_s/n': 'serial_number',
'device_sn': 'serial_number',
// Name
'device_name': 'name',
'meter_name': 'name',
// Status
'device_status': 'status',
// ... más mapeos
};
```
**Solucion:**
1. Validar formato de fecha con regex antes de usarla
2. Agregar mapeos de columnas comunes (`device_s/n``serial_number`, etc.)
3. Normalizar status ("Installed" → ACTIVE, "New_LoRa" → ACTIVE, etc.)
Normalización de status (líneas 210-225):
```typescript
const statusMappings: Record<string, string> = {
'INSTALLED': 'ACTIVE',
'NEW_LORA': 'ACTIVE',
'NEW': 'ACTIVE',
'ENABLED': 'ACTIVE',
'DISABLED': 'INACTIVE',
// ...
};
```
---
## Archivos Modificados en Esta Sesión
**Archivo:** `water-api/src/services/bulk-upload.service.ts`
### Archivos Modificados
| Archivo | Cambio |
|---------|--------|
| `src/pages/meters/MetersTable.tsx` | Fix `.toFixed()` en lastReadingValue |
| `src/pages/consumption/ConsumptionPage.tsx` | Fix `.toFixed()` en readingValue y avgReading |
| `src/pages/meters/MeterPage.tsx` | Fix modal de carga masiva |
| `water-api/src/services/bulk-upload.service.ts` | Validación de fechas, mapeos de columnas, normalización de status |
| `ESTADO_ACTUAL.md` | Documentación actualizada |
| `CAMBIOS_SESION.md` | Este archivo |
| `water-api/src/services/bulk-upload.service.ts` | Validacion de fechas, mapeos, normalizacion |
---
## Verificación
1. ✅ La página de Water Meters carga correctamente
2. ✅ La página de Consumo carga correctamente
3. ✅ El modal de carga masiva muestra resultados
4. ✅ Errores de carga masiva se muestran claramente
5. ✅ Valores como "Installed" no causan error de fecha
### Verificacion
- La pagina de Water Meters carga correctamente
- La pagina de Consumo carga correctamente
- El modal de carga masiva muestra resultados
- Errores de carga masiva se muestran claramente
- Valores como "Installed" no causan error de fecha

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +1,72 @@
# Estado Actual del Proyecto Water Project GRH
# Estado Actual del Proyecto GRH
**Fecha:** 2026-01-23
**Última actualización:** Corrección de errores y mejoras en carga masiva
**Fecha:** 2026-02-09
**Ultima actualizacion:** Documentacion actualizada para reflejar el estado completo del proyecto
---
## Resumen del Proyecto
Sistema de gestión de medidores de agua con:
- **Frontend:** React + TypeScript + Vite (puerto 5173)
Sistema full-stack de gestion de medidores de agua con:
- **Frontend:** React 18 + TypeScript + Vite (puerto 5173)
- **Backend:** Node.js + Express + TypeScript (puerto 3000)
- **Base de datos:** PostgreSQL
- **Upload Panel:** App separada para carga CSV masiva
### Jerarquía de datos:
### Jerarquia de datos:
```
Projects → Concentrators → Meters → Readings
Organismos Operadores → Projects → Concentrators → Meters → Readings
→ Gateways → Devices ↗
```
### URLs de produccion:
- **Frontend:** https://sistema.gestionrecursoshidricos.com
- **Backend:** https://api.gestionrecursoshidricos.com
### Repositorios:
- **Gitea:** https://git.consultoria-as.com/consultoria-as/GRH
- **GitHub:** git@github.com:luanngel/water-project.git
---
## Arquitectura del Sistema
```
┌─────────────────────────────────────────────────────────────┐
│ FRONTEND (React)
│ http://localhost:5173 │
│ FRONTEND (React SPA)
│ http://localhost:5173
├─────────────────────────────────────────────────────────────┤
- React 18 + TypeScript + Vite │
- Tailwind CSS + Material-UI
- Recharts para gráficos
- Cliente API con JWT automático
└─────────────────────────────────────────────────────────────┘
│ React 18 + TypeScript + Vite
│ Tailwind CSS (paleta Zinc) + Material-UI 7
│ Recharts (graficos) + Leaflet (mapas)
│ Cliente API con JWT + refresh automatico │
│ Dark mode / Light mode / System │
└──────────────────────────┬──────────────────────────────────┘
│ REST API + JWT Bearer
┌─────────────────────────────────────────────────────────────┐
│ BACKEND (Node.js) │
│ http://localhost:3000 │
│ BACKEND (Express) │
│ http://localhost:3000
├─────────────────────────────────────────────────────────────┤
- Express + TypeScript
- Autenticación JWT con refresh tokens │
- CRUD completo para todas las entidades
- Carga masiva via Excel (xlsx)
└─────────────────────────────────────────────────────────────┘
│ Express + TypeScript + Zod (validacion)
JWT access (15m) + refresh (7d) tokens
17 archivos de rutas, 18 servicios
Helmet, CORS, Bcrypt, Winston logging
│ node-cron (deteccion flujo negativo) │
│ Multer + XLSX (carga masiva)
Webhooks TTS (The Things Stack / LoRaWAN)
└──────────────────────────┬──────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
BASE DE DATOS
│ PostgreSQL │
PostgreSQL
├─────────────────────────────────────────────────────────────┤
Tablas: users, roles, projects, concentrators,
meters, meter_readings, refresh_tokens
11 tablas: roles, users, organismos_operadores, projects,
concentrators, gateways, devices, meters, meter_readings,
│ tts_uplink_logs, refresh_tokens │
│ 2 vistas: meter_stats_by_project, device_status_summary │
│ 9 archivos SQL (schema + 8 migraciones) │
│ Triggers de updated_at, indices compuestos, JSONB │
└─────────────────────────────────────────────────────────────┘
```
@@ -57,58 +74,148 @@ Projects → Concentrators → Meters → Readings
## Funcionalidades Implementadas
### 1. Autenticación
- Login con JWT + refresh tokens
- Manejo automático de renovación de tokens
- Roles: ADMIN, USER
### 1. Autenticacion y Autorizacion
- Login con JWT: access token (15 min) + refresh token (7 dias)
- Refresh automatico de tokens en el cliente (cola de peticiones)
- **Jerarquia de 3 niveles:** ADMIN → ORGANISMO_OPERADOR → OPERATOR
- Scope filtering en todos los servicios backend (via `scope.ts`)
- JWT incluye: userId, roleId, roleName, projectId, organismoOperadorId
- Hash de contrasenas con bcrypt (12 rounds)
- Proteccion de rutas por rol en backend y frontend
- authenticateToken requerido en todas las rutas GET de medidores
### 2. Gestión de Proyectos
### 2. Dashboard (Home)
- KPIs: Total medidores, medidores activos, consumo promedio, alertas
- Grafico de barras: Medidores por proyecto (Recharts)
- Selector de organismos operadores (filtrado por rol)
- Historial reciente de actividades
- Panel de ultimas alertas
- Soporte por rol (ADMIN, ORGANISMO_OPERADOR, OPERATOR)
### 3. Gestion de Proyectos
- CRUD completo
- Estados: ACTIVE/INACTIVE
- Estados: ACTIVE, INACTIVE, COMPLETED
- Estadisticas por proyecto (medidores, lecturas, areas)
### 3. Gestión de Concentradores
### 4. Gestion de Concentradores
- CRUD completo
- Vinculados a proyectos
- Tipos: Gateway LoRa/LoRaWAN
- Estado: ACTIVE, INACTIVE, OFFLINE, MAINTENANCE, ERROR
- IP, firmware, ultima comunicacion
### 4. Gestión de Medidores
- CRUD completo
- Tipos: LORA, LORAWAN, GRANDES
- Estados: ACTIVE, INACTIVE, MAINTENANCE, FAULTY, REPLACED
- **Carga masiva via Excel**
- Última lectura visible en tabla
### 5. Gestion de Medidores
- CRUD completo con tabla, sidebar de detalle y modal de edicion
- Tipos de medidor: WATER, GAS, ELECTRIC
- Protocolos: GENERAL, LORA, LORAWAN
- Estados: ACTIVE, INACTIVE, OFFLINE, MAINTENANCE, ERROR
- Carga masiva via Excel y CSV
- Busqueda, filtros por proyecto/tipo/estado
- Ultima lectura visible en tabla
- Campos extendidos: protocolo, MAC, gateway, voltaje, senal, flujo, coordenadas
- Tipos adicionales: LORA, LORAWAN, GRANDES CONSUMIDORES
### 5. Gestión de Lecturas (Consumo)
- CRUD completo
### 6. Consumo y Lecturas
- CRUD de lecturas
- Tipos: AUTOMATIC, MANUAL, SCHEDULED
- **Carga masiva via Excel**
- Filtros por proyecto, fecha
- Exportación a CSV
- Indicadores de batería y señal
- Carga masiva via Excel y CSV
- Filtros por proyecto, medidor, rango de fechas
- Resumen de consumo (total, promedio, min, max)
- Indicadores de bateria y senal
- Exportacion
### 6. Dashboard
- KPIs: Total lecturas, medidores activos, consumo promedio
- Gráficos por proyecto
- Últimas alertas
### 7. Analytics
- **Mapa:** Visualizacion de medidores con coordenadas en mapa Leaflet interactivo
- **Reportes:** Dashboard de estadisticas y reportes de consumo
- **Servidor:** Metricas del sistema (CPU, memoria, uptime, requests)
### 8. Conectores Externos
- **SH-Meters:** Integracion con sistema de medidores SH
- **XMeters:** Integracion con sistema XMeters
- **The Things Stack (TTS):** Webhooks LoRaWAN para uplink, join y downlink/ack
- Sincronizacion programada a las 9:00 AM
- Seguimiento de ultima conexion y estado
### 9. Gestion de Organismos Operadores (NUEVO)
- CRUD completo (solo ADMIN)
- Tabla: organismos_operadores (nombre, codigo, contacto, telefono, estado)
- Vinculacion con proyectos (projects.organismo_operador_id)
- Vinculacion con usuarios (users.organismo_operador_id)
- Pagina frontend: OrganismosPage.tsx
### 10. Gestion de Usuarios
- CRUD completo (ADMIN y ORGANISMO_OPERADOR)
- Asignacion de roles, proyecto y organismo operador
- Filtrado por scope: ADMIN ve todos, ORGANISMO ve los de su organismo
- Estados: activo/inactivo
- Cambio de contrasena
### 11. Gestion de Roles
- 3 roles predefinidos: ADMIN, ORGANISMO_OPERADOR, OPERATOR
- Permisos granulares JSONB por recurso
- CRUD de roles (solo ADMIN)
- Conteo de usuarios por rol
### 12. Historico de Tomas (NUEVO)
- Pagina dedicada para consultar historial de lecturas por medidor
- Selector de medidor con busqueda por nombre, serial, ubicacion, cuenta CESPT y clave catastral
- Tarjeta de informacion del medidor seleccionado
- Cards de consumo: Consumo Actual (diario), Consumo Pasado (primer dia del mes anterior), Diferencial
- Grafica AreaChart (Recharts) con gradiente, eje Y ajustado al rango de datos
- Tabla paginada de lecturas (10/20/50 por pagina)
- Filtros de rango de fechas
- Exportacion CSV
- Filtrado por scope: cada rol solo ve los medidores asignados
### 13. Auditoria
- Registro automatico de todas las acciones via middleware
- Visor de logs con filtros (solo ADMIN)
- Actividad del usuario actual (todos los roles)
- Estadisticas de auditoria
- Busqueda por registro especifico
### 14. Notificaciones
- Notificaciones in-app
- Conteo de no leidas en tiempo real
- Marcar como leida (individual y masiva)
- Generacion automatica por flujo negativo (cron job)
- Dropdown en TopMenu
### 15. Dark Mode
- Soporte completo: Dark / Light / System
- Paleta Zinc de Tailwind
- Aplicado a todas las paginas, modales, tablas, formularios
- Persistencia en localStorage
### 16. Upload Panel
- Aplicacion separada (`upload-panel/`) para carga CSV
- Dropzone para archivos
- Carga de medidores (upsert)
- Carga de lecturas
- Descarga de plantillas
- Visualizacion de resultados y errores
---
## Carga Masiva
### Medidores (Excel)
### Medidores (Excel / CSV)
Columnas requeridas:
- `serial_number` - Número de serie del medidor (único)
- `serial_number` - Numero de serie del medidor (unico)
- `name` - Nombre del medidor
- `concentrator_serial` - Serial del concentrador existente
Columnas opcionales:
- `meter_id` - ID del medidor
- `location` - Ubicación
- `location` - Ubicacion
- `type` - LORA, LORAWAN, GRANDES (default: LORA)
- `status` - ACTIVE, INACTIVE, etc. (default: ACTIVE)
- `installation_date` - Fecha de instalación (YYYY-MM-DD)
- `installation_date` - Fecha de instalacion (YYYY-MM-DD)
### Lecturas (Excel)
Mapeos automaticos de columnas: `device_s/n``serial_number`, `device_name``name`, `device_status``status`, etc.
Normalizacion de status: "Installed" → ACTIVE, "New_LoRa" → ACTIVE, "Enabled" → ACTIVE, "Disabled" → INACTIVE.
### Lecturas (Excel / CSV)
Columnas requeridas:
- `meter_serial` - Serial del medidor existente
- `reading_value` - Valor de la lectura
@@ -116,21 +223,12 @@ Columnas requeridas:
Columnas opcionales:
- `reading_type` - AUTOMATIC, MANUAL, SCHEDULED (default: MANUAL)
- `received_at` - Fecha/hora (default: ahora)
- `battery_level` - Nivel de batería (%)
- `signal_strength` - Intensidad de señal (dBm)
- `battery_level` - Nivel de bateria (%)
- `signal_strength` - Intensidad de senal (dBm)
---
## Credenciales
### Usuario Admin
- **Nombre:** Ivan Alcaraz
- **Email:** ialcarazsalazar@consultoria-as.com
- **Password:** Aasi940812
---
## Datos Actuales en BD
## Datos en Base de Datos
### Proyectos
- ADAMANT
@@ -152,98 +250,80 @@ Columnas opcionales:
---
## Correcciones Recientes (2026-01-23)
## Historial de Correcciones
### 1. Error `.toFixed()` con valores string
**Problema:** PostgreSQL devuelve DECIMAL como string, causando error al llamar `.toFixed()`.
**Solución:** Convertir a número con `Number()` antes de llamar `.toFixed()`.
**Archivos:**
- `src/pages/meters/MetersTable.tsx:75`
- `src/pages/consumption/ConsumptionPage.tsx:133, 213, 432`
### 2026-01-23: Fix pantalla blanca y carga masiva
1. **Fix `.toFixed()` con strings** - PostgreSQL devuelve DECIMAL como string. Se envuelve con `Number()`.
2. **Fix modal de carga masiva** - Separar recarga de datos del cierre del modal.
3. **Fix fechas invalidas en carga masiva** - Validacion de formato con regex + mapeos de columnas + normalizacion de status.
### 2. Modal de carga masiva se cerraba sin mostrar resultados
**Problema:** El modal se cerraba automáticamente después de la carga.
**Solución:** El modal ahora permanece abierto para mostrar resultados y errores.
**Archivo:** `src/pages/meters/MeterPage.tsx:332-340`
### 2026-02-03: Dark mode, Analytics, Conectores, CSV Upload
- Implementacion completa de dark mode con paleta Zinc
- Seccion Analytics: mapa, reportes, servidor
- Seccion Conectores: SH-Meters, XMeters, TTS
- Toggle dark/light theme
- Panel CSV para carga masiva
- Nuevos tipos de medidor: LORA, LORAWAN, GRANDES CONSUMIDORES
- Documentacion completa del proyecto
### 3. Validación de fechas en carga masiva
**Problema:** Valores como "Installed" en columnas no mapeadas causaban error de fecha inválida.
**Solución:** Validar que `installation_date` sea realmente una fecha antes de insertarla.
**Archivo:** `water-api/src/services/bulk-upload.service.ts:183-195`
### 2026-02-04: Favicon y conectores
- Actualizacion de favicon
- Mejoras en tiempo de ultima conexion de conectores
- Plan de implementacion para rol ORGANISMOS_OPERADORES
### 4. Mapeo de columnas mejorado
**Mejora:** Agregados más mapeos de columnas comunes (device_status, device_name, etc.)
**Archivo:** `water-api/src/services/bulk-upload.service.ts:65-90`
### 2026-02-09: Organismos Operadores + Historico de Tomas
- Implementacion completa del rol ORGANISMO_OPERADOR (jerarquia de 3 niveles)
- Nueva tabla `organismos_operadores` con migracion SQL
- Scope filtering en todos los servicios backend (meter, reading, project, user, concentrator, notification)
- JWT actualizado con `organismoOperadorId`
- Pagina OrganismosPage.tsx para gestion de organismos (ADMIN)
- Pagina HistoricoPage.tsx para consulta de historial de lecturas por medidor
- Cards de consumo: Actual, Pasado, Diferencial
- Grafica AreaChart con gradiente y eje Y ajustado
- Busqueda por cuenta CESPT y clave catastral
- authenticateToken en rutas GET de medidores
- Sidebar con visibilidad de 3 niveles
- UsersPage actualizada con asignacion de organismo
- ProjectsPage con campo organismo_operador_id
### 5. Normalización de status
**Mejora:** Valores como "Installed", "New_LoRa" se convierten automáticamente a "ACTIVE".
**Archivo:** `water-api/src/services/bulk-upload.service.ts:210-225`
### 2026-02-05: Sincronizacion de conectores
- Cambio de hora de sincronizacion de 2:00 AM a 9:00 AM
---
## Comandos Útiles
## Comandos Utiles
```bash
# Iniciar backend
# Iniciar backend (desarrollo)
cd /home/GRH/water-project/water-api
npm run dev
# Iniciar frontend
# Iniciar frontend (desarrollo)
cd /home/GRH/water-project
npm run dev
# Compilar backend
# Iniciar upload panel (desarrollo)
cd /home/GRH/water-project/upload-panel
npm run dev
# Compilar backend para produccion
cd /home/GRH/water-project/water-api
npm run build && npm run start
# Compilar frontend para produccion
cd /home/GRH/water-project
npm run build
# Ver logs del backend
tail -f /tmp/water-api.log
# Ejecutar schema de base de datos
psql -d water_project -f water-api/sql/schema.sql
```
---
## Estructura de Archivos
## Proximos Pasos Sugeridos
```
water-project/
├── src/ # Frontend React
│ ├── api/ # Cliente API
│ │ ├── client.ts # Cliente HTTP con JWT
│ │ ├── meters.ts # API de medidores
│ │ ├── readings.ts # API de lecturas
│ │ ├── projects.ts # API de proyectos
│ │ └── concentrators.ts # API de concentradores
│ ├── pages/ # Páginas
│ │ ├── meters/ # Módulo de medidores
│ │ │ ├── MeterPage.tsx
│ │ │ ├── MetersTable.tsx
│ │ │ ├── MetersModal.tsx
│ │ │ ├── MetersSidebar.tsx
│ │ │ ├── MetersBulkUploadModal.tsx
│ │ │ └── useMeters.ts
│ │ ├── consumption/ # Módulo de consumo
│ │ │ ├── ConsumptionPage.tsx
│ │ │ └── ReadingsBulkUploadModal.tsx
│ │ └── ...
│ └── components/ # Componentes reutilizables
└── water-api/ # Backend Node.js
├── src/
│ ├── controllers/ # Controladores REST
│ ├── services/ # Lógica de negocio
│ │ ├── bulk-upload.service.ts
│ │ └── ...
│ ├── routes/ # Definición de rutas
│ ├── middleware/ # Middlewares (auth, etc.)
│ └── config/ # Configuración (DB, etc.)
└── sql/ # Scripts SQL
```
---
## Próximos Pasos Sugeridos
1. **Integración TTS** - Webhooks para The Things Stack
2. **Alertas automáticas** - Notificaciones por consumo anormal
3. **Reportes** - Generación de reportes PDF
4. **Despliegue** - Configurar para producción
1. **Reportes PDF** - Generacion y descarga de reportes en PDF
2. **Tests** - Suite de tests con Vitest (frontend) y Supertest (backend)
3. **CI/CD** - Pipeline de integracion continua
4. **Docker** - Containerizacion del proyecto completo
5. **Alertas avanzadas** - Configuracion de umbrales y notificaciones por email

611
README.md
View File

@@ -1,23 +1,57 @@
# Water Project - Sistema de Gestion de Recursos Hidricos (GRH)
# GRH - Sistema de Gestion de Recursos Hidricos
Sistema de gestion y monitoreo de infraestructura hidrica desarrollado con React, TypeScript y Vite.
Sistema full-stack de gestion y monitoreo de infraestructura hidrica. Permite administrar medidores de agua, concentradores, proyectos, consumo, lecturas y conectarse con sistemas IoT (LoRaWAN / The Things Stack).
---
## Descripcion General
El **Sistema de Gestion de Recursos Hidricos (GRH)** es una aplicacion web frontend disenada para el monitoreo, administracion y control de infraestructura de toma de agua. Permite gestionar medidores, concentradores, proyectos, usuarios y roles a traves de una interfaz moderna y responsiva.
El **Sistema GRH** es una aplicacion web completa para organismos operadores de agua (CESPT Tijuana, Tecate, Mexicali, etc.) que incluye:
### Caracteristicas Principales
- **Dashboard interactivo** con KPIs, alertas e historial de actividades
- **Gestion de Medidores (Tomas de Agua)** - CRUD completo con filtros por proyecto
- **Dashboard interactivo** con KPIs, graficos y alertas
- **Gestion de Medidores** - CRUD completo con carga masiva Excel/CSV
- **Gestion de Concentradores** - Configuracion de gateways LoRa/LoRaWAN
- **Gestion de Proyectos** - Administracion de proyectos de infraestructura
- **Gestion de Usuarios y Roles** - Control de acceso al sistema
- **Consumo y Lecturas** - Seguimiento historico de lecturas con filtros y exportacion
- **Analytics** - Mapa de medidores, reportes y metricas del servidor
- **Conectores** - Integracion con SH-Meters, XMeters y The Things Stack
- **Organismos Operadores** - Gestion de organismos operadores de agua (ADMIN)
- **Historico de Tomas** - Consulta de historial de lecturas por medidor con grafica y estadisticas
- **Usuarios y Roles** - Control de acceso basado en roles con jerarquia de 3 niveles
- **Auditoria** - Registro completo de actividad del sistema (ADMIN)
- **Notificaciones** - Alertas en tiempo real (flujo negativo, etc.)
- **Tema claro/oscuro** - Personalizacion de la interfaz
- **Diseno responsive** - Compatible con desktop, tablet y movil
---
## Arquitectura
```
┌─────────────────────────────────────────────────────────────┐
│ FRONTEND (React SPA) │
│ http://localhost:5173 │
│ React 18 + TypeScript + Vite + Tailwind CSS + MUI │
└──────────────────────────┬──────────────────────────────────┘
│ REST API (JWT)
┌─────────────────────────────────────────────────────────────┐
│ BACKEND (Express API) │
│ http://localhost:3000 │
│ Express + TypeScript + Zod + Winston + node-cron │
└──────────────────────────┬──────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ PostgreSQL │
│ 11 tablas + 2 vistas + triggers + indices │
└─────────────────────────────────────────────────────────────┘
```
Adicionalmente existe un **Upload Panel** (`upload-panel/`) como aplicacion separada para carga masiva de datos via CSV.
---
## Stack Tecnologico
### Frontend
@@ -27,13 +61,26 @@ El **Sistema de Gestion de Recursos Hidricos (GRH)** es una aplicacion web front
| TypeScript | 5.2.2 | Type safety |
| Vite | 5.2.0 | Build tool y dev server |
| Tailwind CSS | 4.1.18 | Estilos utility-first |
| Material-UI | 7.3.6 | Componentes UI |
| Material-UI (MUI) | 7.3.6 | Componentes UI |
| MUI X Data Grid | 8.21.0 | Tablas de datos avanzadas |
| Recharts | 3.6.0 | Visualizacion de datos |
| Leaflet / React-Leaflet | 1.9.4 / 4.2.1 | Mapas interactivos |
| Lucide React | 0.559.0 | Iconos SVG |
### Herramientas de Desarrollo
- **ESLint** - Linting de codigo
- **TypeScript ESLint** - Analisis estatico
### Backend
| Tecnologia | Version | Proposito |
|------------|---------|-----------|
| Express.js | 4.18.2 | Framework HTTP |
| TypeScript | 5.3.3 | Type safety |
| PostgreSQL (pg) | 8.11.3 | Driver de base de datos |
| JWT (jsonwebtoken) | 9.0.2 | Autenticacion con tokens |
| Bcrypt | 5.1.1 | Hash de contrasenas |
| Zod | 3.22.4 | Validacion de datos |
| Helmet | 7.1.0 | Headers de seguridad |
| Winston | 3.11.0 | Logging |
| Multer | 2.0.2 | Subida de archivos |
| XLSX | 0.18.5 | Parseo de archivos Excel |
| node-cron | 3.0.3 | Tareas programadas |
---
@@ -42,334 +89,368 @@ El **Sistema de Gestion de Recursos Hidricos (GRH)** es una aplicacion web front
### Prerrequisitos
- Node.js >= 18.x
- npm >= 9.x o yarn >= 1.22
- npm >= 9.x
- PostgreSQL >= 14.x
### Pasos de Instalacion
### 1. Clonar el repositorio
1. **Clonar el repositorio**
```bash
git clone <url-del-repositorio>
cd water-project
git clone https://git.consultoria-as.com/consultoria-as/GRH.git
cd GRH
```
2. **Instalar dependencias**
### 2. Configurar la base de datos
```bash
# Crear la base de datos
createdb water_project
# Ejecutar el schema principal
psql -d water_project -f water-api/sql/schema.sql
# Ejecutar migraciones adicionales
psql -d water_project -f water-api/sql/add_audit_logs.sql
psql -d water_project -f water-api/sql/add_notifications.sql
psql -d water_project -f water-api/sql/add_meter_extended_fields.sql
psql -d water_project -f water-api/sql/create_meter_types.sql
psql -d water_project -f water-api/sql/add_meter_project_relation.sql
psql -d water_project -f water-api/sql/add_user_project_relation.sql
psql -d water_project -f water-api/sql/add_organismos_operadores.sql
psql -d water_project -f water-api/sql/add_user_meter_fields.sql
```
### 3. Configurar el backend
```bash
cd water-api
cp .env.example .env
# Editar .env con las credenciales de PostgreSQL y secretos JWT
npm install
```
3. **Configurar variables de entorno**
### 4. Configurar el frontend
```bash
cd ..
cp .env.example .env
# Editar .env con la URL del backend
npm install
```
Editar el archivo `.env`:
```env
VITE_API_BASE_URL=https://tu-api-url.com
VITE_API_TOKEN=tu-token-de-api
```
### 5. Iniciar en desarrollo
4. **Iniciar servidor de desarrollo**
```bash
# Terminal 1 - Backend
cd water-api
npm run dev
# Terminal 2 - Frontend
cd ..
npm run dev
```
La aplicacion estara disponible en `http://localhost:5173`
El frontend estara disponible en `http://localhost:5173` y el backend en `http://localhost:3000`.
---
## Variables de Entorno
### Frontend (`.env`)
| Variable | Descripcion | Ejemplo |
|----------|-------------|---------|
| `VITE_API_BASE_URL` | URL base del backend | `http://localhost:3000` |
### Backend (`water-api/.env`)
| Variable | Descripcion | Ejemplo |
|----------|-------------|---------|
| `PORT` | Puerto del servidor | `3000` |
| `NODE_ENV` | Entorno | `development` |
| `DB_HOST` | Host de PostgreSQL | `localhost` |
| `DB_PORT` | Puerto de PostgreSQL | `5432` |
| `DB_NAME` | Nombre de la base de datos | `water_project` |
| `DB_USER` | Usuario de PostgreSQL | `postgres` |
| `DB_PASSWORD` | Contrasena de PostgreSQL | `your_password` |
| `JWT_ACCESS_SECRET` | Secreto para access tokens | `random_string` |
| `JWT_REFRESH_SECRET` | Secreto para refresh tokens | `random_string` |
| `JWT_ACCESS_EXPIRES` | Expiracion access token | `15m` |
| `JWT_REFRESH_EXPIRES` | Expiracion refresh token | `7d` |
| `CORS_ORIGIN` | Origenes permitidos | `http://localhost:5173` |
| `TTS_ENABLED` | Habilitar The Things Stack | `false` |
| `TTS_BASE_URL` | URL de TTS | `https://...` |
| `TTS_WEBHOOK_SECRET` | Secreto para webhooks TTS | `random_string` |
---
## Scripts Disponibles
### Frontend
| Comando | Descripcion |
|---------|-------------|
| `npm run dev` | Inicia el servidor de desarrollo |
| `npm run build` | Compila TypeScript y genera build de produccion |
| `npm run preview` | Previsualiza el build de produccion |
| `npm run lint` | Ejecuta ESLint en el codigo |
| `npm run dev` | Servidor de desarrollo (puerto 5173) |
| `npm run build` | Compilar TypeScript + build de produccion |
| `npm run preview` | Previsualizar build de produccion |
| `npm run lint` | Ejecutar ESLint |
### Backend (`water-api/`)
| Comando | Descripcion |
|---------|-------------|
| `npm run dev` | Servidor de desarrollo con hot-reload |
| `npm run build` | Compilar TypeScript |
| `npm run start` | Ejecutar build compilado |
| `npm run watch` | Desarrollo con nodemon |
### Upload Panel (`upload-panel/`)
| Comando | Descripcion |
|---------|-------------|
| `npm run dev` | Servidor de desarrollo |
| `npm run build` | Build de produccion |
---
## Estructura del Proyecto
```
water-project/
├── public/ # Assets estaticos
── grhWatermark.jpg
├── src/
│ ├── api/ # Capa de comunicacion con API
│ │ ├── me.ts # Endpoints de perfil
│ │ ├── meters.ts # CRUD de medidores
│ │ ├── concentrators.ts # CRUD de concentradores
│ │ ── projects.ts # CRUD de proyectos
GRH/
├── src/ # Frontend React SPA
── api/ # Cliente API (14 modulos)
│ ├── client.ts # Cliente HTTP con JWT y refresh automatico
│ │ ├── auth.ts # Autenticacion y gestion de tokens
│ ├── meters.ts # CRUD de medidores + lecturas historicas
│ │ ├── readings.ts # Lecturas de consumo
│ │ ├── projects.ts # Proyectos
│ │ ├── concentrators.ts # Concentradores
│ │ ── users.ts # Usuarios
│ │ ├── roles.ts # Roles
│ │ ├── organismos.ts # CRUD organismos operadores
│ │ ├── analytics.ts # Analytics y metricas
│ │ ├── notifications.ts # Notificaciones
│ │ ├── audit.ts # Auditoria
│ │ ├── me.ts # Perfil de usuario
│ │ ├── meterTypes.ts # Tipos de medidor
│ │ └── types.ts # Tipos compartidos
│ │
│ ├── components/
│ │ ├── layout/ # Componentes de layout
│ │ │ ├── Sidebar.tsx # Menu lateral
│ │ │ ├── TopMenu.tsx # Barra superior
│ │ │ └── common/ # Componentes reutilizables
│ │ │ ├── ProfileModal.tsx
│ │ │ ├── ConfirmModal.tsx
│ │ │ ── Watermark.tsx
│ │ └── SettingsModals.tsx
│ ├── components/ # Componentes reutilizables
│ │ ├── layout/
│ │ │ ├── Sidebar.tsx # Menu lateral (colapsable, pin)
│ │ │ ├── TopMenu.tsx # Barra superior con breadcrumb
│ │ │ └── common/
│ │ │ ├── ProfileModal.tsx # Editar perfil y avatar
│ │ │ ├── ConfirmModal.tsx # Confirmacion de acciones
│ │ │ ── Watermark.tsx # Marca de agua GRH
│ │ │ └── ProjectBadge.tsx # Badge de proyecto
│ │ ├── SettingsModals.tsx # Configuracion de tema/UI
│ │ └── NotificationDropdown.tsx # Panel de notificaciones
│ │
│ ├── pages/ # Paginas principales
│ │ ├── Home.tsx # Dashboard
│ │ ├── LoginPage.tsx # Login
│ │ ├── UsersPage.tsx # Gestion de usuarios
│ │ ├── RolesPage.tsx # Gestion de roles
│ ├── pages/
│ │ ├── Home.tsx # Dashboard con KPIs y graficos
│ │ ├── LoginPage.tsx # Inicio de sesion
│ │ ├── UsersPage.tsx # Gestion de usuarios
│ │ ├── RolesPage.tsx # Gestion de roles
│ │ ├── AuditoriaPage.tsx # Visor de logs de auditoria
│ │ ├── OrganismosPage.tsx # Gestion de organismos operadores
│ │ ├── projects/
│ │ │ └── ProjectsPage.tsx
│ │ ├── meters/ # Modulo de medidores
│ │ ├── meters/ # Modulo de medidores
│ │ │ ├── MeterPage.tsx
│ │ │ ├── useMeters.ts # Hook personalizado
│ │ │ ├── MetersTable.tsx
│ │ │ ├── MetersModal.tsx
│ │ │ ── MetersSidebar.tsx
│ │ └── concentrators/ # Modulo de concentradores
│ │ ── ConcentratorsPage.tsx
│ │ ├── useConcentrators.ts
│ │ ├── ConcentratorsTable.tsx
│ │ ├── ConcentratorsModal.tsx
│ │ ── ConcentratorsSidebar.tsx
│ │ │ ── MetersSidebar.tsx
│ │ │ ├── MetersBulkUploadModal.tsx
│ │ ── useMeters.ts
│ │ ├── concentrators/ # Modulo de concentradores
│ │ ├── ConcentratorsPage.tsx
│ │ ├── ConcentratorsTable.tsx
│ │ ── ConcentratorsModal.tsx
│ │ │ ├── ConcentratorsSidebar.tsx
│ │ │ └── useConcentrators.ts
│ │ ├── consumption/ # Modulo de consumo
│ │ │ ├── ConsumptionPage.tsx
│ │ │ └── ReadingsBulkUploadModal.tsx
│ │ ├── historico/ # Modulo de historico de tomas
│ │ │ └── HistoricoPage.tsx
│ │ ├── analytics/ # Modulo de analytics
│ │ │ ├── AnalyticsMapPage.tsx
│ │ │ ├── AnalyticsReportsPage.tsx
│ │ │ ├── AnalyticsServerPage.tsx
│ │ │ └── MapComponents.tsx
│ │ └── conectores/ # Conectores externos
│ │ ├── SHMetersPage.tsx
│ │ ├── XMetersPage.tsx
│ │ └── TTSPage.tsx
│ │
│ ├── assets/
│ │ └── images/
│ ├── App.tsx # Componente raiz
── main.tsx # Punto de entrada
│ └── index.css # Estilos globales
│ ├── hooks/
│ │ └── useNotifications.ts
├── App.tsx # Componente raiz (routing + auth)
│ ├── main.tsx # Punto de entrada React
── index.css # Estilos globales (Tailwind)
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
└── .env.example
├── water-api/ # Backend Express API
│ ├── src/
│ │ ├── index.ts # Setup del servidor Express
│ │ ├── config/
│ │ │ ├── index.ts # Carga de configuracion
│ │ │ └── database.ts # Pool de conexiones PostgreSQL
│ │ ├── routes/ # 17 archivos de rutas
│ │ ├── controllers/ # Controladores REST
│ │ ├── services/ # Logica de negocio (18 modulos)
│ │ ├── middleware/
│ │ │ ├── auth.middleware.ts # Verificacion JWT
│ │ │ ├── audit.middleware.ts # Logging de actividad
│ │ │ └── ttsWebhook.middleware.ts
│ │ ├── validators/ # Validacion con Zod
│ │ ├── utils/
│ │ │ ├── jwt.ts # Generacion/verificacion de tokens
│ │ │ ├── password.ts # Wrappers de bcrypt
│ │ │ ├── logger.ts # Configuracion Winston
│ │ │ └── scope.ts # Filtrado por scope de rol
│ │ ├── jobs/
│ │ │ └── negativeFlowDetection.ts # Tarea programada
│ │ └── types/ # Interfaces TypeScript
│ │
│ ├── sql/ # Schema y migraciones
│ │ ├── schema.sql # Schema principal + migraciones (11 tablas + 2 vistas)
│ │ ├── add_audit_logs.sql
│ │ ├── add_notifications.sql
│ │ ├── add_meter_extended_fields.sql
│ │ ├── create_meter_types.sql
│ │ ├── add_meter_project_relation.sql
│ │ ├── add_user_project_relation.sql
│ │ ├── add_organismos_operadores.sql
│ │ └── add_user_meter_fields.sql
│ │
│ ├── package.json
│ ├── tsconfig.json
│ └── .env.example
├── upload-panel/ # App separada para carga CSV
│ ├── src/
│ │ ├── App.tsx
│ │ ├── components/
│ │ │ ├── MetersUpload.tsx
│ │ │ ├── ReadingsUpload.tsx
│ │ │ ├── FileDropzone.tsx
│ │ │ └── ResultsDisplay.tsx
│ │ └── api/upload.ts
│ ├── package.json
│ └── vite.config.ts
├── package.json # Dependencias frontend
├── vite.config.ts # Configuracion Vite
├── tsconfig.json # Configuracion TypeScript
├── index.html # HTML de entrada
├── DOCUMENTATION.md # Documentacion tecnica
├── ESTADO_ACTUAL.md # Estado actual del proyecto
└── CAMBIOS_SESION.md # Historial de cambios
```
---
## Modulos Funcionales
## Base de Datos
### 1. Dashboard (Home)
### Jerarquia de datos
```
Projects → Concentrators → Meters → Readings
→ Gateways → Devices ↗
```
El dashboard principal ofrece:
- Selector de organismos operadores (CESPT TIJUANA, TECATE, MEXICALI)
- Grafico de barras: "Numero de Medidores por Proyecto"
- Tarjetas de acceso rapido: Tomas, Alertas, Mantenimiento, Reportes
- Historial reciente de actividades
- Panel de ultimas alertas
### Tablas principales
### 2. Gestion de Medidores
| Tabla | Descripcion |
|-------|-------------|
| `roles` | Roles del sistema (ADMIN, ORGANISMO_OPERADOR, OPERATOR) con permisos JSONB |
| `users` | Usuarios con email, password hash, rol, proyecto y organismo |
| `organismos_operadores` | Organismos operadores de agua (CESPT Tijuana, Tecate, etc.) |
| `projects` | Proyectos de infraestructura hidrica (vinculados a organismo) |
| `concentrators` | Concentradores de datos vinculados a proyectos |
| `gateways` | Gateways LoRaWAN con integracion TTS |
| `devices` | Dispositivos LoRaWAN (sensores/transmisores) |
| `meters` | Medidores de agua con ubicacion y ultima lectura |
| `meter_readings` | Historial de lecturas con bateria y senal |
| `tts_uplink_logs` | Logs de mensajes uplink de The Things Stack |
| `refresh_tokens` | Tokens de refresco JWT para sesiones |
Modulo completo para administrar medidores/tomas de agua:
**Funcionalidades:**
- Listado con busqueda y filtros
- Filtrado por proyecto
- Tipos de toma: GENERAL, LORA, LORAWAN, GRANDES
- CRUD completo (Crear, Leer, Actualizar, Eliminar)
**Campos principales:**
- Area, Numero de cuenta, Usuario, Direccion
- Serial del medidor, Nombre, Estado
- Tipo de protocolo, Particion DMA
- Configuracion de dispositivo (Device EUI, AppKey, etc.)
### 3. Gestion de Concentradores
Administracion de concentradores y gateways:
**Funcionalidades:**
- Listado con filtros por proyecto
- Configuracion de Gateway (ID, EUI, Nombre)
- Seleccion de ubicacion de antena (Indoor/Outdoor)
- CRUD completo
### 4. Gestion de Proyectos
Administracion de proyectos de infraestructura:
- Tabla con busqueda integrada
- Estados: ACTIVE/INACTIVE
- Informacion de operador y tiempos
### 5. Gestion de Usuarios
Control de usuarios del sistema:
- Listado de usuarios
- Asignacion de roles
- Estados: ACTIVE/INACTIVE
### 6. Gestion de Roles
Administracion de roles de acceso:
- Roles predefinidos: SUPER_ADMIN, USER
- Descripcion de permisos
### Vistas
- `meter_stats_by_project` - Estadisticas agregadas de medidores por proyecto
- `device_status_summary` - Resumen de estados de dispositivos por proyecto
---
## API y Comunicacion
## API Endpoints
### Configuracion
Todos los endpoints estan bajo el prefijo `/api/`. La mayoria requieren autenticacion JWT.
La aplicacion se conecta a una API REST externa. Configurar en `.env`:
```env
VITE_API_BASE_URL=https://tu-api.com
VITE_API_TOKEN=tu-token
```
### Endpoints Principales
| Recurso | Endpoint Base |
|---------|---------------|
| Medidores | `/api/v3/data/.../m4hzpnopjkppaav/records` |
| Concentradores | `/api/v3/data/.../mheif1vdgnyt8x2/records` |
| Proyectos | `/api/v3/data/.../m9882vn3xb31e29/records` |
### Estructura de Respuesta
```typescript
interface ApiResponse<T> {
records: Array<{
id: string;
fields: T;
}>;
next?: string;
prev?: string;
}
```
---
## Modelos de Datos
### Meter (Medidor)
```typescript
interface Meter {
id: string;
createdAt: string;
updatedAt: string;
areaName: string;
accountNumber: string | null;
userName: string | null;
userAddress: string | null;
meterSerialNumber: string;
meterName: string;
meterStatus: string;
protocolType: string;
priceNo: string | null;
priceName: string | null;
dmaPartition: string | null;
supplyTypes: string;
deviceId: string;
deviceName: string;
deviceType: string;
usageAnalysisType: string;
installedTime: string;
}
```
### Concentrator
```typescript
interface Concentrator {
id: string;
"Area Name": string;
"Device S/N": string;
"Device Name": string;
"Device Time": string;
"Device Status": string;
"Operator": string;
"Installed Time": string;
"Communication Time": string;
"Instruction Manual": string;
}
```
### Project
```typescript
interface Project {
id: string;
areaName: string;
deviceSN: string;
deviceName: string;
deviceType: string;
deviceStatus: "ACTIVE" | "INACTIVE";
operator: string;
installedTime: string;
communicationTime: string;
}
```
| Grupo | Prefijo | Descripcion |
|-------|---------|-------------|
| Auth | `/api/auth` | Login, refresh, logout, perfil |
| Organismos | `/api/organismos-operadores` | CRUD de organismos operadores (ADMIN) |
| Projects | `/api/projects` | CRUD de proyectos + estadisticas |
| Meters | `/api/meters` | CRUD de medidores + lecturas historicas |
| Meter Types | `/api/meter-types` | Tipos de medidor |
| Concentrators | `/api/concentrators` | CRUD de concentradores |
| Gateways | `/api/gateways` | CRUD de gateways + dispositivos |
| Devices | `/api/devices` | CRUD de dispositivos LoRaWAN |
| Users | `/api/users` | Gestion de usuarios (admin) |
| Roles | `/api/roles` | Gestion de roles |
| Readings | `/api/readings` | Lecturas y resumen de consumo |
| Notifications | `/api/notifications` | Notificaciones del usuario |
| Audit | `/api/audit-logs` | Logs de auditoria (admin) |
| Bulk Upload | `/api/bulk-upload` | Carga masiva Excel |
| CSV Upload | `/api/csv-upload` | Carga masiva CSV |
| TTS Webhooks | `/api/webhooks/tts` | Webhooks The Things Stack |
| System | `/api/system` | Metricas y salud del servidor (admin) |
---
## Autenticacion
### Flujo de Login
El sistema usa **JWT con refresh tokens**:
1. Usuario ingresa credenciales
2. Validacion del checkbox "No soy un robot"
3. Token almacenado en `localStorage` (`grh_auth`)
4. Redireccion al Dashboard
1. El usuario envia email/password a `POST /api/auth/login`
2. El backend valida credenciales con bcrypt y genera:
- **Access token** (15 minutos)
- **Refresh token** (7 dias)
3. Los tokens se almacenan en `localStorage`
4. El cliente HTTP envia el access token en `Authorization: Bearer <token>`
5. Al expirar, el cliente automaticamente llama a `POST /api/auth/refresh`
### Almacenamiento
```javascript
// localStorage keys
grh_auth: { token: string, ts: number }
water_project_settings_v1: { theme: string, compactMode: boolean }
```
---
## Configuracion de Temas
El sistema soporta tres modos de tema:
- **Sistema** - Detecta preferencia del OS
- **Claro** - Tema light
- **Oscuro** - Tema dark
Configuracion persistida en `localStorage` bajo `water_project_settings_v1`.
### Roles y permisos (Jerarquia de 3 niveles)
| Rol | Descripcion | Scope |
|-----|-------------|-------|
| `ADMIN` | Acceso completo al sistema | Ve todos los datos |
| `ORGANISMO_OPERADOR` | Gestiona proyectos de su organismo | Ve datos de proyectos de su organismo |
| `OPERATOR` | Opera medidores de su proyecto | Ve datos de su proyecto asignado |
---
## Despliegue
### Build de Produccion
### Build de produccion
```bash
npm run build
# Frontend
npm run build # Genera dist/
# Backend
cd water-api
npm run build # Genera dist/
npm run start # Ejecuta el build
```
Los archivos compilados se generan en la carpeta `dist/`.
### Configuracion de Vite
El servidor de desarrollo esta configurado para:
- Puerto: 5173
- Host: habilitado para acceso remoto
- Hosts permitidos: localhost, 127.0.0.1, dominios personalizados
### URLs de produccion
- **Frontend:** `https://sistema.gestionrecursoshidricos.com`
- **Backend:** `https://api.gestionrecursoshidricos.com`
---
## Contribucion
## Repositorios
1. Fork del repositorio
2. Crear rama feature (`git checkout -b feature/nueva-funcionalidad`)
3. Commit de cambios (`git commit -m 'Agregar nueva funcionalidad'`)
4. Push a la rama (`git push origin feature/nueva-funcionalidad`)
5. Crear Pull Request
| Remote | URL |
|--------|-----|
| Gitea | `https://git.consultoria-as.com/consultoria-as/GRH` |
| GitHub | `git@github.com:luanngel/water-project.git` |
---
## Licencia
Este proyecto es privado y pertenece a GRH - Gestion de Recursos Hidricos.
---
## Contacto
Para soporte o consultas sobre el sistema, contactar al equipo de desarrollo.
Este proyecto es privado y pertenece a GRH - Gestion de Recursos Hidricos / Consultoria AS.

652
docs/API.md Normal file
View File

@@ -0,0 +1,652 @@
# Documentacion API
## Informacion General
- **URL Base**: `https://api.gestionrecursoshidricos.com/api`
- **Formato**: JSON
- **Autenticacion**: Bearer Token (JWT)
## Autenticacion
### Login
```http
POST /auth/login
Content-Type: application/json
{
"email": "usuario@ejemplo.com",
"password": "contraseña"
}
```
**Respuesta exitosa:**
```json
{
"success": true,
"data": {
"user": {
"id": "uuid",
"email": "usuario@ejemplo.com",
"name": "Nombre",
"role": "ADMIN"
},
"accessToken": "jwt_token",
"refreshToken": "refresh_token"
}
}
```
### Refresh Token
```http
POST /auth/refresh
Content-Type: application/json
{
"refreshToken": "refresh_token"
}
```
### Logout
```http
POST /auth/logout
Authorization: Bearer {accessToken}
```
### Obtener Perfil
```http
GET /auth/me
Authorization: Bearer {accessToken}
```
---
## Proyectos
### Listar Proyectos
```http
GET /projects?page=1&pageSize=10
Authorization: Bearer {accessToken}
```
**Parametros de consulta:**
| Parametro | Tipo | Descripcion |
|-----------|------|-------------|
| page | number | Numero de pagina (default: 1) |
| pageSize | number | Registros por pagina (default: 10) |
| status | string | Filtrar por estado (ACTIVE, INACTIVE) |
| search | string | Buscar por nombre |
### Obtener Proyecto
```http
GET /projects/:id
Authorization: Bearer {accessToken}
```
### Estadisticas del Proyecto
```http
GET /projects/:id/stats
Authorization: Bearer {accessToken}
```
### Crear Proyecto
```http
POST /projects
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"name": "Nombre del Proyecto",
"description": "Descripcion",
"area_name": "Nombre del Area",
"location": "Ubicacion",
"meter_type_id": "uuid-tipo-medidor"
}
```
### Actualizar Proyecto
```http
PUT /projects/:id
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"name": "Nuevo Nombre",
"status": "ACTIVE"
}
```
### Eliminar Proyecto
```http
DELETE /projects/:id
Authorization: Bearer {accessToken}
```
*Requiere rol ADMIN*
---
## Concentradores
### Listar Concentradores
```http
GET /concentrators?project_id=uuid
Authorization: Bearer {accessToken}
```
**Parametros de consulta:**
| Parametro | Tipo | Descripcion |
|-----------|------|-------------|
| project_id | uuid | Filtrar por proyecto |
| status | string | Filtrar por estado |
| search | string | Buscar por serial o nombre |
### Obtener Concentrador
```http
GET /concentrators/:id
Authorization: Bearer {accessToken}
```
### Crear Concentrador
```http
POST /concentrators
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"serial_number": "CONC001",
"name": "Concentrador Principal",
"project_id": "uuid-proyecto",
"location": "Ubicacion",
"ip_address": "192.168.1.100"
}
```
### Actualizar Concentrador
```http
PUT /concentrators/:id
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"name": "Nuevo Nombre",
"status": "ACTIVE"
}
```
### Eliminar Concentrador
```http
DELETE /concentrators/:id
Authorization: Bearer {accessToken}
```
---
## Medidores
### Listar Medidores
```http
GET /meters?page=1&pageSize=50
Authorization: Bearer {accessToken}
```
*Resultados filtrados automaticamente por scope del usuario (ADMIN ve todos, ORGANISMO ve su organismo, OPERATOR ve su proyecto)*
**Parametros de consulta:**
| Parametro | Tipo | Descripcion |
|-----------|------|-------------|
| page | number | Numero de pagina |
| pageSize | number | Registros por pagina |
| project_id | uuid | Filtrar por proyecto |
| concentrator_id | uuid | Filtrar por concentrador |
| status | string | ACTIVE, INACTIVE, OFFLINE, MAINTENANCE, ERROR |
| type | string | LORA, LORAWAN, GRANDES CONSUMIDORES |
| search | string | Buscar por serial o nombre |
### Obtener Medidor
```http
GET /meters/:id
Authorization: Bearer {accessToken}
```
### Lecturas del Medidor
```http
GET /meters/:id/readings?page=1&pageSize=50
Authorization: Bearer {accessToken}
```
*Resultados filtrados por scope del usuario*
**Parametros de consulta:**
| Parametro | Tipo | Descripcion |
|-----------|------|-------------|
| page | number | Numero de pagina |
| pageSize | number | Registros por pagina (max 100) |
| start_date | date | Fecha inicio (YYYY-MM-DD) |
| end_date | date | Fecha fin (YYYY-MM-DD) |
**Respuesta:**
```json
{
"success": true,
"data": [
{
"id": "uuid",
"meter_id": "uuid",
"reading_value": 1234.56,
"reading_type": "AUTOMATIC",
"battery_level": 85,
"signal_strength": -45,
"received_at": "2024-01-20T10:30:00Z",
"meter_serial_number": "MED001",
"meter_name": "Medidor 001",
"project_id": "uuid",
"project_name": "ADAMANT"
}
],
"pagination": {
"page": 1,
"pageSize": 50,
"total": 150,
"totalPages": 3
}
}
```
### Crear Medidor
```http
POST /meters
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"serial_number": "MED001",
"name": "Medidor 001",
"concentrator_id": "uuid-concentrador",
"project_id": "uuid-proyecto",
"area_name": "Zona A",
"location": "Depto 101",
"type": "LORA",
"status": "ACTIVE",
"installation_date": "2024-01-15"
}
```
### Actualizar Medidor
```http
PUT /meters/:id
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"name": "Nuevo Nombre",
"status": "MAINTENANCE"
}
```
### Actualizar Parcial (PATCH)
```http
PATCH /meters/:id
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"status": "ACTIVE"
}
```
### Eliminar Medidor
```http
DELETE /meters/:id
Authorization: Bearer {accessToken}
```
*Requiere rol ADMIN*
---
## Lecturas
### Listar Lecturas
```http
GET /readings?page=1&pageSize=50
```
**Parametros de consulta:**
| Parametro | Tipo | Descripcion |
|-----------|------|-------------|
| meter_id | uuid | Filtrar por medidor |
| project_id | uuid | Filtrar por proyecto |
| concentrator_id | uuid | Filtrar por concentrador |
| start_date | date | Fecha inicio (YYYY-MM-DD) |
| end_date | date | Fecha fin (YYYY-MM-DD) |
| reading_type | string | AUTOMATIC, MANUAL, SCHEDULED |
### Resumen de Consumo
```http
GET /readings/summary?project_id=uuid
```
**Respuesta:**
```json
{
"success": true,
"data": {
"totalReadings": 1500,
"totalMeters": 50,
"avgReading": 125.5,
"lastReadingDate": "2024-01-20T10:30:00Z"
}
}
```
### Crear Lectura
```http
POST /readings
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"meter_id": "uuid-medidor",
"reading_value": 1234.56,
"reading_type": "MANUAL",
"battery_level": 85,
"signal_strength": -45,
"received_at": "2024-01-20T10:30:00Z"
}
```
### Eliminar Lectura
```http
DELETE /readings/:id
Authorization: Bearer {accessToken}
```
*Requiere rol ADMIN*
---
## Carga CSV (Sin Autenticacion)
### Subir Medidores CSV
```http
POST /csv-upload/meters
Content-Type: multipart/form-data
file: archivo.csv
```
**Formato CSV:**
```csv
serial_number,name,concentrator_serial,area_name,location,meter_type,status,installation_date
MED001,Medidor 1,CONC001,Zona A,Depto 101,LORA,ACTIVE,2024-01-15
```
**Respuesta:**
```json
{
"success": true,
"message": "Procesamiento completado: 10 insertados, 5 actualizados, 2 errores",
"data": {
"total": 17,
"inserted": 10,
"updated": 5,
"errors": [
{
"row": 15,
"field": "concentrator_serial",
"message": "Concentrador no encontrado"
}
]
}
}
```
### Subir Lecturas CSV
```http
POST /csv-upload/readings
Content-Type: multipart/form-data
file: archivo.csv
```
**Formato CSV:**
```csv
meter_serial,reading_value,received_at,reading_type,battery_level,signal_strength
MED001,1234.56,2024-01-20 10:30:00,MANUAL,85,-45
```
### Descargar Plantilla Medidores
```http
GET /csv-upload/meters/template
```
### Descargar Plantilla Lecturas
```http
GET /csv-upload/readings/template
```
---
## Organismos Operadores
### Listar Organismos
```http
GET /organismos-operadores
Authorization: Bearer {accessToken}
```
*Requiere rol ADMIN*
### Obtener Organismo
```http
GET /organismos-operadores/:id
Authorization: Bearer {accessToken}
```
### Crear Organismo
```http
POST /organismos-operadores
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"name": "CESPT Tijuana",
"code": "CESPT-TJ",
"contact_name": "Juan Perez",
"contact_email": "juan@cespt.gob.mx",
"contact_phone": "664-123-4567",
"is_active": true
}
```
*Requiere rol ADMIN*
### Actualizar Organismo
```http
PUT /organismos-operadores/:id
Authorization: Bearer {accessToken}
Content-Type: application/json
```
### Eliminar Organismo
```http
DELETE /organismos-operadores/:id
Authorization: Bearer {accessToken}
```
*Requiere rol ADMIN*
---
## Usuarios
### Listar Usuarios
```http
GET /users
Authorization: Bearer {accessToken}
```
*Requiere rol ADMIN o ORGANISMO_OPERADOR. Resultados filtrados por scope.*
### Crear Usuario
```http
POST /users
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"email": "nuevo@ejemplo.com",
"password": "contraseña123",
"name": "Nombre Usuario",
"role_id": "uuid-rol",
"project_id": "uuid-proyecto",
"organismo_operador_id": "uuid-organismo"
}
```
*Requiere rol ADMIN o ORGANISMO_OPERADOR*
### Actualizar Usuario
```http
PUT /users/:id
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"name": "Nuevo Nombre",
"is_active": true
}
```
### Cambiar Contraseña
```http
PUT /users/:id/password
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"currentPassword": "actual",
"newPassword": "nueva123"
}
```
---
## Roles
### Listar Roles
```http
GET /roles
Authorization: Bearer {accessToken}
```
**Roles disponibles (jerarquia de 3 niveles):**
| Rol | Descripcion | Scope |
|-----|-------------|-------|
| ADMIN | Acceso completo al sistema | Ve todos los datos |
| ORGANISMO_OPERADOR | Gestiona proyectos de su organismo | Ve datos de proyectos de su organismo |
| OPERATOR | Opera medidores de su proyecto | Ve datos de su proyecto asignado |
---
## Notificaciones
### Listar Notificaciones
```http
GET /notifications?page=1&pageSize=20
Authorization: Bearer {accessToken}
```
### Contador No Leidas
```http
GET /notifications/unread-count
Authorization: Bearer {accessToken}
```
### Marcar como Leida
```http
PATCH /notifications/:id/read
Authorization: Bearer {accessToken}
```
### Marcar Todas como Leidas
```http
PATCH /notifications/read-all
Authorization: Bearer {accessToken}
```
---
## Auditoria
### Listar Logs de Auditoria
```http
GET /audit-logs?page=1&pageSize=50
Authorization: Bearer {accessToken}
```
*Requiere rol ADMIN*
**Parametros de consulta:**
| Parametro | Tipo | Descripcion |
|-----------|------|-------------|
| user_id | uuid | Filtrar por usuario |
| action | string | CREATE, UPDATE, DELETE, LOGIN, etc. |
| table_name | string | Filtrar por tabla |
| start_date | date | Fecha inicio |
| end_date | date | Fecha fin |
### Mi Actividad
```http
GET /audit-logs/my-activity
Authorization: Bearer {accessToken}
```
---
## Webhooks TTS (The Things Stack)
### Health Check
```http
GET /webhooks/tts/health
```
### Uplink (Datos de Dispositivos)
```http
POST /webhooks/tts/uplink
X-Downlink-Apikey: {webhook_secret}
Content-Type: application/json
{
"end_device_ids": {
"device_id": "device-001",
"dev_eui": "0004A30B001C1234"
},
"uplink_message": {
"decoded_payload": {
"reading": 1234.56,
"battery": 85
}
}
}
```
### Join Accept
```http
POST /webhooks/tts/join
X-Downlink-Apikey: {webhook_secret}
```
---
## Codigos de Respuesta
| Codigo | Descripcion |
|--------|-------------|
| 200 | Exito |
| 201 | Creado exitosamente |
| 400 | Error en la solicitud |
| 401 | No autorizado |
| 403 | Prohibido (sin permisos) |
| 404 | No encontrado |
| 409 | Conflicto (duplicado) |
| 500 | Error interno del servidor |
## Formato de Error
```json
{
"success": false,
"message": "Descripcion del error",
"error": "CODIGO_ERROR"
}
```

556
docs/ARQUITECTURA.md Normal file
View File

@@ -0,0 +1,556 @@
# Arquitectura y Base de Datos
## Arquitectura General
### Diagrama de Componentes
```
┌─────────────────────────────────────────────────────────────────────────┐
│ CLIENTES │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Navegador Web │ │ Navegador Web │ │ Dispositivos │ │
│ │ (App Principal)│ │ (Panel Carga) │ │ LoRaWAN │ │
│ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │ │
└────────────┼───────────────────────┼───────────────────────┼────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ CAPA DE PRESENTACION │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ NGINX (Reverse Proxy) │ │
│ │ - SSL/TLS Termination │ │
│ │ - Load Balancing │ │
│ │ - Static File Serving │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ Frontend React │ │ Upload Panel │ │
│ │ (sistema.grh.com) │ │ (panel.grh.com) │ │
│ │ Puerto: 5173 │ │ Puerto: 5174 │ │
│ └────────────────────┘ └────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ CAPA DE SERVICIOS │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Express.js API Server │ │
│ │ (api.grh.com - Puerto 3000) │ │
│ ├──────────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Routes │ │ Controllers │ │ Services │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ - auth │ │ - auth │ │ - auth │ │ │
│ │ │ - projects │ │ - project │ │ - project │ │ │
│ │ │ - meters │ │ - meter │ │ - meter │ │ │
│ │ │ - readings │ │ - reading │ │ - reading │ │ │
│ │ │ - users │ │ - user │ │ - user │ │ │
│ │ │ - organismos│ │ - organismo │ │ - organismo │ │ │
│ │ │ - csv-upload│ │ - etc... │ │ - csv-upload│ │ │
│ │ │ - webhooks │ │ │ │ - tts │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Middleware │ │ Validators │ │ Jobs │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ - auth │ │ - zod │ │ - cron │ │ │
│ │ │ - audit │ │ - schemas │ │ - negative │ │ │
│ │ │ - tts verify│ │ │ │ flow │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ CAPA DE DATOS │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ PostgreSQL Database │ │
│ │ Puerto: 5432 │ │
│ ├──────────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ users │ │ projects │ │concentrators│ │ │
│ │ │ roles │ │ gateways │ │ meters │ │ │
│ │ │ organismos │ │ │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ readings │ │ devices │ │ audit_logs │ │ │
│ │ │ (meter_ │ │ tts_uplink │ │notifications│ │ │
│ │ │ readings) │ │ _logs │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ SERVICIOS EXTERNOS │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ The Things Stack (TTS) │ │
│ │ Plataforma LoRaWAN │ │
│ ├──────────────────────────────────────────────────────────────────┤ │
│ │ - Recepcion de uplinks de dispositivos │ │
│ │ - Gestion de dispositivos LoRaWAN │ │
│ │ - Webhooks hacia la API │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## Modelo de Datos
### Diagrama Entidad-Relacion
```
┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ roles │ │ users │ │ projects │
├─────────────┤ ├─────────────────┤ ├─────────────────┤
│ id (PK) │──┐ │ id (PK) │ ┌──│ id (PK) │
│ name │ └───▶│ role_id (FK) │ │ │ name │
│ description │ │ project_id (FK) │◀───┤ │ description │
│ permissions │ │ organismo_op_id │──┐ │ │ area_name │
└─────────────┘ │ email │ │ │ │ status │
│ password_hash │ │ │ │ organismo_op_id │──┐
│ name │ │ │ │ created_by (FK) │──▶ users
│ is_active │ │ │ │ meter_type_id │──▶ meter_types
└─────────────────┘ │ │ └─────────────────┘ │
│ │ │
┌─────────────────┐ │ │ │
│ organismos_ │◀─┘─┼───────────────────────┘
│ operadores │ │
├─────────────────┤ │
│ id (PK) │ │
│ name │ │
│ code │ │
│ contact_name │ │
│ contact_email │ │
│ is_active │ │
└─────────────────┘ │
│ │
│ │
┌─────────────────┐ │ │
│ concentrators │◀───┘ │
├─────────────────┤ │
│ id (PK) │ │
│ serial_number │ │
│ name │ │
│ project_id (FK) │◀───────────────┘
│ status │
│ ip_address │
└─────────────────┘
┌────────┴────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ gateways │ │ meters │
├─────────────────┤ ├─────────────────┤
│ id (PK) │ │ id (PK) │
│ gateway_id │ │ serial_number │
│ name │ │ name │
│ project_id (FK) │ │ project_id (FK) │
│ concentrator_id │ │ concentrator_id │
│ status │ │ device_id (FK) │──▶ devices
└─────────────────┘ │ type │
│ │ status │
│ │ last_reading │
▼ └─────────────────┘
┌─────────────────┐ │
│ devices │ │
├─────────────────┤ ▼
│ id (PK) │ ┌─────────────────┐
│ dev_eui │ │ meter_readings │
│ name │ ├─────────────────┤
│ project_id (FK) │ │ id (PK) │
│ gateway_id (FK) │ │ meter_id (FK) │
│ status │ │ reading_value │
└─────────────────┘ │ reading_type │
│ │ battery_level │
│ │ signal_strength │
▼ │ received_at │
┌─────────────────┐ └─────────────────┘
│ tts_uplink_logs │
├─────────────────┤
│ id (PK) │
│ device_id (FK) │
│ raw_payload │
│ decoded_payload │
│ processed │
└─────────────────┘
## Scope Filtering (Control de Acceso por Datos)
Todos los servicios del backend aplican filtrado automatico basado en el rol del usuario autenticado:
```
┌──────────────────────────────────────────────────────────┐
│ Scope Filtering │
├──────────────────────────────────────────────────────────┤
│ │
│ ADMIN (roleName = 'ADMIN') │
│ └── Sin filtro, ve TODOS los registros │
│ │
│ ORGANISMO_OPERADOR (organismoOperadorId = X) │
│ └── WHERE project_id IN ( │
│ SELECT id FROM projects │
│ WHERE organismo_operador_id = X │
│ ) │
│ │
│ OPERATOR (projectId = Y) │
│ └── WHERE project_id = Y │
│ │
├──────────────────────────────────────────────────────────┤
│ Utility: water-api/src/utils/scope.ts │
│ Se aplica en: meter, reading, project, user, │
│ concentrator, notification services │
└──────────────────────────────────────────────────────────┘
```
---
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ audit_logs │ │ notifications │ │ meter_types │
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ id (PK) │ │ id (PK) │ │ id (PK) │
│ user_id (FK) │ │ user_id (FK) │ │ name │
│ action │ │ meter_id (FK) │ │ code │
│ table_name │ │ type │ │ description │
│ record_id │ │ title │ │ is_active │
│ old_values │ │ message │ └─────────────────┘
│ new_values │ │ is_read │
└─────────────────┘ └─────────────────┘
```
---
## Descripcion de Tablas
### Tablas de Autenticacion y Usuarios
#### `roles`
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | UUID | Identificador unico |
| name | ENUM | ADMIN, ORGANISMO_OPERADOR, OPERATOR |
| description | TEXT | Descripcion del rol |
| permissions | JSONB | Permisos detallados |
#### `users`
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | UUID | Identificador unico |
| email | VARCHAR | Email unico (login) |
| password_hash | VARCHAR | Hash bcrypt de contraseña |
| name | VARCHAR | Nombre completo |
| role_id | UUID FK | Rol asignado |
| project_id | UUID FK | Proyecto asignado (OPERATOR) |
| organismo_operador_id | UUID FK | Organismo asignado (ORGANISMO_OPERADOR) |
| is_active | BOOLEAN | Estado de la cuenta |
| last_login | TIMESTAMP | Ultimo acceso |
#### `organismos_operadores`
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | UUID | Identificador unico |
| name | VARCHAR | Nombre del organismo |
| code | VARCHAR | Codigo unico (ej: CESPT-TJ) |
| contact_name | VARCHAR | Nombre del contacto |
| contact_email | VARCHAR | Email de contacto |
| contact_phone | VARCHAR | Telefono de contacto |
| is_active | BOOLEAN | Estado activo |
| created_at | TIMESTAMP | Fecha de creacion |
| updated_at | TIMESTAMP | Fecha de actualizacion |
#### `refresh_tokens`
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | UUID | Identificador unico |
| user_id | UUID FK | Usuario propietario |
| token_hash | VARCHAR | Hash del token |
| expires_at | TIMESTAMP | Fecha de expiracion |
| revoked_at | TIMESTAMP | Fecha de revocacion |
---
### Tablas de Estructura
#### `projects`
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | UUID | Identificador unico |
| name | VARCHAR | Nombre del proyecto |
| description | TEXT | Descripcion |
| area_name | VARCHAR | Nombre del area |
| location | TEXT | Ubicacion |
| status | ENUM | ACTIVE, INACTIVE, COMPLETED |
| organismo_operador_id | UUID FK | Organismo operador propietario |
| meter_type_id | UUID FK | Tipo de medidor por defecto |
| created_by | UUID FK | Usuario creador |
#### `concentrators`
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | UUID | Identificador unico |
| serial_number | VARCHAR | Numero de serie unico |
| name | VARCHAR | Nombre descriptivo |
| project_id | UUID FK | Proyecto asociado |
| location | TEXT | Ubicacion fisica |
| status | ENUM | Estado del concentrador |
| ip_address | VARCHAR | Direccion IP |
| firmware_version | VARCHAR | Version de firmware |
| last_communication | TIMESTAMP | Ultima comunicacion |
#### `gateways`
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | UUID | Identificador unico |
| gateway_id | VARCHAR | ID unico del gateway |
| name | VARCHAR | Nombre descriptivo |
| project_id | UUID FK | Proyecto asociado |
| concentrator_id | UUID FK | Concentrador asociado |
| location | TEXT | Ubicacion |
| status | ENUM | Estado |
| tts_gateway_id | VARCHAR | ID en The Things Stack |
---
### Tablas de Medicion
#### `meters`
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | UUID | Identificador unico |
| serial_number | VARCHAR | Numero de serie unico |
| name | VARCHAR | Nombre descriptivo |
| project_id | UUID FK | Proyecto asociado |
| concentrator_id | UUID FK | Concentrador asociado |
| device_id | UUID FK | Dispositivo LoRaWAN asociado |
| area_name | VARCHAR | Nombre del area |
| location | TEXT | Ubicacion especifica |
| type | VARCHAR | LORA, LORAWAN, GRANDES CONSUMIDORES |
| status | ENUM | ACTIVE, INACTIVE, OFFLINE, MAINTENANCE, ERROR |
| last_reading_value | NUMERIC | Ultima lectura registrada |
| last_reading_at | TIMESTAMP | Fecha de ultima lectura |
| installation_date | DATE | Fecha de instalacion |
**Campos extendidos:**
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| protocol | VARCHAR | Protocolo de comunicacion |
| mac | VARCHAR | Direccion MAC |
| voltage | DECIMAL | Voltaje |
| signal | INTEGER | Intensidad de senal |
| leakage_status | VARCHAR | Estado de fuga |
| burst_status | VARCHAR | Estado de ruptura |
| current_flow | DECIMAL | Flujo actual |
| latitude | DECIMAL | Latitud GPS |
| longitude | DECIMAL | Longitud GPS |
| data | JSONB | Datos adicionales flexibles |
#### `meter_readings`
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | UUID | Identificador unico |
| meter_id | UUID FK | Medidor asociado |
| device_id | UUID FK | Dispositivo origen |
| reading_value | NUMERIC | Valor de la lectura |
| reading_type | ENUM | AUTOMATIC, MANUAL, SCHEDULED |
| battery_level | SMALLINT | Nivel de bateria (0-100) |
| signal_strength | SMALLINT | Intensidad de senal (dBm) |
| raw_payload | TEXT | Payload crudo del dispositivo |
| received_at | TIMESTAMP | Fecha/hora de recepcion |
#### `meter_types`
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | UUID | Identificador unico |
| name | VARCHAR | Nombre del tipo |
| code | VARCHAR | Codigo unico |
| description | TEXT | Descripcion |
| is_active | BOOLEAN | Estado activo |
---
### Tablas de IoT (The Things Stack)
#### `devices`
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | UUID | Identificador unico |
| dev_eui | VARCHAR | DevEUI unico del dispositivo |
| name | VARCHAR | Nombre descriptivo |
| device_type | VARCHAR | Tipo de dispositivo |
| project_id | UUID FK | Proyecto asociado |
| gateway_id | UUID FK | Gateway asociado |
| status | ENUM | Estado del dispositivo |
| tts_device_id | VARCHAR | ID en TTS |
| tts_status | VARCHAR | Estado en TTS |
| app_key | VARCHAR | Application Key |
| join_eui | VARCHAR | Join EUI |
#### `tts_uplink_logs`
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | UUID | Identificador unico |
| device_id | UUID FK | Dispositivo origen |
| dev_eui | VARCHAR | DevEUI |
| raw_payload | JSONB | Payload completo |
| decoded_payload | JSONB | Payload decodificado |
| gateway_ids | TEXT[] | IDs de gateways |
| rssi | INTEGER | RSSI |
| snr | FLOAT | SNR |
| processed | BOOLEAN | Indica si fue procesado |
| error_message | TEXT | Mensaje de error si aplica |
---
### Tablas de Sistema
#### `audit_logs`
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | UUID | Identificador unico |
| user_id | UUID FK | Usuario que realizo la accion |
| user_email | VARCHAR | Email del usuario |
| user_name | VARCHAR | Nombre del usuario |
| action | ENUM | CREATE, UPDATE, DELETE, LOGIN, etc. |
| table_name | VARCHAR | Tabla afectada |
| record_id | UUID | ID del registro afectado |
| old_values | JSONB | Valores anteriores |
| new_values | JSONB | Valores nuevos |
| description | TEXT | Descripcion de la accion |
| ip_address | VARCHAR | IP del cliente |
| user_agent | TEXT | User Agent del navegador |
| success | BOOLEAN | Resultado de la operacion |
| error_message | TEXT | Mensaje de error si fallo |
#### `notifications`
| Campo | Tipo | Descripcion |
|-------|------|-------------|
| id | UUID | Identificador unico |
| user_id | UUID FK | Usuario destinatario |
| meter_id | UUID FK | Medidor relacionado (opcional) |
| notification_type | ENUM | NEGATIVE_FLOW, SYSTEM_ALERT, MAINTENANCE |
| title | VARCHAR | Titulo de la notificacion |
| message | TEXT | Mensaje detallado |
| meter_serial_number | VARCHAR | Serial del medidor (si aplica) |
| flow_value | DECIMAL | Valor de flujo (si aplica) |
| is_read | BOOLEAN | Estado de lectura |
| read_at | TIMESTAMP | Fecha de lectura |
---
## Indices
### Indices Principales
```sql
-- Meters
CREATE INDEX idx_meters_serial_number ON meters(serial_number);
CREATE INDEX idx_meters_project_id ON meters(project_id);
CREATE INDEX idx_meters_concentrator_id ON meters(concentrator_id);
CREATE INDEX idx_meters_status ON meters(status);
CREATE INDEX idx_meters_type ON meters(type);
-- Readings
CREATE INDEX idx_meter_readings_meter_id ON meter_readings(meter_id);
CREATE INDEX idx_meter_readings_received_at ON meter_readings(received_at);
CREATE INDEX idx_meter_readings_meter_id_received_at ON meter_readings(meter_id, received_at);
-- Devices
CREATE INDEX idx_devices_dev_eui ON devices(dev_eui);
CREATE INDEX idx_devices_project_id ON devices(project_id);
-- Audit
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at);
CREATE INDEX idx_audit_logs_table_name ON audit_logs(table_name);
-- Notifications
CREATE INDEX idx_notifications_user_id ON notifications(user_id);
CREATE INDEX idx_notifications_is_read ON notifications(is_read);
```
---
## Flujo de Datos
### Flujo de Lectura Automatica (TTS)
```
1. Dispositivo LoRaWAN envia uplink
2. The Things Stack recibe el mensaje
3. TTS envia webhook a /api/webhooks/tts/uplink
4. API verifica firma del webhook (X-Downlink-Apikey)
5. API guarda en tts_uplink_logs
6. API busca device por dev_eui
7. API busca meter asociado al device
8. API crea registro en meter_readings
9. API actualiza last_reading en meters
10. Job de deteccion de flujo negativo evalua la lectura
11. Si detecta anomalia, crea notification
```
### Flujo de Carga CSV
```
1. Usuario sube archivo CSV
2. API parsea el CSV
3. Por cada fila:
├─▶ Validar campos requeridos
├─▶ Buscar concentrador por serial
├─▶ Si meter existe: UPDATE
│ Si no existe: INSERT
└─▶ Registrar resultado (exito/error)
4. Retornar resumen de procesamiento
```

394
docs/INSTALACION.md Normal file
View File

@@ -0,0 +1,394 @@
# Guia de Instalacion
## Requisitos Previos
### Software Requerido
- **Node.js** 18.x o superior
- **npm** 9.x o superior
- **PostgreSQL** 14.x o superior
- **Git**
### Puertos Utilizados
| Servicio | Puerto |
|----------|--------|
| Frontend Principal | 5173 |
| Panel de Carga CSV | 5174 |
| Backend API | 3000 |
| PostgreSQL | 5432 |
---
## Instalacion del Backend (water-api)
### 1. Clonar el Repositorio
```bash
git clone https://git.consultoria-as.com/consultoria-as/water-project.git
cd water-project
```
### 2. Configurar la Base de Datos
#### Crear la base de datos:
```bash
sudo -u postgres psql
```
```sql
CREATE DATABASE water_project;
CREATE USER water_user WITH PASSWORD 'tu_password_seguro';
GRANT ALL PRIVILEGES ON DATABASE water_project TO water_user;
\q
```
#### Ejecutar los scripts SQL:
```bash
cd water-api/sql
psql -U water_user -d water_project -f schema.sql
psql -U water_user -d water_project -f add_audit_logs.sql
psql -U water_user -d water_project -f add_notifications.sql
psql -U water_user -d water_project -f add_meter_extended_fields.sql
psql -U water_user -d water_project -f add_meter_project_relation.sql
psql -U water_user -d water_project -f add_meter_types.sql
psql -U water_user -d water_project -f add_organismos_operadores.sql
psql -U water_user -d water_project -f add_user_meter_fields.sql
```
**Nota:** `add_organismos_operadores.sql` crea la tabla `organismos_operadores`, agrega columnas FK en `projects` y `users`, y agrega el rol `ORGANISMO_OPERADOR`. `add_user_meter_fields.sql` agrega campos como `cespt_account` y `cadastral_key`.
### 3. Configurar Variables de Entorno
```bash
cd water-api
cp .env.example .env
```
Editar `.env`:
```env
# Server
PORT=3000
NODE_ENV=production
# Database
DB_HOST=localhost
DB_PORT=5432
DB_NAME=water_project
DB_USER=water_user
DB_PASSWORD=tu_password_seguro
# JWT (generar claves seguras)
JWT_ACCESS_SECRET=clave_secreta_acceso_minimo_32_caracteres
JWT_REFRESH_SECRET=clave_secreta_refresh_minimo_32_caracteres
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# CORS (URLs del frontend separadas por coma)
CORS_ORIGIN=http://localhost:5173,http://localhost:5174,https://sistema.gestionrecursoshidricos.com,https://panel.gestionrecursoshidricos.com
# TTS (The Things Stack) - Opcional
TTS_ENABLED=false
TTS_BASE_URL=https://your-tts-server.com
TTS_APPLICATION_ID=your-app-id
TTS_API_KEY=your-api-key
TTS_WEBHOOK_SECRET=your-webhook-secret
```
### 4. Instalar Dependencias y Ejecutar
```bash
npm install
npm run dev # Desarrollo con hot-reload
# o
npm run build # Compilar para produccion
npm start # Ejecutar version compilada
```
### 5. Verificar Instalacion
```bash
curl http://localhost:3000/health
```
Respuesta esperada:
```json
{
"status": "ok",
"timestamp": "2024-01-20T10:30:00.000Z",
"environment": "production"
}
```
---
## Instalacion del Frontend Principal
### 1. Configurar Variables de Entorno
```bash
cd /path/to/water-project
cp .env.example .env
```
Editar `.env`:
```env
VITE_API_BASE_URL=http://localhost:3000
```
Para produccion:
```env
VITE_API_BASE_URL=https://api.gestionrecursoshidricos.com
```
### 2. Instalar Dependencias y Ejecutar
```bash
npm install
npm run dev # Desarrollo
# o
npm run build # Compilar para produccion
npm run preview # Vista previa de produccion
```
### 3. Verificar Instalacion
Abrir en el navegador: http://localhost:5173
---
## Instalacion del Panel de Carga CSV
### 1. Configurar Variables de Entorno
```bash
cd upload-panel
cp .env.example .env # Si existe, o crear manualmente
```
Crear `.env`:
```env
VITE_API_URL=http://localhost:3000/api
```
Para produccion:
```env
VITE_API_URL=https://api.gestionrecursoshidricos.com/api
```
### 2. Instalar Dependencias y Ejecutar
```bash
npm install
npm run dev # Desarrollo (puerto 5174)
# o
npm run build # Compilar para produccion
```
### 3. Verificar Instalacion
Abrir en el navegador: http://localhost:5174
---
## Despliegue en Produccion
### Opcion 1: PM2 (Recomendado)
#### Instalar PM2:
```bash
npm install -g pm2
```
#### Configurar ecosystem.config.js:
```javascript
module.exports = {
apps: [
{
name: 'water-api',
cwd: '/path/to/water-project/water-api',
script: 'npm',
args: 'start',
env: {
NODE_ENV: 'production',
PORT: 3000
}
}
]
};
```
#### Iniciar con PM2:
```bash
pm2 start ecosystem.config.js
pm2 save
pm2 startup # Configurar inicio automatico
```
### Opcion 2: Systemd Service
Crear `/etc/systemd/system/water-api.service`:
```ini
[Unit]
Description=Water API Server
After=network.target postgresql.service
[Service]
Type=simple
User=www-data
WorkingDirectory=/path/to/water-project/water-api
ExecStart=/usr/bin/node dist/index.js
Restart=on-failure
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable water-api
sudo systemctl start water-api
```
### Configurar Nginx (Reverse Proxy)
```nginx
# /etc/nginx/sites-available/water-project
# API
server {
listen 443 ssl;
server_name api.gestionrecursoshidricos.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_bypass $http_upgrade;
}
}
# Frontend Principal
server {
listen 443 ssl;
server_name sistema.gestionrecursoshidricos.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
root /path/to/water-project/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
# Panel de Carga
server {
listen 443 ssl;
server_name panel.gestionrecursoshidricos.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
root /path/to/water-project/upload-panel/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
```
---
## Crear Usuario Administrador Inicial
### Via SQL:
```sql
-- Primero obtener el ID del rol ADMIN
SELECT id FROM roles WHERE name = 'ADMIN';
-- Crear usuario (password debe ser hash bcrypt)
-- Puedes usar: https://bcrypt-generator.com/ para generar el hash
INSERT INTO users (email, password_hash, name, role_id, is_active)
VALUES (
'admin@ejemplo.com',
'$2b$10$xxxxx...', -- Hash bcrypt de la contraseña
'Administrador',
'uuid-del-rol-admin',
true
);
```
### Via API (si ya tienes un admin):
```bash
curl -X POST http://localhost:3000/api/users \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{
"email": "nuevo@ejemplo.com",
"password": "password123",
"name": "Nuevo Admin",
"role_id": "uuid-rol-admin"
}'
```
---
## Configuracion de The Things Stack (TTS)
### 1. Configurar Variables de Entorno
```env
TTS_ENABLED=true
TTS_BASE_URL=https://tu-servidor-tts.com
TTS_APPLICATION_ID=tu-aplicacion
TTS_API_KEY=tu-api-key
TTS_WEBHOOK_SECRET=tu-webhook-secret
```
### 2. Configurar Webhook en TTS
En la consola de TTS, configurar webhook apuntando a:
- **URL Base**: `https://api.gestionrecursoshidricos.com/api/webhooks/tts`
- **Eventos**:
- Uplink: `/uplink`
- Join: `/join`
- Downlink ACK: `/downlink/ack`
### 3. Configurar Header de Autenticacion
- **Header**: `X-Downlink-Apikey`
- **Valor**: El mismo que `TTS_WEBHOOK_SECRET`
---
## Solucion de Problemas
### Error de conexion a la base de datos
```
Error: connect ECONNREFUSED 127.0.0.1:5432
```
- Verificar que PostgreSQL esta corriendo: `sudo systemctl status postgresql`
- Verificar credenciales en `.env`
### Error CORS
```
Access-Control-Allow-Origin
```
- Verificar que la URL del frontend esta en `CORS_ORIGIN`
### Puerto en uso
```
Error: listen EADDRINUSE :::3000
```
- Verificar si hay otro proceso: `lsof -i :3000`
- Terminar proceso: `kill -9 <PID>`
### Permisos de archivo
```
EACCES: permission denied
```
- Verificar permisos del directorio
- Ejecutar: `chown -R $USER:$USER /path/to/water-project`

449
docs/MANUAL_USUARIO.md Normal file
View File

@@ -0,0 +1,449 @@
# Manual de Usuario - Sistema GRH
## Indice
1. [Acceso al Sistema](#acceso-al-sistema)
2. [Dashboard Principal](#dashboard-principal)
3. [Gestion de Proyectos](#gestion-de-proyectos)
4. [Gestion de Concentradores](#gestion-de-concentradores)
5. [Gestion de Medidores](#gestion-de-medidores)
6. [Consumo y Lecturas](#consumo-y-lecturas)
7. [Historico de Tomas](#historico-de-tomas)
8. [Organismos Operadores](#organismos-operadores)
9. [Panel de Carga CSV](#panel-de-carga-csv)
10. [Notificaciones](#notificaciones)
11. [Administracion de Usuarios](#administracion-de-usuarios)
12. [Auditoria](#auditoria)
---
## Acceso al Sistema
### URL de Acceso
- **Sistema Principal**: https://sistema.gestionrecursoshidricos.com
- **Panel de Carga CSV**: https://panel.gestionrecursoshidricos.com
### Inicio de Sesion
1. Ingrese a la URL del sistema
2. Introduzca su correo electronico
3. Introduzca su contraseña
4. Haga clic en "Iniciar Sesion"
### Roles de Usuario (Jerarquia de 3 niveles)
| Rol | Permisos | Visibilidad |
|-----|----------|-------------|
| **ADMIN** | Acceso completo a todas las funciones | Ve todos los datos del sistema |
| **ORGANISMO_OPERADOR** | Gestion de usuarios y visualizacion de su organismo | Ve datos de los proyectos de su organismo |
| **OPERATOR** | Operacion de medidores de su proyecto | Ve datos de su proyecto asignado |
### Menu Lateral por Rol
| Seccion | ADMIN | ORGANISMO_OPERADOR | OPERATOR |
|---------|-------|-------------------|----------|
| Dashboard | Si | Si | Si |
| Project Management | Si | Si | Si |
| Users Management | Si | Si | No |
| Organismos Operadores | Si | No | No |
| Conectores | Si | No | No |
| Analytics | Si | Si | No |
---
## Dashboard Principal
El dashboard muestra un resumen general del sistema:
### Indicadores Clave (KPIs)
- **Total de Medidores**: Cantidad total de medidores registrados
- **Lecturas del Dia**: Numero de lecturas recibidas hoy
- **Alertas Activas**: Notificaciones pendientes de atencion
- **Proyectos Activos**: Proyectos en estado activo
### Alertas Recientes
Lista de las ultimas alertas del sistema, incluyendo:
- Flujo negativo detectado
- Medidores sin comunicacion
- Alertas de mantenimiento
### Actividad Reciente
Historial de las ultimas acciones realizadas en el sistema.
---
## Gestion de Proyectos
### Ver Proyectos
1. Navegue a **Proyectos** en el menu lateral
2. Vera la lista de todos los proyectos
3. Use los filtros para buscar proyectos especificos
### Crear Proyecto
1. Haga clic en **"Nuevo Proyecto"**
2. Complete los campos:
- **Nombre**: Nombre del proyecto (requerido)
- **Descripcion**: Descripcion detallada
- **Area**: Nombre del area geografica
- **Ubicacion**: Direccion o coordenadas
- **Tipo de Medidor**: Tipo por defecto para el proyecto
3. Haga clic en **"Guardar"**
### Editar Proyecto
1. Haga clic en el icono de edicion del proyecto
2. Modifique los campos necesarios
3. Haga clic en **"Guardar"**
### Estadisticas del Proyecto
- Haga clic en un proyecto para ver sus estadisticas
- Incluye: total de medidores, lecturas, consumo promedio
---
## Gestion de Concentradores
Los concentradores son dispositivos que agrupan multiples medidores.
### Ver Concentradores
1. Navegue a **Concentradores** en el menu lateral
2. Filtre por proyecto si es necesario
### Crear Concentrador
1. Haga clic en **"Nuevo Concentrador"**
2. Complete los campos:
- **Serial**: Numero de serie unico (requerido)
- **Nombre**: Nombre descriptivo (requerido)
- **Proyecto**: Proyecto al que pertenece (requerido)
- **Ubicacion**: Ubicacion fisica
- **IP**: Direccion IP (opcional)
3. Haga clic en **"Guardar"**
### Estados del Concentrador
| Estado | Descripcion |
|--------|-------------|
| ACTIVE | Funcionando correctamente |
| INACTIVE | Desactivado manualmente |
| OFFLINE | Sin comunicacion |
| MAINTENANCE | En mantenimiento |
| ERROR | Con fallas |
---
## Gestion de Medidores
### Ver Medidores
1. Navegue a **Medidores** en el menu lateral
2. Use los filtros disponibles:
- Por proyecto
- Por concentrador
- Por estado
- Por tipo
- Busqueda por serial/nombre
### Crear Medidor Individual
1. Haga clic en **"Nuevo Medidor"**
2. Complete los campos:
- **Serial**: Numero de serie unico (requerido)
- **Nombre**: Nombre descriptivo (requerido)
- **Concentrador**: Concentrador asociado (requerido)
- **Area**: Nombre del area
- **Ubicacion**: Ubicacion especifica (ej: "Depto 101")
- **Tipo**: LORA, LORAWAN, o GRANDES CONSUMIDORES
- **Estado**: Estado inicial
- **Fecha Instalacion**: Fecha de instalacion
3. Haga clic en **"Guardar"**
### Carga Masiva de Medidores (Excel)
1. Haga clic en **"Carga Masiva"**
2. Descargue la plantilla Excel
3. Complete la plantilla con los datos
4. Suba el archivo Excel
5. Revise los resultados
### Tipos de Medidor
| Tipo | Descripcion |
|------|-------------|
| LORA | Medidores con comunicacion LoRa |
| LORAWAN | Medidores LoRaWAN |
| GRANDES CONSUMIDORES | Medidores de alto consumo |
### Ver Detalle del Medidor
- Haga clic en un medidor para ver:
- Informacion general
- Ultima lectura
- Historial de lecturas
- Graficos de consumo
---
## Consumo y Lecturas
### Ver Lecturas
1. Navegue a **Consumo** en el menu lateral
2. Filtre por:
- Proyecto
- Concentrador
- Medidor especifico
- Rango de fechas
- Tipo de lectura
### Tipos de Lectura
| Tipo | Descripcion |
|------|-------------|
| AUTOMATIC | Lectura automatica del dispositivo |
| MANUAL | Lectura ingresada manualmente |
| SCHEDULED | Lectura programada |
### Crear Lectura Manual
1. Haga clic en **"Nueva Lectura"**
2. Seleccione el medidor
3. Ingrese el valor de lectura
4. Opcionalmente ingrese:
- Nivel de bateria (0-100)
- Intensidad de senal (dBm)
5. Haga clic en **"Guardar"**
### Carga Masiva de Lecturas (Excel)
1. Haga clic en **"Carga Masiva"**
2. Descargue la plantilla
3. Complete con los datos
4. Suba el archivo
5. Revise los resultados
### Graficos de Consumo
- Visualice el consumo historico en graficos
- Filtre por periodo de tiempo
- Exporte datos si es necesario
---
## Historico de Tomas
*Disponible para todos los roles. Cada rol solo ve los medidores de su scope.*
### Acceder al Historico
1. Navegue a **Historico** en la seccion "Project Management" del menu lateral
2. Vera un selector de medidor y un estado vacio inicial
### Seleccionar Medidor
1. Haga clic en el campo de busqueda
2. Escriba para filtrar por:
- Nombre del medidor
- Numero de serie
- Ubicacion
- Cuenta CESPT
- Clave catastral
3. Seleccione el medidor del dropdown
### Informacion del Medidor
Al seleccionar un medidor se muestra:
- **Tarjeta de informacion:** Serial, nombre, proyecto, ubicacion, tipo, estado, ultima lectura
- **Cards de consumo:**
- **Consumo Actual:** Lectura mas reciente (consumo diario)
- **Consumo Pasado:** Lectura del primer dia del mes anterior
- **Diferencial:** Diferencia entre actual y pasado (con indicador de tendencia)
### Filtrar por Fechas
1. Use los campos "Desde" y "Hasta" para definir un rango
2. Los datos se actualizan automaticamente
3. Use el boton "Limpiar" para remover los filtros
### Grafica de Consumo
- Grafica de area con gradiente azul
- Eje X: fechas, Eje Y: valor de lectura (m3)
- El rango del eje Y se ajusta automaticamente a los datos
### Tabla de Lecturas
- Columnas: Fecha/Hora, Lectura (m3), Tipo, Bateria, Senal
- Paginacion configurable: 10, 20 o 50 registros por pagina
- Indicadores visuales de bateria y senal
### Exportar CSV
1. Haga clic en el boton **"Exportar CSV"** en el encabezado
2. Se descargara un archivo `historico_{serial}_{fecha}.csv`
---
## Organismos Operadores
*Solo disponible para usuarios con rol ADMIN*
### Ver Organismos
1. Haga clic en **"Organismos Operadores"** en el menu lateral
2. Vera la tabla con todos los organismos registrados
### Crear Organismo
1. Haga clic en **"Nuevo Organismo"**
2. Complete los campos:
- **Nombre:** Nombre del organismo (ej: CESPT Tijuana)
- **Codigo:** Codigo unico (ej: CESPT-TJ)
- **Contacto:** Nombre de la persona de contacto
- **Email:** Correo electronico de contacto
- **Telefono:** Numero de telefono
- **Estado:** Activo/Inactivo
3. Haga clic en **"Guardar"**
### Editar Organismo
1. Haga clic en el icono de edicion del organismo
2. Modifique los campos necesarios
3. Haga clic en **"Guardar"**
### Vincular Proyectos y Usuarios
- Los proyectos se vinculan a un organismo via el campo `organismo_operador_id` en la pagina de Proyectos
- Los usuarios con rol ORGANISMO_OPERADOR se vinculan a un organismo via la pagina de Usuarios
---
## Panel de Carga CSV
El panel de carga CSV permite subir datos de medidores y lecturas sin necesidad de autenticacion.
### Acceso
- URL: https://panel.gestionrecursoshidricos.com
### Cargar Medidores
1. En la seccion **"Tomas de Agua"**
2. Descargue la plantilla CSV haciendo clic en "Descargar plantilla CSV"
3. Complete el archivo con los datos:
```csv
serial_number,name,concentrator_serial,area_name,location,meter_type,status,installation_date
MED001,Medidor 1,Mexico-GRH,ZONA A,Depto 101,LORA,ACTIVE,2024-01-15
MED002,Medidor 2,Mexico-GRH,ZONA A,Depto 102,LORA,ACTIVE,2024-01-15
```
**Campos:**
| Campo | Requerido | Descripcion |
|-------|-----------|-------------|
| serial_number | Si | Numero de serie unico del medidor |
| name | Si | Nombre descriptivo |
| concentrator_serial | Si | Serial del concentrador existente |
| area_name | No | Nombre del area |
| location | No | Ubicacion especifica |
| meter_type | No | LORA (default), LORAWAN, GRANDES CONSUMIDORES |
| status | No | ACTIVE (default), INACTIVE, MAINTENANCE |
| installation_date | No | Fecha YYYY-MM-DD |
4. Arrastre el archivo o haga clic para seleccionarlo
5. Haga clic en **"Subir Archivo"**
6. Revise los resultados:
- Registros insertados (nuevos)
- Registros actualizados (existentes)
- Errores encontrados
**Logica de Upsert:**
- Si el `serial_number` ya existe: se **actualiza** el medidor
- Si el `serial_number` no existe: se **crea** un nuevo medidor
### Cargar Lecturas
1. En la seccion **"Lecturas"**
2. Descargue la plantilla CSV
3. Complete el archivo:
```csv
meter_serial,reading_value,received_at,reading_type,battery_level,signal_strength
MED001,1234.56,2024-01-20 10:30:00,MANUAL,85,-45
MED002,567.89,2024-01-20 10:35:00,MANUAL,90,-42
```
**Campos:**
| Campo | Requerido | Descripcion |
|-------|-----------|-------------|
| meter_serial | Si | Serial del medidor existente |
| reading_value | Si | Valor numerico de la lectura |
| received_at | No | Fecha/hora (default: ahora) |
| reading_type | No | AUTOMATIC, MANUAL, SCHEDULED |
| battery_level | No | Nivel de bateria 0-100 |
| signal_strength | No | Intensidad de senal en dBm |
4. Suba el archivo
5. Revise los resultados
**Nota:** El medidor debe existir previamente para poder cargar lecturas.
---
## Notificaciones
### Ver Notificaciones
1. Haga clic en el icono de campana en la barra superior
2. Vera las notificaciones recientes
### Tipos de Notificacion
| Tipo | Descripcion |
|------|-------------|
| NEGATIVE_FLOW | Flujo negativo detectado en un medidor |
| SYSTEM_ALERT | Alerta general del sistema |
| MAINTENANCE | Recordatorio de mantenimiento |
### Gestionar Notificaciones
- **Marcar como leida**: Haga clic en la notificacion
- **Marcar todas como leidas**: Boton en la parte superior
- **Eliminar**: Icono de eliminar en cada notificacion
---
## Administracion de Usuarios
*Disponible para ADMIN (ve todos) y ORGANISMO_OPERADOR (ve los de su organismo)*
### Ver Usuarios
1. Navegue a **Usuarios** en el menu lateral
2. Vera la lista de usuarios filtrada segun su rol
### Crear Usuario
1. Haga clic en **"Nuevo Usuario"**
2. Complete los campos:
- **Email**: Correo electronico (sera el usuario de login)
- **Nombre**: Nombre completo
- **Contraseña**: Contraseña inicial
- **Rol**: ADMIN, ORGANISMO_OPERADOR, u OPERATOR
- **Organismo Operador**: Para ORGANISMO_OPERADOR - organismo asignado
- **Proyecto**: Para OPERATOR - proyecto asignado
3. Haga clic en **"Guardar"**
### Editar Usuario
1. Haga clic en el icono de edicion
2. Modifique los campos necesarios
3. Haga clic en **"Guardar"**
### Desactivar Usuario
1. Haga clic en el icono de desactivar
2. Confirme la accion
- El usuario no podra iniciar sesion pero sus datos se conservan
---
## Auditoria
*Solo disponible para usuarios con rol ADMIN*
### Ver Logs de Auditoria
1. Navegue a **Auditoria** en el menu lateral
2. Vera el historial de acciones del sistema
### Filtros Disponibles
- Por usuario
- Por accion (CREATE, UPDATE, DELETE, LOGIN, etc.)
- Por tabla/entidad
- Por rango de fechas
### Informacion del Log
Cada registro muestra:
- Fecha y hora
- Usuario que realizo la accion
- Tipo de accion
- Entidad afectada
- Valores anteriores y nuevos (para updates)
- Direccion IP
- Resultado (exito/error)
---
## Soporte
Para soporte tecnico o reportar problemas:
- Contacte al administrador del sistema
- Revise la documentacion tecnica en `/docs`

126
docs/README.md Normal file
View File

@@ -0,0 +1,126 @@
# Sistema de Gestion de Recursos Hidricos (GRH)
## Descripcion General
Sistema web para la gestion y monitoreo de medidores de agua, integrando dispositivos LoRaWAN a traves de The Things Stack (TTS). Permite el seguimiento de consumo, deteccion de anomalias y gestion de proyectos de medicion.
## Arquitectura del Sistema
```
┌─────────────────────────────────────────────────────────────────┐
│ FRONTEND │
├─────────────────────────────────────────────────────────────────┤
│ App Principal (React) │ Panel de Carga (React) │
│ Puerto: 5173 │ Puerto: 5174 │
│ - Dashboard │ - Carga CSV Medidores │
│ - Gestion Medidores │ - Carga CSV Lecturas │
│ - Gestion Proyectos │ │
│ - Consumo/Lecturas │ │
│ - Usuarios/Roles │ │
│ - Auditoria │ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ BACKEND API │
│ Puerto: 3000 │
├─────────────────────────────────────────────────────────────────┤
│ Express.js + TypeScript │
│ - Autenticacion JWT │
│ - Control de Acceso por Roles │
│ - Webhooks TTS (LoRaWAN) │
│ - Jobs Programados (Cron) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ BASE DE DATOS │
│ PostgreSQL │
├─────────────────────────────────────────────────────────────────┤
│ - users, roles │
│ - projects, concentrators, gateways │
│ - meters, meter_readings, devices │
│ - notifications, audit_logs │
│ - tts_uplink_logs │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ SERVICIOS EXTERNOS │
├─────────────────────────────────────────────────────────────────┤
│ The Things Stack (TTS) │
│ - Recepcion de datos LoRaWAN │
│ - Gestion de dispositivos IoT │
└─────────────────────────────────────────────────────────────────┘
```
## Estructura del Proyecto
```
water-project/
├── src/ # Frontend principal (React + Vite)
│ ├── api/ # Clientes API
│ ├── components/ # Componentes React
│ ├── hooks/ # Custom hooks
│ ├── pages/ # Paginas de la aplicacion
│ └── types/ # Tipos TypeScript
├── water-api/ # Backend API (Express + TypeScript)
│ ├── src/
│ │ ├── config/ # Configuracion (DB, etc.)
│ │ ├── controllers/ # Controladores HTTP
│ │ ├── middleware/ # Middlewares (auth, audit)
│ │ ├── routes/ # Definicion de rutas
│ │ ├── services/ # Logica de negocio
│ │ ├── validators/ # Validacion de datos
│ │ ├── jobs/ # Tareas programadas
│ │ └── utils/ # Utilidades
│ └── sql/ # Scripts SQL
├── upload-panel/ # Panel de carga CSV (React + Vite)
│ └── src/
│ ├── api/ # Cliente API
│ └── components/ # Componentes de carga
└── docs/ # Documentacion
```
## Tecnologias Utilizadas
### Frontend
- **React 18** - Framework UI
- **Vite 5** - Build tool
- **TypeScript** - Tipado estatico
- **Tailwind CSS 4** - Estilos
- **Material UI** - Componentes UI
- **Recharts** - Graficos
- **Lucide React** - Iconos
### Backend
- **Node.js** - Runtime
- **Express 4** - Framework web
- **TypeScript** - Tipado estatico
- **PostgreSQL** - Base de datos
- **JWT** - Autenticacion
- **Multer** - Upload de archivos
- **node-cron** - Tareas programadas
- **Zod** - Validacion de schemas
### Servicios Externos
- **The Things Stack (TTS)** - Integracion LoRaWAN
## URLs de Produccion
| Servicio | URL |
|----------|-----|
| App Principal | https://sistema.gestionrecursoshidricos.com |
| Panel de Carga | https://panel.gestionrecursoshidricos.com |
| API | https://api.gestionrecursoshidricos.com |
## Documentacion Detallada
- [Manual de Usuario](./MANUAL_USUARIO.md)
- [Documentacion API](./API.md)
- [Guia de Instalacion](./INSTALACION.md)
- [Panel de Carga CSV](./UPLOAD_PANEL.md)
- [Arquitectura y Base de Datos](./ARQUITECTURA.md)

297
docs/UPLOAD_PANEL.md Normal file
View File

@@ -0,0 +1,297 @@
# Panel de Carga CSV
## Descripcion
El Panel de Carga CSV es una aplicacion web independiente que permite subir datos de medidores y lecturas mediante archivos CSV, sin necesidad de autenticacion. Esta diseñado para uso interno y facilita la carga masiva de datos.
## Acceso
- **URL**: https://panel.gestionrecursoshidricos.com
- **Puerto local**: 5174
- **Autenticacion**: No requerida
---
## Interfaz de Usuario
```
┌─────────────────────────────────────────────────────────────────┐
│ Panel de Carga de Datos - GRH │
│ Sistema de Gestion de Recursos Hidricos │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ TOMAS DE AGUA │ │ LECTURAS │ │
│ │ (Medidores) │ │ │ │
│ │ │ │ │ │
│ │ Campos requeridos: │ │ Campos requeridos: │ │
│ │ - serial_number │ │ - meter_serial │ │
│ │ - name │ │ - reading_value │ │
│ │ - concentrator_serial │ │ │ │
│ │ │ │ │ │
│ │ [Descargar plantilla] │ │ [Descargar plantilla] │ │
│ │ │ │ │ │
│ │ ┌───────────────────┐ │ │ ┌───────────────────┐ │ │
│ │ │ Arrastra CSV aqui │ │ │ │ Arrastra CSV aqui │ │ │
│ │ │ o haz clic │ │ │ │ o haz clic │ │ │
│ │ └───────────────────┘ │ │ └───────────────────┘ │ │
│ │ │ │ │ │
│ │ [ SUBIR ARCHIVO ] │ │ [ SUBIR ARCHIVO ] │ │
│ └─────────────────────────┘ └─────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Resultado de la carga: │ │
│ │ Total: 100 | Insertados: 95 | Actualizados: 3 | Errores: 2│ │
│ │ │ │
│ │ Errores: │ │
│ │ - Fila 15: Concentrador "XXX" no encontrado │ │
│ │ - Fila 42: Valor de lectura invalido │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Carga de Medidores (Tomas de Agua)
### Formato del CSV
```csv
serial_number,name,concentrator_serial,area_name,location,meter_type,status,installation_date
MED001,Medidor 001,Mexico-GRH,ZONA A,Depto 101,LORA,ACTIVE,2024-01-15
MED002,Medidor 002,Mexico-GRH,ZONA A,Depto 102,LORA,ACTIVE,2024-01-15
MED003,Medidor 003,Mexico-GRH,ZONA B,Depto 201,LORAWAN,ACTIVE,2024-01-16
```
### Campos
| Campo | Requerido | Tipo | Descripcion | Ejemplo |
|-------|-----------|------|-------------|---------|
| serial_number | **Si** | Texto | Numero de serie unico del medidor | MED001 |
| name | **Si** | Texto | Nombre descriptivo | Medidor Depto 101 |
| concentrator_serial | **Si** | Texto | Serial del concentrador existente | Mexico-GRH |
| area_name | No | Texto | Nombre del area o zona | ZONA A |
| location | No | Texto | Ubicacion especifica | Depto 101, Piso 1 |
| meter_type | No | Texto | Tipo de medidor (default: LORA) | LORA |
| status | No | Texto | Estado inicial (default: ACTIVE) | ACTIVE |
| installation_date | No | Fecha | Fecha de instalacion (YYYY-MM-DD) | 2024-01-15 |
### Tipos de Medidor Validos
- `LORA` (default)
- `LORAWAN`
- `GRANDES CONSUMIDORES`
- `WATER`
- `GAS`
- `ELECTRIC`
### Estados Validos
- `ACTIVE` (default)
- `INACTIVE`
- `OFFLINE`
- `MAINTENANCE`
- `ERROR`
### Logica de Upsert
El sistema utiliza el campo `serial_number` como identificador unico:
- **Si el serial_number YA EXISTE**: Se **actualiza** el medidor con los nuevos valores
- **Si el serial_number NO EXISTE**: Se **crea** un nuevo medidor
Esto permite:
1. Agregar nuevos medidores
2. Actualizar medidores existentes
3. Hacer ambas operaciones en un solo archivo
### Ejemplo de CSV para Actualizacion
```csv
serial_number,name,concentrator_serial,area_name,location,meter_type,status,installation_date
MED001,Medidor Actualizado,Mexico-GRH,ZONA B,Nueva Ubicacion,LORA,MAINTENANCE,2024-01-15
```
Solo se actualizaran los campos proporcionados (no vacios).
---
## Carga de Lecturas
### Formato del CSV
```csv
meter_serial,reading_value,received_at,reading_type,battery_level,signal_strength
MED001,1234.56,2024-01-20 10:30:00,MANUAL,85,-45
MED002,567.89,2024-01-20 10:35:00,MANUAL,90,-42
MED003,890.12,2024-01-20 10:40:00,AUTOMATIC,78,-50
```
### Campos
| Campo | Requerido | Tipo | Descripcion | Ejemplo |
|-------|-----------|------|-------------|---------|
| meter_serial | **Si** | Texto | Serial del medidor existente | MED001 |
| reading_value | **Si** | Numero | Valor de la lectura | 1234.56 |
| received_at | No | Fecha/Hora | Fecha y hora de la lectura | 2024-01-20 10:30:00 |
| reading_type | No | Texto | Tipo de lectura (default: MANUAL) | MANUAL |
| battery_level | No | Entero | Nivel de bateria (0-100) | 85 |
| signal_strength | No | Entero | Intensidad de senal (dBm) | -45 |
### Tipos de Lectura Validos
- `AUTOMATIC` - Lectura automatica del dispositivo
- `MANUAL` - Lectura ingresada manualmente (default)
- `SCHEDULED` - Lectura programada
### Notas Importantes
1. **El medidor debe existir**: El `meter_serial` debe corresponder a un medidor ya registrado en el sistema
2. **Fecha por defecto**: Si no se especifica `received_at`, se usa la fecha/hora actual
3. **Actualizacion automatica**: Al insertar una lectura, se actualiza automaticamente el `last_reading_value` del medidor
---
## Proceso de Carga
### Paso 1: Descargar Plantilla
1. Haga clic en "Descargar plantilla CSV"
2. Se descargara un archivo con los encabezados correctos y una fila de ejemplo
### Paso 2: Preparar el Archivo
1. Abra la plantilla en Excel, Google Sheets o cualquier editor de texto
2. Complete los datos manteniendo el formato CSV
3. Guarde el archivo como `.csv` (valores separados por comas)
### Paso 3: Subir el Archivo
1. Arrastre el archivo al area de carga, o haga clic para seleccionarlo
2. Verifique que aparezca el nombre del archivo
3. Haga clic en "Subir Archivo"
### Paso 4: Revisar Resultados
El sistema mostrara:
- **Total**: Numero total de filas procesadas
- **Insertados**: Registros nuevos creados
- **Actualizados**: Registros existentes modificados
- **Errores**: Filas que no pudieron procesarse
### Errores Comunes
| Error | Causa | Solucion |
|-------|-------|----------|
| "serial_number es requerido" | Celda vacia | Agregar serial |
| "concentrator_serial es requerido" | Celda vacia | Agregar serial del concentrador |
| "Concentrador no encontrado" | Serial incorrecto | Verificar serial del concentrador |
| "Medidor no encontrado" | Serial de medidor incorrecto (lecturas) | Verificar que el medidor exista |
| "Valor de lectura invalido" | No es numero | Usar formato numerico (ej: 1234.56) |
| "Nivel de bateria invalido" | Fuera de rango | Usar valor entre 0 y 100 |
---
## Ejemplos Practicos
### Ejemplo 1: Cargar 3 Medidores Nuevos
```csv
serial_number,name,concentrator_serial,area_name,location,meter_type,status,installation_date
RES-001,Departamento 101,Mexico-GRH,TORRE A,Piso 1,LORA,ACTIVE,2024-01-15
RES-002,Departamento 102,Mexico-GRH,TORRE A,Piso 1,LORA,ACTIVE,2024-01-15
RES-003,Departamento 201,Mexico-GRH,TORRE A,Piso 2,LORA,ACTIVE,2024-01-15
```
### Ejemplo 2: Actualizar Ubicacion de Medidores
```csv
serial_number,name,concentrator_serial,area_name,location,meter_type,status,installation_date
RES-001,,Mexico-GRH,,Nueva Ubicacion,,,
RES-002,,Mexico-GRH,,Bodega 1,,,
```
Solo los campos con valor seran actualizados.
### Ejemplo 3: Cargar Lecturas Diarias
```csv
meter_serial,reading_value,received_at,reading_type,battery_level,signal_strength
RES-001,125.50,2024-01-20 08:00:00,MANUAL,95,-40
RES-002,89.75,2024-01-20 08:05:00,MANUAL,92,-38
RES-003,156.25,2024-01-20 08:10:00,MANUAL,88,-45
```
### Ejemplo 4: Cargar Lecturas sin Fecha (Usa Fecha Actual)
```csv
meter_serial,reading_value,received_at,reading_type,battery_level,signal_strength
RES-001,130.25,,,85,
RES-002,92.50,,,90,
RES-003,161.00,,,85,
```
---
## API Endpoints
El panel utiliza los siguientes endpoints de la API:
### Medidores
**Subir CSV:**
```http
POST /api/csv-upload/meters
Content-Type: multipart/form-data
file: archivo.csv
```
**Descargar plantilla:**
```http
GET /api/csv-upload/meters/template
```
### Lecturas
**Subir CSV:**
```http
POST /api/csv-upload/readings
Content-Type: multipart/form-data
file: archivo.csv
```
**Descargar plantilla:**
```http
GET /api/csv-upload/readings/template
```
---
## Configuracion Tecnica
### Variables de Entorno
Crear archivo `.env` en `upload-panel/`:
```env
VITE_API_URL=https://api.gestionrecursoshidricos.com/api
```
Para desarrollo local:
```env
VITE_API_URL=http://localhost:3000/api
```
### Ejecutar Localmente
```bash
cd upload-panel
npm install
npm run dev
```
El panel estara disponible en: http://localhost:5174
### Compilar para Produccion
```bash
npm run build
```
Los archivos compilados estaran en `upload-panel/dist/`

View File

@@ -0,0 +1,170 @@
# Plan: Agregar Rol ORGANISMOS_OPERADORES
## Resumen
Agregar un nuevo rol "ORGANISMOS_OPERADORES" que:
- Se ubica entre ADMIN y OPERATOR en la jerarquía
- Permite asignar **múltiples proyectos** a un usuario (a diferencia de OPERATOR que solo tiene uno)
- Puede ver datos de todos sus proyectos asignados
---
## Fase 1: Base de Datos
### 1.1 Crear migración SQL
**Archivo nuevo:** `water-api/sql/add_organismos_operadores_role.sql`
```sql
-- Agregar nuevo valor al enum role_name
ALTER TYPE role_name ADD VALUE 'ORGANISMOS_OPERADORES' AFTER 'ADMIN';
-- Crear tabla user_projects para relación muchos-a-muchos
CREATE TABLE user_projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, project_id)
);
CREATE INDEX idx_user_projects_user_id ON user_projects(user_id);
CREATE INDEX idx_user_projects_project_id ON user_projects(project_id);
-- Insertar el nuevo rol con permisos
INSERT INTO roles (name, description, permissions) VALUES (
'ORGANISMOS_OPERADORES',
'Organismos Operadores - gestiona múltiples proyectos asignados',
'{
"users": {"create": false, "read": true, "update": false, "delete": false},
"projects": {"create": false, "read": true, "update": true, "delete": false},
"devices": {"create": true, "read": true, "update": true, "delete": false},
"meters": {"create": true, "read": true, "update": true, "delete": false},
"readings": {"create": true, "read": true, "update": false, "delete": false},
"settings": {"create": false, "read": true, "update": false, "delete": false},
"reports": {"create": true, "read": true, "export": true}
}'::JSONB
);
```
---
## Fase 2: Backend
### 2.1 Actualizar validador de roles
**Archivo:** `water-api/src/validators/role.validator.ts`
- Línea 7: Agregar 'ORGANISMOS_OPERADORES' al enum
```typescript
export const RoleNameEnum = z.enum(['ADMIN', 'ORGANISMOS_OPERADORES', 'OPERATOR', 'VIEWER']);
```
### 2.2 Actualizar tipos
**Archivo:** `water-api/src/types/index.ts`
- Agregar `projectIds?: string[]` a `JwtPayload`
- Agregar `project_ids?: string[]` a `User` y `UserPublic`
### 2.3 Actualizar servicio de usuarios
**Archivo:** `water-api/src/services/user.service.ts`
- Agregar funciones `getUserProjects(userId)` y `setUserProjects(userId, projectIds[])`
- Modificar `create()` para manejar `project_ids` cuando rol es ORGANISMOS_OPERADORES
- Modificar `update()` igual
- Modificar `getById()` y `getAll()` para incluir project_ids en respuesta
### 2.4 Actualizar servicio de autenticación
**Archivo:** `water-api/src/services/auth.service.ts`
- En `login()`: si rol es ORGANISMOS_OPERADORES, consultar `user_projects` y agregar array al JWT
- En `refresh()`: mismo cambio
- En `getMe()`: retornar `project_ids` array
### 2.5 Actualizar middleware de autenticación
**Archivo:** `water-api/src/middleware/auth.middleware.ts`
- Extraer `projectIds` del JWT y adjuntar a `req.user`
### 2.6 Actualizar validador de usuarios
**Archivo:** `water-api/src/validators/user.validator.ts`
- Agregar `project_ids: z.array(z.string().uuid()).optional()` a schemas
---
## Fase 3: Frontend
### 3.1 Actualizar API de autenticación
**Archivo:** `src/api/auth.ts`
- Agregar `projectIds?: string[]` a `JwtPayload` y `AuthUser`
- Agregar función `getCurrentUserProjectIds(): string[] | null`
- Agregar función `isOrganismosOperadores(): boolean`
### 3.2 Actualizar API de usuarios
**Archivo:** `src/api/users.ts`
- Agregar `project_ids?: string[]` a interfaces `User`, `CreateUserInput`, `UpdateUserInput`
### 3.3 Actualizar UsersPage
**Archivo:** `src/pages/UsersPage.tsx`
- Agregar `projectIds?: string[]` a interfaces
- Agregar componente de selección múltiple de proyectos para ORGANISMOS_OPERADORES
- Mantener selector único para OPERATOR
- Validar que se seleccione al menos un proyecto para ORGANISMOS_OPERADORES
### 3.4 Actualizar Sidebar
**Archivo:** `src/components/layout/Sidebar.tsx`
- Agregar `isOrganismosOperadores` check
- ORGANISMOS_OPERADORES puede ver: Dashboard, Proyectos, Medidores, Concentradores, Consumo, Analytics
- ORGANISMOS_OPERADORES NO puede ver: Users Management, Conectores, Auditoría
### 3.5 Actualizar páginas de datos
**Archivos:**
- `src/pages/meters/useMeters.ts`
- `src/pages/consumption/ConsumptionPage.tsx`
- `src/pages/projects/ProjectsPage.tsx`
Cambios en cada archivo:
- Importar `getCurrentUserProjectIds`
- Filtrar proyectos visibles usando array de IDs
- Permitir cambiar entre proyectos asignados
---
## Orden de Implementación
1. **SQL Migration** - Crear tabla y enum
2. **Backend Types** - Actualizar tipos base
3. **Backend Validators** - Actualizar validaciones
4. **Backend Services** - user.service.ts, auth.service.ts
5. **Backend Middleware** - auth.middleware.ts
6. **Frontend Auth API** - src/api/auth.ts
7. **Frontend Users API** - src/api/users.ts
8. **Frontend UsersPage** - Multi-select UI
9. **Frontend Sidebar** - Visibilidad de menús
10. **Frontend Data Pages** - Filtrado por proyectos
---
## Archivos a Modificar
| Archivo | Cambio |
|---------|--------|
| `water-api/sql/add_organismos_operadores_role.sql` | **NUEVO** - Migración |
| `water-api/src/validators/role.validator.ts` | Agregar enum value |
| `water-api/src/validators/user.validator.ts` | Agregar project_ids |
| `water-api/src/types/index.ts` | Agregar projectIds a tipos |
| `water-api/src/services/user.service.ts` | Funciones multi-proyecto |
| `water-api/src/services/auth.service.ts` | ProjectIds en JWT |
| `water-api/src/middleware/auth.middleware.ts` | Extraer projectIds |
| `src/api/auth.ts` | Funciones helper |
| `src/api/users.ts` | Actualizar interfaces |
| `src/pages/UsersPage.tsx` | Multi-select UI |
| `src/components/layout/Sidebar.tsx` | Visibilidad menús |
| `src/pages/meters/useMeters.ts` | Filtrado multi-proyecto |
| `src/pages/consumption/ConsumptionPage.tsx` | Filtrado multi-proyecto |
| `src/pages/projects/ProjectsPage.tsx` | Filtrado multi-proyecto |
---
## Verificación
1. **Base de datos**: Ejecutar migración y verificar que el rol existe
2. **Backend**:
- Crear usuario con rol ORGANISMOS_OPERADORES y múltiples proyectos
- Verificar que JWT incluye array de projectIds
3. **Frontend**:
- Verificar multi-select aparece solo para ORGANISMOS_OPERADORES
- Verificar que sidebar muestra/oculta elementos correctamente
- Verificar que páginas de datos filtran por proyectos asignados

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/jpeg" href="/grhWatermark.jpg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GRH</title>
</head>

104
package-lock.json generated
View File

@@ -15,13 +15,16 @@
"@mui/material": "^7.3.6",
"@mui/x-data-grid": "^8.21.0",
"@tailwindcss/vite": "^4.1.18",
"leaflet": "^1.9.4",
"lucide-react": "^0.559.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"recharts": "^3.6.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@types/leaflet": "^1.9.21",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
@@ -523,6 +526,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -539,6 +543,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -555,6 +560,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -571,6 +577,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -587,6 +594,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -603,6 +611,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -619,6 +628,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -635,6 +645,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -651,6 +662,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -667,6 +679,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -683,6 +696,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -699,6 +713,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -715,6 +730,7 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -731,6 +747,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -747,6 +764,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -763,6 +781,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -779,6 +798,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -795,6 +815,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -811,6 +832,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -827,6 +849,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -843,6 +866,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -859,6 +883,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -875,6 +900,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1575,6 +1601,17 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@react-leaflet/core": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
@@ -1625,6 +1662,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1638,6 +1676,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1651,6 +1690,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1664,6 +1704,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1677,6 +1718,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1690,6 +1732,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1703,6 +1746,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1716,6 +1760,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1729,6 +1774,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1742,6 +1788,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1755,6 +1802,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1768,6 +1816,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1781,6 +1830,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1794,6 +1844,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1807,6 +1858,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1820,6 +1872,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1833,6 +1886,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1846,6 +1900,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1859,6 +1914,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1872,6 +1928,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1885,6 +1942,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1898,6 +1956,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2285,8 +2344,26 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@@ -2303,6 +2380,7 @@
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -3145,6 +3223,7 @@
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -3548,6 +3627,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -3958,6 +4038,12 @@
"json-buffer": "3.0.1"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -4340,6 +4426,7 @@
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
@@ -4535,6 +4622,7 @@
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -4654,6 +4742,20 @@
"integrity": "sha512-L7BnWgRbMwzMAubQcS7sXdPdNLmKlucPlopgAzx7FtYbksWZgEWiuYM5x9T6UqS2Ne0rsgQTq5kY2SGqpzUkYA==",
"license": "MIT"
},
"node_modules/react-leaflet": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^2.1.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
@@ -4815,6 +4917,7 @@
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -5202,6 +5305,7 @@
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",

View File

@@ -17,13 +17,16 @@
"@mui/material": "^7.3.6",
"@mui/x-data-grid": "^8.21.0",
"@tailwindcss/vite": "^4.1.18",
"leaflet": "^1.9.4",
"lucide-react": "^0.559.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"recharts": "^3.6.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@types/leaflet": "^1.9.21",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",

3611
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -9,6 +9,15 @@ import ProjectsPage from "./pages/projects/ProjectsPage";
import UsersPage from "./pages/UsersPage";
import RolesPage from "./pages/RolesPage";
import ConsumptionPage from "./pages/consumption/ConsumptionPage";
import AuditoriaPage from "./pages/AuditoriaPage";
import SHMetersPage from "./pages/conectores/SHMetersPage";
import XMetersPage from "./pages/conectores/XMetersPage";
import TTSPage from "./pages/conectores/TTSPage";
import AnalyticsMapPage from "./pages/analytics/AnalyticsMapPage";
import AnalyticsReportsPage from "./pages/analytics/AnalyticsReportsPage";
import AnalyticsServerPage from "./pages/analytics/AnalyticsServerPage";
import OrganismosPage from "./pages/OrganismosPage";
import HistoricoPage from "./pages/historico/HistoricoPage";
import ProfileModal from "./components/layout/common/ProfileModal";
import { updateMyProfile } from "./api/me";
@@ -37,8 +46,17 @@ export type Page =
| "meters"
| "concentrators"
| "consumption"
| "auditoria"
| "users"
| "roles";
| "roles"
| "sh-meters"
| "xmeters"
| "tts"
| "analytics-map"
| "analytics-reports"
| "analytics-server"
| "organismos"
| "historico";
export default function App() {
const [isAuth, setIsAuth] = useState<boolean>(false);
@@ -175,10 +193,28 @@ export default function App() {
return <ConcentratorsPage />;
case "consumption":
return <ConsumptionPage />;
case "auditoria":
return <AuditoriaPage />;
case "users":
return <UsersPage />;
case "roles":
return <RolesPage />;
case "sh-meters":
return <SHMetersPage />;
case "xmeters":
return <XMetersPage />;
case "tts":
return <TTSPage />;
case "analytics-map":
return <AnalyticsMapPage />;
case "analytics-reports":
return <AnalyticsReportsPage />;
case "analytics-server":
return <AnalyticsServerPage />;
case "organismos":
return <OrganismosPage />;
case "historico":
return <HistoricoPage />;
case "home":
default:
return (
@@ -238,7 +274,7 @@ export default function App() {
</div>
{/* ✅ AQUÍ VA LA MARCA DE AGUA */}
<main className="relative min-w-0 flex-1 overflow-auto">
<main className="relative min-w-0 flex-1 overflow-auto bg-slate-50 dark:bg-zinc-950">
<Watermark />
<div className="relative z-10">{renderPage()}</div>
</main>

86
src/api/analytics.ts Normal file
View File

@@ -0,0 +1,86 @@
import { apiClient } from './client';
export interface ServerMetrics {
uptime: number;
memory: {
total: number;
used: number;
free: number;
percentage: number;
};
cpu: {
usage: number;
cores: number;
};
requests: {
total: number;
errors: number;
avgResponseTime: number;
};
database: {
connected: boolean;
responseTime: number;
};
timestamp: string;
}
export interface MeterWithCoords {
id: string;
serial_number: string;
name: string;
status: string;
project_name: string;
lat: number;
lng: number;
last_reading?: number;
last_reading_date?: string;
}
export interface ReportStats {
totalMeters: number;
activeMeters: number;
inactiveMeters: number;
totalConsumption: number;
totalProjects: number;
metersWithAlerts: number;
consumptionByProject: Array<{
project_name: string;
total_consumption: number;
meter_count: number;
}>;
consumptionTrend: Array<{
date: string;
consumption: number;
}>;
}
export async function getServerMetrics(): Promise<ServerMetrics> {
return apiClient.get<ServerMetrics>('/api/system/metrics');
}
export async function getSystemHealth(): Promise<{
status: string;
database: boolean;
uptime: number;
}> {
return apiClient.get('/api/system/health');
}
export async function getMetersWithCoordinates(): Promise<MeterWithCoords[]> {
return apiClient.get<MeterWithCoords[]>('/api/system/meters-locations');
}
export async function getReportStats(): Promise<ReportStats> {
return apiClient.get<ReportStats>('/api/system/report-stats');
}
export interface ConnectorStats {
meterCount: number;
messagesReceived: number;
daysSinceStart: number;
meterType: string;
}
export async function getConnectorStats(type: 'sh-meters' | 'xmeters'): Promise<ConnectorStats> {
return apiClient.get<ConnectorStats>(`/api/system/connector-stats/${type}`);
}

151
src/api/audit.ts Normal file
View File

@@ -0,0 +1,151 @@
/**
* Audit API Client
* Functions to interact with audit log endpoints
*/
import { apiClient } from './client';
export type AuditAction =
| 'CREATE'
| 'UPDATE'
| 'DELETE'
| 'LOGIN'
| 'LOGOUT'
| 'READ'
| 'EXPORT'
| 'BULK_UPLOAD'
| 'STATUS_CHANGE'
| 'PERMISSION_CHANGE';
export interface AuditLog {
id: string;
user_id: string | null;
user_email: string;
user_name: string;
action: AuditAction;
table_name: string;
record_id: string | null;
old_values: Record<string, any> | null;
new_values: Record<string, any> | null;
description: string | null;
ip_address: string | null;
user_agent: string | null;
success: boolean;
error_message: string | null;
created_at: string;
}
export interface AuditLogFilters {
userId?: string;
action?: AuditAction;
tableName?: string;
recordId?: string;
startDate?: string;
endDate?: string;
success?: boolean;
page?: number;
limit?: number;
}
export interface AuditLogListResponse {
success: boolean;
message: string;
data: AuditLog[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
export interface AuditLogResponse {
success: boolean;
message: string;
data: AuditLog;
}
export interface AuditStatistic {
date: string;
action: AuditAction;
table_name: string;
action_count: number;
unique_users: number;
successful_actions: number;
failed_actions: number;
}
export interface AuditStatisticsResponse {
success: boolean;
message: string;
data: AuditStatistic[];
}
/**
* Get all audit logs with filters (admin only)
*/
export async function getAuditLogs(
filters?: AuditLogFilters
): Promise<AuditLogListResponse> {
const params = new URLSearchParams();
if (filters?.userId) params.append('userId', filters.userId);
if (filters?.action) params.append('action', filters.action);
if (filters?.tableName) params.append('tableName', filters.tableName);
if (filters?.recordId) params.append('recordId', filters.recordId);
if (filters?.startDate) params.append('startDate', filters.startDate);
if (filters?.endDate) params.append('endDate', filters.endDate);
if (filters?.success !== undefined)
params.append('success', String(filters.success));
if (filters?.page) params.append('page', String(filters.page));
if (filters?.limit) params.append('limit', String(filters.limit));
const queryString = params.toString();
const url = queryString ? `/api/audit-logs?${queryString}` : '/api/audit-logs';
return apiClient.get<AuditLogListResponse>(url);
}
/**
* Get a single audit log by ID (admin only)
*/
export async function getAuditLogById(id: string): Promise<AuditLogResponse> {
return apiClient.get<AuditLogResponse>(`/api/audit-logs/${id}`);
}
/**
* Get audit logs for a specific record (admin only)
*/
export async function getAuditLogsForRecord(
tableName: string,
recordId: string
): Promise<AuditLogListResponse> {
return apiClient.get<AuditLogListResponse>(
`/api/audit-logs/record/${tableName}/${recordId}`
);
}
/**
* Get audit statistics (admin only)
*/
export async function getAuditStatistics(
days: number = 30
): Promise<AuditStatisticsResponse> {
return apiClient.get<AuditStatisticsResponse>(
`/api/audit-logs/statistics?days=${days}`
);
}
/**
* Get current user's activity logs
*/
export async function getMyActivity(
page: number = 1,
limit: number = 50
): Promise<AuditLogListResponse> {
return apiClient.get<AuditLogListResponse>(
`/api/audit-logs/my-activity?page=${page}&limit=${limit}`
);
}

View File

@@ -34,9 +34,22 @@ export interface AuthUser {
email: string;
name: string;
role: string;
projectId?: string | null;
organismoOperadorId?: string | null;
organismoName?: string | null;
avatar_url?: string;
}
export interface JwtPayload {
userId: string;
roleId: string;
roleName: string;
projectId?: string | null;
organismoOperadorId?: string | null;
exp?: number;
iat?: number;
}
/**
* Login response combining tokens and user data
*/
@@ -329,3 +342,94 @@ export async function changePassword(
newPassword,
});
}
/**
* Get current user's project ID from JWT token
* @returns The project ID or null if not assigned
*/
export function getCurrentUserProjectId(): string | null {
const token = getAccessToken();
if (!token) return null;
try {
const payload = parseJwtPayload(token) as JwtPayload | null;
return payload?.projectId || null;
} catch {
return null;
}
}
/**
* Get current user's role name from JWT token
* @returns The role name or null
*/
export function getCurrentUserRole(): string | null {
const token = getAccessToken();
if (!token) return null;
try {
const payload = parseJwtPayload(token) as JwtPayload | null;
return payload?.roleName || null;
} catch {
return null;
}
}
/**
* Get current user's ID from JWT token
* @returns The user ID or null
*/
export function getCurrentUserId(): string | null {
const token = getAccessToken();
if (!token) return null;
try {
const payload = parseJwtPayload(token) as JwtPayload | null;
return payload?.userId || null;
} catch {
return null;
}
}
/**
* Check if current user is an admin
* @returns boolean indicating if user is admin
*/
export function isCurrentUserAdmin(): boolean {
const role = getCurrentUserRole();
return role?.toUpperCase() === 'ADMIN';
}
/**
* Get current user's organismo operador ID from JWT token
* @returns The organismo operador ID or null
*/
export function getCurrentUserOrganismoId(): string | null {
const token = getAccessToken();
if (!token) return null;
try {
const payload = parseJwtPayload(token) as JwtPayload | null;
return payload?.organismoOperadorId || null;
} catch {
return null;
}
}
/**
* Check if current user is an Organismo Operador
* @returns boolean indicating if user is organismo operador
*/
export function isCurrentUserOrganismo(): boolean {
const role = getCurrentUserRole();
return role?.toUpperCase() === 'ORGANISMO_OPERADOR';
}
/**
* Check if current user is an Operador (OPERATOR)
* @returns boolean indicating if user is operador
*/
export function isCurrentUserOperador(): boolean {
const role = getCurrentUserRole();
return role?.toUpperCase() === 'OPERATOR';
}

View File

@@ -224,10 +224,18 @@ async function parseResponse<T>(response: Response): Promise<T> {
if (data && typeof data === 'object') {
if ('success' in data) {
if (data.success === false) {
const errorMessage = typeof data.error === 'string'
? data.error
: (data.error?.message || 'Request failed');
const errorDetails = typeof data.error === 'object'
? data.error?.errors
: undefined;
throw new ApiError(
data.error?.message || 'Request failed',
errorMessage,
response.status,
data.error?.errors
errorDetails
);
}
// If response has pagination, return object with data and pagination
@@ -298,7 +306,10 @@ async function request<T>(config: InternalRequestConfig): Promise<T> {
try {
const errorData = await response.json();
if (errorData.error?.message) {
if (typeof errorData.error === 'string') {
errorMessage = errorData.error;
} else if (errorData.error?.message) {
errorMessage = errorData.error.message;
errors = errorData.error.errors;
} else if (errorData.message) {

77
src/api/meterTypes.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* Meter Types API
* Handles all meter type-related API operations
*/
import { apiClient } from './client';
/**
* Meter Type entity
*/
export interface MeterType {
id: string;
name: string;
code: string;
description: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
/**
* Fetch all active meter types
* @returns Promise resolving to an array of meter types
*/
export async function fetchMeterTypes(): Promise<MeterType[]> {
// apiClient automatically unwraps the response and returns only the data array
const data = await apiClient.get<any[]>('/api/meter-types');
// Transform snake_case to camelCase
return data.map((item: any) => ({
id: item.id,
name: item.name,
code: item.code,
description: item.description,
isActive: item.is_active,
createdAt: item.created_at,
updatedAt: item.updated_at,
}));
}
/**
* Fetch a meter type by ID
* @param id - Meter type ID
* @returns Promise resolving to a meter type
*/
export async function fetchMeterTypeById(id: string): Promise<MeterType> {
const item = await apiClient.get<any>(`/api/meter-types/${id}`);
return {
id: item.id,
name: item.name,
code: item.code,
description: item.description,
isActive: item.is_active,
createdAt: item.created_at,
updatedAt: item.updated_at,
};
}
/**
* Fetch a meter type by code
* @param code - Meter type code (LORA, LORAWAN, GRANDES)
* @returns Promise resolving to a meter type
*/
export async function fetchMeterTypeByCode(code: string): Promise<MeterType> {
const item = await apiClient.get<any>(`/api/meter-types/code/${code}`);
return {
id: item.id,
name: item.name,
code: item.code,
description: item.description,
isActive: item.is_active,
createdAt: item.created_at,
updatedAt: item.updated_at,
};
}

View File

@@ -53,6 +53,24 @@ export interface Meter {
concentratorSerial?: string;
projectId?: string;
projectName?: string;
protocol?: string | null;
mac?: string | null;
gateway?: string | null;
voltage?: number | null;
voltageRtu?: number | null;
voltageStatus?: string | null;
signal?: number | null;
leakageStatus?: string | null;
burstStatus?: string | null;
currentFlow?: number | null;
totalFlowReverse?: number | null;
manufacturer?: string | null;
latitude?: number | null;
longitude?: number | null;
address?: string | null;
cesptAccount?: string | null;
cadastralKey?: string | null;
}
/**
@@ -67,19 +85,62 @@ export interface MeterInput {
type?: string;
status?: string;
installationDate?: string;
protocol?: string;
mac?: string;
gateway?: string;
voltage?: number;
voltageRtu?: number;
voltageStatus?: string;
signal?: number;
leakageStatus?: string;
burstStatus?: string;
currentFlow?: number;
totalFlowReverse?: number;
manufacturer?: string;
latitude?: number;
longitude?: number;
address?: string;
cesptAccount?: string;
cadastralKey?: string;
}
/**
* Meter reading entity
* Meter reading entity (from /api/meters/:id/readings)
*/
export interface MeterReading {
id: string;
meterId: string;
value: number;
unit: string;
readingValue: number;
readingType: string;
readAt: string;
batteryLevel: number | null;
signalStrength: number | null;
receivedAt: string;
createdAt: string;
meterSerialNumber: string;
meterName: string;
meterLocation: string | null;
concentratorId: string;
concentratorName: string;
projectId: string;
projectName: string;
}
export interface MeterReadingFilters {
startDate?: string;
endDate?: string;
page?: number;
pageSize?: number;
}
export interface PaginatedMeterReadings {
data: MeterReading[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
/**
@@ -133,6 +194,9 @@ export async function createMeter(data: MeterInput): Promise<Meter> {
type: data.type,
status: data.status,
installation_date: data.installationDate,
address: data.address,
cespt_account: data.cesptAccount,
cadastral_key: data.cadastralKey,
};
const response = await apiClient.post<Record<string, unknown>>('/api/meters', backendData);
return transformKeys<Meter>(response);
@@ -155,6 +219,9 @@ export async function updateMeter(id: string, data: Partial<MeterInput>): Promis
if (data.type !== undefined) backendData.type = data.type;
if (data.status !== undefined) backendData.status = data.status;
if (data.installationDate !== undefined) backendData.installation_date = data.installationDate;
if (data.address !== undefined) backendData.address = data.address;
if (data.cesptAccount !== undefined) backendData.cespt_account = data.cesptAccount;
if (data.cadastralKey !== undefined) backendData.cadastral_key = data.cadastralKey;
const response = await apiClient.patch<Record<string, unknown>>(`/api/meters/${id}`, backendData);
return transformKeys<Meter>(response);
@@ -170,12 +237,24 @@ export async function deleteMeter(id: string): Promise<void> {
}
/**
* Fetch readings for a specific meter
* Fetch readings for a specific meter with pagination and date filters
* @param id - The meter ID
* @returns Promise resolving to an array of meter readings
* @param filters - Optional pagination and date filters
* @returns Promise resolving to paginated meter readings
*/
export async function fetchMeterReadings(id: string): Promise<MeterReading[]> {
return apiClient.get<MeterReading[]>(`/api/meters/${id}/readings`);
export async function fetchMeterReadings(id: string, filters?: MeterReadingFilters): Promise<PaginatedMeterReadings> {
const params: Record<string, string> = {};
if (filters?.startDate) params.start_date = filters.startDate;
if (filters?.endDate) params.end_date = filters.endDate;
if (filters?.page) params.page = String(filters.page);
if (filters?.pageSize) params.pageSize = String(filters.pageSize);
const response = await apiClient.get<{ data: Record<string, unknown>[]; pagination: { page: number; pageSize: number; total: number; totalPages: number } }>(`/api/meters/${id}/readings`, { params });
return {
data: transformArray<MeterReading>(response.data),
pagination: response.pagination,
};
}
/**

101
src/api/notifications.ts Normal file
View File

@@ -0,0 +1,101 @@
import { apiClient } from './client';
export type NotificationType = 'NEGATIVE_FLOW' | 'SYSTEM_ALERT' | 'MAINTENANCE';
export interface Notification {
id: string;
user_id: string;
meter_id: string | null;
notification_type: NotificationType;
title: string;
message: string;
meter_serial_number: string | null;
flow_value: number | null;
is_read: boolean;
read_at: string | null;
created_at: string;
}
export interface PaginatedNotifications {
data: Notification[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
export interface NotificationFilters {
page?: number;
limit?: number;
is_read?: boolean;
notification_type?: NotificationType;
start_date?: string;
end_date?: string;
}
/**
* Fetch all notifications for the current user with optional filtering
* @param filters - Optional filters for notifications
* @returns Promise resolving to paginated notifications
*/
export async function fetchNotifications(filters?: NotificationFilters): Promise<PaginatedNotifications> {
const params: Record<string, string | number | boolean> = {};
if (filters?.page !== undefined) params.page = filters.page;
if (filters?.limit !== undefined) params.limit = filters.limit;
if (filters?.is_read !== undefined) params.is_read = filters.is_read;
if (filters?.notification_type) params.notification_type = filters.notification_type;
if (filters?.start_date) params.start_date = filters.start_date;
if (filters?.end_date) params.end_date = filters.end_date;
return apiClient.get<PaginatedNotifications>('/api/notifications', { params });
}
/**
* Get count of unread notifications
* @returns Promise resolving to unread count
*/
export async function getUnreadCount(): Promise<number> {
const response = await apiClient.get<{ count: number }>('/api/notifications/unread-count');
return response.count;
}
/**
* Fetch a single notification by ID
* @param id - The notification ID
* @returns Promise resolving to the notification
*/
export async function fetchNotification(id: string): Promise<Notification> {
return apiClient.get<Notification>(`/api/notifications/${id}`);
}
/**
* Mark a notification as read
* @param id - The notification ID
* @returns Promise resolving to the updated notification
*/
export async function markAsRead(id: string): Promise<Notification> {
return apiClient.patch<Notification>(`/api/notifications/${id}/read`);
}
/**
* Mark all notifications as read
* @returns Promise resolving to count of marked notifications
*/
export async function markAllAsRead(): Promise<number> {
const response = await apiClient.patch<{ count: number }>('/api/notifications/read-all');
return response.count;
}
/**
* Delete a notification
* @param id - The notification ID
* @returns Promise resolving when the notification is deleted
*/
export async function deleteNotification(id: string): Promise<void> {
return apiClient.delete<void>(`/api/notifications/${id}`);
}

99
src/api/organismos.ts Normal file
View File

@@ -0,0 +1,99 @@
/**
* Organismos Operadores API
* Handles all organismo-related API requests
*/
import { apiClient } from './client';
export interface OrganismoOperador {
id: string;
name: string;
description: string | null;
region: string | null;
contact_name: string | null;
contact_email: string | null;
is_active: boolean;
project_count: number;
user_count: number;
created_at: string;
updated_at: string;
}
export interface CreateOrganismoInput {
name: string;
description?: string;
region?: string;
contact_name?: string;
contact_email?: string;
is_active?: boolean;
}
export interface UpdateOrganismoInput {
name?: string;
description?: string;
region?: string;
contact_name?: string;
contact_email?: string;
is_active?: boolean;
}
export interface OrganismoListResponse {
data: OrganismoOperador[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
export interface OrganismoProject {
id: string;
name: string;
status: string;
}
/**
* Get all organismos operadores
*/
export async function getAllOrganismos(params?: {
page?: number;
pageSize?: number;
}): Promise<OrganismoListResponse> {
return apiClient.get<OrganismoListResponse>('/api/organismos-operadores', { params });
}
/**
* Get a single organismo by ID
*/
export async function getOrganismoById(id: string): Promise<OrganismoOperador> {
return apiClient.get<OrganismoOperador>(`/api/organismos-operadores/${id}`);
}
/**
* Get projects belonging to an organismo
*/
export async function getOrganismoProjects(id: string): Promise<OrganismoProject[]> {
return apiClient.get<OrganismoProject[]>(`/api/organismos-operadores/${id}/projects`);
}
/**
* Create a new organismo operador
*/
export async function createOrganismo(data: CreateOrganismoInput): Promise<OrganismoOperador> {
return apiClient.post<OrganismoOperador>('/api/organismos-operadores', data);
}
/**
* Update an organismo operador
*/
export async function updateOrganismo(id: string, data: UpdateOrganismoInput): Promise<OrganismoOperador> {
return apiClient.put<OrganismoOperador>(`/api/organismos-operadores/${id}`, data);
}
/**
* Delete an organismo operador
*/
export async function deleteOrganismo(id: string): Promise<void> {
return apiClient.delete<void>(`/api/organismos-operadores/${id}`);
}

View File

@@ -40,6 +40,8 @@ export interface Project {
areaName: string;
location: string | null;
status: string;
meterTypeId: string | null;
organismoOperadorId: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
@@ -54,6 +56,8 @@ export interface ProjectInput {
areaName: string;
location?: string;
status?: string;
meterTypeId?: string | null;
organismoOperadorId?: string | null;
}
/**
@@ -94,6 +98,8 @@ export async function createProject(data: ProjectInput): Promise<Project> {
area_name: data.areaName,
location: data.location,
status: data.status,
meter_type_id: data.meterTypeId,
organismo_operador_id: data.organismoOperadorId,
};
const response = await apiClient.post<Record<string, unknown>>('/api/projects', backendData);
return transformKeys<Project>(response);
@@ -112,6 +118,8 @@ export async function updateProject(id: string, data: Partial<ProjectInput>): Pr
if (data.areaName !== undefined) backendData.area_name = data.areaName;
if (data.location !== undefined) backendData.location = data.location;
if (data.status !== undefined) backendData.status = data.status;
if (data.meterTypeId !== undefined) backendData.meter_type_id = data.meterTypeId;
if (data.organismoOperadorId !== undefined) backendData.organismo_operador_id = data.organismoOperadorId;
const response = await apiClient.patch<Record<string, unknown>>(`/api/projects/${id}`, backendData);
return transformKeys<Project>(response);
@@ -126,6 +134,16 @@ export async function deleteProject(id: string): Promise<void> {
return apiClient.delete<void>(`/api/projects/${id}`);
}
/**
* Deactivate a project and unassign users
* @param id - The project ID
* @returns Promise resolving when the project is deactivated
*/
export async function deactivateProject(id: string): Promise<Project> {
const response = await apiClient.post<Record<string, unknown>>(`/api/projects/${id}/deactivate`, {});
return transformKeys<Project>(response);
}
/**
* Fetch unique area names from all projects
* @returns Promise resolving to an array of unique area names

View File

@@ -17,8 +17,16 @@ export interface User {
description: string;
permissions: Record<string, Record<string, boolean>>;
};
project_id: string | null;
organismo_operador_id: string | null;
organismo_name: string | null;
is_active: boolean;
last_login: string | null;
phone: string | null;
street: string | null;
city: string | null;
state: string | null;
zip_code: string | null;
created_at: string;
updated_at: string;
}
@@ -28,14 +36,28 @@ export interface CreateUserInput {
password: string;
name: string;
role_id: string;
project_id?: string | null;
organismo_operador_id?: string | null;
is_active?: boolean;
phone?: string | null;
street?: string | null;
city?: string | null;
state?: string | null;
zip_code?: string | null;
}
export interface UpdateUserInput {
email?: string;
name?: string;
role_id?: string;
project_id?: string | null;
organismo_operador_id?: string | null;
is_active?: boolean;
phone?: string | null;
street?: string | null;
city?: string | null;
state?: string | null;
zip_code?: string | null;
}
export interface ChangePasswordInput {

View File

@@ -0,0 +1,257 @@
/**
* NotificationDropdown Component
* Displays a dropdown with user notifications
*/
import React, { useEffect } from 'react';
import { X, Check, Trash2, AlertCircle } from 'lucide-react';
import { useNotifications } from '../hooks/useNotifications';
import type { Notification } from '../api/notifications';
interface NotificationDropdownProps {
isOpen: boolean;
onClose: () => void;
}
/**
* Format timestamp to relative time (e.g., "2 hours ago")
*/
function formatTimeAgo(timestamp: string): string {
const now = new Date();
const created = new Date(timestamp);
const diffMs = now.getTime() - created.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
return created.toLocaleDateString();
}
/**
* Single notification item component
*/
const NotificationItem: React.FC<{
notification: Notification;
onMarkAsRead: (id: string) => void;
onDelete: (id: string) => void;
}> = ({ notification, onMarkAsRead, onDelete }) => {
const handleClick = () => {
if (!notification.is_read) {
onMarkAsRead(notification.id);
}
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
onDelete(notification.id);
};
return (
<div
className={`p-4 border-b border-gray-200 hover:bg-gray-50 transition cursor-pointer ${
!notification.is_read ? 'bg-blue-50' : 'bg-white'
}`}
onClick={handleClick}
>
<div className="flex items-start gap-3">
{/* Icon */}
<div className={`flex-shrink-0 mt-1 ${
notification.notification_type === 'NEGATIVE_FLOW' ? 'text-red-500' : 'text-blue-500'
}`}>
<AlertCircle size={20} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-semibold text-gray-900 truncate">
{notification.title}
</h4>
{!notification.is_read && (
<span className="flex-shrink-0 w-2 h-2 bg-blue-600 rounded-full mt-1.5" />
)}
</div>
<p className="mt-1 text-sm text-gray-600 line-clamp-2">
{notification.message}
</p>
{notification.flow_value !== null && (
<p className="mt-1 text-xs text-red-600 font-medium">
Flow value: {notification.flow_value} units
</p>
)}
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-gray-400">
{formatTimeAgo(notification.created_at)}
</span>
<button
onClick={handleDelete}
className="text-gray-400 hover:text-red-600 transition"
title="Delete notification"
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
</div>
);
};
/**
* Main NotificationDropdown component
*/
const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ isOpen, onClose }) => {
const {
notifications,
loading,
error,
hasMore,
fetchNotifications,
fetchMore,
markAsRead,
markAllAsRead,
deleteNotification,
} = useNotifications();
// Fetch notifications when dropdown opens
useEffect(() => {
if (isOpen) {
fetchNotifications();
}
}, [isOpen, fetchNotifications]);
// Close dropdown on Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}
}, [isOpen, onClose]);
if (!isOpen) return null;
const handleMarkAllAsRead = async () => {
try {
await markAllAsRead();
} catch (err) {
console.error('Error marking all as read:', err);
}
};
const handleMarkAsRead = async (id: string) => {
try {
await markAsRead(id);
} catch (err) {
console.error('Error marking as read:', err);
}
};
const handleDelete = async (id: string) => {
try {
await deleteNotification(id);
} catch (err) {
console.error('Error deleting notification:', err);
}
};
const unreadCount = notifications.filter(n => !n.is_read).length;
return (
<div className="absolute right-0 mt-2 w-96 rounded-xl bg-white border border-gray-200 shadow-xl overflow-hidden z-50">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
<div>
<h3 className="text-sm font-semibold text-gray-900">Notifications</h3>
{unreadCount > 0 && (
<p className="text-xs text-gray-500">{unreadCount} unread</p>
)}
</div>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={handleMarkAllAsRead}
className="text-xs text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
title="Mark all as read"
>
<Check size={14} />
Mark all read
</button>
)}
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition"
title="Close"
>
<X size={18} />
</button>
</div>
</div>
{/* Content */}
<div className="max-h-96 overflow-y-auto">
{loading && notifications.length === 0 ? (
<div className="p-8 text-center text-gray-500 text-sm">
Loading notifications...
</div>
) : error ? (
<div className="p-8 text-center text-red-600 text-sm">
{error}
</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center">
<div className="text-gray-400 mb-2">
<AlertCircle size={32} className="mx-auto" />
</div>
<p className="text-sm text-gray-500">No notifications</p>
<p className="text-xs text-gray-400 mt-1">
You're all caught up!
</p>
</div>
) : (
<>
{notifications.map(notification => (
<NotificationItem
key={notification.id}
notification={notification}
onMarkAsRead={handleMarkAsRead}
onDelete={handleDelete}
/>
))}
{hasMore && (
<div className="p-3 text-center border-t border-gray-200">
<button
onClick={fetchMore}
disabled={loading}
className="text-xs text-blue-600 hover:text-blue-700 font-medium disabled:opacity-50"
>
{loading ? 'Loading...' : 'Load more'}
</button>
</div>
)}
</>
)}
</div>
</div>
);
};
export default NotificationDropdown;

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import {
Home,
Settings,
@@ -6,8 +6,12 @@ import {
ExpandLess,
Menu,
People,
Cable,
BarChart,
Business,
} from "@mui/icons-material";
import { Page } from "../../App";
import { getCurrentUserRole } from "../../api/auth";
interface SidebarProps {
setPage: (page: Page) => void;
@@ -16,9 +20,16 @@ interface SidebarProps {
export default function Sidebar({ setPage }: SidebarProps) {
const [systemOpen, setSystemOpen] = useState(true);
const [usersOpen, setUsersOpen] = useState(true);
const [conectoresOpen, setConectoresOpen] = useState(true);
const [analyticsOpen, setAnalyticsOpen] = useState(true);
const [pinned, setPinned] = useState(false);
const [hovered, setHovered] = useState(false);
const userRole = useMemo(() => getCurrentUserRole(), []);
const isAdmin = userRole?.toUpperCase() === 'ADMIN';
const isOrganismo = userRole?.toUpperCase() === 'ORGANISMO_OPERADOR';
const isOperador = userRole?.toUpperCase() === 'OPERATOR';
const isExpanded = pinned || hovered;
return (
@@ -49,7 +60,7 @@ export default function Sidebar({ setPage }: SidebarProps) {
{/* MENU */}
<div className="flex-1 py-4 px-2 overflow-y-auto">
<ul className="space-y-1 text-white text-sm">
{/* DASHBOARD */}
{/* DASHBOARD - visible to all */}
<li>
<button
onClick={() => setPage("home")}
@@ -60,7 +71,7 @@ export default function Sidebar({ setPage }: SidebarProps) {
</button>
</li>
{/* PROJECT MANAGEMENT */}
{/* PROJECT MANAGEMENT - visible to all */}
<li>
<button
onClick={() => isExpanded && setSystemOpen(!systemOpen)}
@@ -114,48 +125,183 @@ export default function Sidebar({ setPage }: SidebarProps) {
Consumo
</button>
</li>
<li>
<button
onClick={() => setPage("historico")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
Histórico
</button>
</li>
{/* Auditoria - ADMIN only */}
{isAdmin && (
<li>
<button
onClick={() => setPage("auditoria")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
Auditoría
</button>
</li>
)}
</ul>
)}
</li>
{/* USERS MANAGEMENT */}
<li>
<button
onClick={() => isExpanded && setUsersOpen(!usersOpen)}
className="flex items-center w-full px-2 py-2 rounded-md hover:bg-white/10 font-bold"
>
<People className="w-5 h-5 shrink-0" />
{isExpanded && (
<>
<span className="ml-3 flex-1 text-left">
Users Management
</span>
{usersOpen ? <ExpandLess /> : <ExpandMore />}
</>
{/* USERS MANAGEMENT - ADMIN and ORGANISMO_OPERADOR */}
{(isAdmin || isOrganismo) && (
<li>
<button
onClick={() => isExpanded && setUsersOpen(!usersOpen)}
className="flex items-center w-full px-2 py-2 rounded-md hover:bg-white/10 font-bold"
>
<People className="w-5 h-5 shrink-0" />
{isExpanded && (
<>
<span className="ml-3 flex-1 text-left">
Users Management
</span>
{usersOpen ? <ExpandLess /> : <ExpandMore />}
</>
)}
</button>
{isExpanded && usersOpen && (
<ul className="mt-1 space-y-1 text-xs">
<li>
<button
onClick={() => setPage("users")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
Users
</button>
</li>
{/* Roles - ADMIN only */}
{isAdmin && (
<li>
<button
onClick={() => setPage("roles")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
Roles
</button>
</li>
)}
</ul>
)}
</button>
</li>
)}
{isExpanded && usersOpen && (
<ul className="mt-1 space-y-1 text-xs">
<li>
<button
onClick={() => setPage("users")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
Users
</button>
</li>
<li>
<button
onClick={() => setPage("roles")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
Roles
</button>
</li>
</ul>
)}
</li>
{/* ORGANISMOS OPERADORES - ADMIN only */}
{isAdmin && (
<li>
<button
onClick={() => setPage("organismos")}
className="flex items-center w-full px-2 py-2 rounded-md hover:bg-white/10 font-bold"
>
<Business className="w-5 h-5 shrink-0" />
{isExpanded && <span className="ml-3">Organismos Operadores</span>}
</button>
</li>
)}
{/* CONECTORES - ADMIN only */}
{isAdmin && (
<li>
<button
onClick={() => isExpanded && setConectoresOpen(!conectoresOpen)}
className="flex items-center w-full px-2 py-2 rounded-md hover:bg-white/10 font-bold"
>
<Cable className="w-5 h-5 shrink-0" />
{isExpanded && (
<>
<span className="ml-3 flex-1 text-left">
Conectores
</span>
{conectoresOpen ? <ExpandLess /> : <ExpandMore />}
</>
)}
</button>
{isExpanded && conectoresOpen && (
<ul className="mt-1 space-y-1 text-xs">
<li>
<button
onClick={() => setPage("sh-meters")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
SH-METERS
</button>
</li>
<li>
<button
onClick={() => setPage("xmeters")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
XMETERS
</button>
</li>
<li>
<button
onClick={() => setPage("tts")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
TTS
</button>
</li>
</ul>
)}
</li>
)}
{/* ANALYTICS - ADMIN and ORGANISMO_OPERADOR */}
{(isAdmin || isOrganismo) && (
<li>
<button
onClick={() => isExpanded && setAnalyticsOpen(!analyticsOpen)}
className="flex items-center w-full px-2 py-2 rounded-md hover:bg-white/10 font-bold"
>
<BarChart className="w-5 h-5 shrink-0" />
{isExpanded && (
<>
<span className="ml-3 flex-1 text-left">Analytics</span>
{analyticsOpen ? <ExpandLess /> : <ExpandMore />}
</>
)}
</button>
{isExpanded && analyticsOpen && (
<ul className="mt-1 space-y-1 text-xs">
<li>
<button
onClick={() => setPage("analytics-map")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
Mapa
</button>
</li>
<li>
<button
onClick={() => setPage("analytics-reports")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
Reportes
</button>
</li>
<li>
<button
onClick={() => setPage("analytics-server")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
Carga de Server
</button>
</li>
</ul>
)}
</li>
)}
</ul>
</div>
</aside>

View File

@@ -1,5 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Bell, User, LogOut } from "lucide-react";
import { Bell, User, LogOut, Sun, Moon } from "lucide-react";
import NotificationDropdown from "../NotificationDropdown";
import { useNotifications } from "../../hooks/useNotifications";
import ProjectBadge from "./common/ProjectBadge";
interface TopMenuProps {
page: string;
@@ -29,7 +32,32 @@ const TopMenu: React.FC<TopMenuProps> = ({
onRequestLogout,
}) => {
const [openUserMenu, setOpenUserMenu] = useState(false);
const [openNotifications, setOpenNotifications] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(() => {
return document.documentElement.classList.contains("dark");
});
const menuRef = useRef<HTMLDivElement | null>(null);
const notificationRef = useRef<HTMLDivElement | null>(null);
const { unreadCount } = useNotifications();
const toggleTheme = () => {
const root = document.documentElement;
const newIsDark = !isDarkMode;
if (newIsDark) {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
setIsDarkMode(newIsDark);
// Save to localStorage
const settings = JSON.parse(localStorage.getItem("water_project_settings_v1") || "{}");
settings.theme = newIsDark ? "dark" : "light";
localStorage.setItem("water_project_settings_v1", JSON.stringify(settings));
};
const initials = useMemo(() => {
const parts = (userName || "").trim().split(/\s+/).filter(Boolean);
@@ -48,6 +76,16 @@ const TopMenu: React.FC<TopMenuProps> = ({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [openUserMenu]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (!openNotifications) return;
const el = notificationRef.current;
if (el && !el.contains(e.target as Node)) setOpenNotifications(false);
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [openNotifications]);
useEffect(() => {
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") setOpenUserMenu(false);
@@ -65,7 +103,7 @@ const TopMenu: React.FC<TopMenuProps> = ({
}}
>
{/* IZQUIERDA */}
<div className="flex items-center gap-2 text-sm font-medium opacity-90">
<div className="flex items-center gap-4 text-sm font-medium opacity-90">
{page !== "home" && (
<>
<span className="capitalize">{page}</span>
@@ -77,18 +115,44 @@ const TopMenu: React.FC<TopMenuProps> = ({
)}
</>
)}
<ProjectBadge />
</div>
{/* DERECHA */}
<div className="flex items-center gap-3">
{/* Theme Toggle */}
<button
aria-label="Notificaciones"
aria-label={isDarkMode ? "Cambiar a modo claro" : "Cambiar a modo oscuro"}
className="p-2 rounded-full hover:bg-white/10 transition"
type="button"
onClick={toggleTheme}
title={isDarkMode ? "Modo claro" : "Modo oscuro"}
>
<Bell size={20} />
{isDarkMode ? <Sun size={20} /> : <Moon size={20} />}
</button>
<div className="relative" ref={notificationRef}>
<button
aria-label="Notificaciones"
className="relative p-2 rounded-full hover:bg-white/10 transition"
type="button"
onClick={() => setOpenNotifications(!openNotifications)}
>
<Bell size={20} />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
<NotificationDropdown
isOpen={openNotifications}
onClose={() => setOpenNotifications(false)}
/>
</div>
{/* USER MENU */}
<div className="relative" ref={menuRef}>
<button
@@ -116,14 +180,14 @@ const TopMenu: React.FC<TopMenuProps> = ({
role="menu"
className="
absolute right-0 mt-2 w-80
rounded-2xl bg-white border border-slate-200
rounded-2xl bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800
shadow-xl overflow-hidden z-50
"
>
{/* Header usuario */}
<div className="px-5 py-4 border-b border-slate-200">
<div className="px-5 py-4 border-b border-slate-200 dark:border-zinc-800">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-full bg-slate-100 overflow-hidden flex items-center justify-center">
<div className="w-11 h-11 rounded-full bg-slate-100 dark:bg-zinc-800 overflow-hidden flex items-center justify-center">
{avatarUrl ? (
<img
src={avatarUrl}
@@ -131,22 +195,22 @@ const TopMenu: React.FC<TopMenuProps> = ({
className="w-full h-full object-cover"
/>
) : (
<span className="text-sm font-semibold text-slate-700">
<span className="text-sm font-semibold text-slate-700 dark:text-zinc-200">
{initials}
</span>
)}
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-900 truncate">
<div className="text-sm font-semibold text-slate-900 dark:text-white truncate">
{userName}
</div>
{userEmail ? (
<div className="text-xs text-slate-500 truncate">
<div className="text-xs text-slate-500 dark:text-zinc-400 truncate">
{userEmail}
</div>
) : (
<div className="text-xs text-slate-400 truncate"></div>
<div className="text-xs text-slate-400 dark:text-zinc-500 truncate"></div>
)}
</div>
</div>
@@ -161,14 +225,14 @@ const TopMenu: React.FC<TopMenuProps> = ({
left={<User size={16} />}
/>
<div className="h-px bg-slate-200 my-1" />
<div className="h-px bg-slate-200 dark:bg-zinc-800 my-1" />
<MenuItem
label="Cerrar sesión"
tone="danger"
onClick={() => {
setOpenUserMenu(false);
onRequestLogout?.(); // ✅ abre confirm modal en App
onRequestLogout?.();
}}
left={<LogOut size={16} />}
/>
@@ -202,13 +266,13 @@ function MenuItem({
className={[
"w-full flex items-center gap-3 px-5 py-3 text-sm text-left",
"transition-colors",
disabled ? "opacity-40 cursor-not-allowed" : "hover:bg-slate-100",
disabled ? "opacity-40 cursor-not-allowed" : "hover:bg-slate-100 dark:hover:bg-zinc-800",
tone === "danger"
? "text-red-600 hover:text-red-700"
: "text-slate-700",
? "text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
: "text-slate-700 dark:text-zinc-200",
].join(" ")}
>
<span className="text-slate-400">{left}</span>
<span className="text-slate-400 dark:text-zinc-500">{left}</span>
<span className="font-medium">{label}</span>
</button>
);

View File

@@ -56,22 +56,22 @@ export default function ConfirmModal({
<div
ref={panelRef}
tabIndex={-1}
className="rounded-2xl bg-white border border-slate-200 shadow-xl overflow-hidden outline-none"
className="rounded-2xl bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-700 shadow-xl overflow-hidden outline-none"
>
<div className="px-6 py-4 border-b border-slate-200">
<div className="text-base font-semibold text-slate-900">{title}</div>
<div className="px-6 py-4 border-b border-slate-200 dark:border-zinc-700">
<div className="text-base font-semibold text-slate-900 dark:text-white">{title}</div>
</div>
<div className="px-6 py-5">
<p className="text-sm text-slate-700">{message}</p>
<p className="text-sm text-slate-700 dark:text-zinc-300">{message}</p>
</div>
<div className="px-6 py-4 border-t border-slate-200 flex justify-end gap-3">
<div className="px-6 py-4 border-t border-slate-200 dark:border-zinc-700 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
disabled={loading}
className="rounded-xl px-4 py-2 text-sm font-medium border border-slate-200 bg-white text-slate-700 hover:bg-slate-100 transition disabled:opacity-60"
className="rounded-xl px-4 py-2 text-sm font-medium border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-300 hover:bg-slate-100 dark:hover:bg-zinc-700 transition disabled:opacity-60"
>
{cancelText}
</button>

View File

@@ -150,10 +150,10 @@ export default function ProfileModal({
{/* Modal */}
<div className="relative mx-auto mt-16 w-[min(860px,calc(100vw-32px))]">
<div className="rounded-2xl bg-white shadow-xl border border-slate-200 overflow-hidden">
<div className="rounded-2xl bg-white dark:bg-zinc-900 shadow-xl border border-slate-200 dark:border-zinc-700 overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-slate-200">
<div className="text-base font-semibold text-slate-900">
<div className="px-6 py-4 border-b border-slate-200 dark:border-zinc-700">
<div className="text-base font-semibold text-slate-900 dark:text-white">
Editar perfil
</div>
</div>
@@ -162,9 +162,9 @@ export default function ProfileModal({
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-[260px_1fr] gap-6">
{/* LEFT: Avatar */}
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-5">
<div className="rounded-2xl border border-slate-200 dark:border-zinc-700 bg-slate-50 dark:bg-zinc-800 p-5">
<div className="flex flex-col items-center text-center">
<div className="w-28 h-28 rounded-2xl bg-white border border-slate-200 overflow-hidden flex items-center justify-center">
<div className="w-28 h-28 rounded-2xl bg-white dark:bg-zinc-700 border border-slate-200 dark:border-zinc-600 overflow-hidden flex items-center justify-center">
{computedAvatarSrc ? (
<img
src={computedAvatarSrc}
@@ -172,17 +172,17 @@ export default function ProfileModal({
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-slate-700 font-semibold text-2xl">
<div className="w-full h-full flex items-center justify-center text-slate-700 dark:text-zinc-200 font-semibold text-2xl">
{initials}
</div>
)}
</div>
<div className="mt-4">
<div className="text-sm font-semibold text-slate-900 truncate max-w-[220px]">
<div className="text-sm font-semibold text-slate-900 dark:text-white truncate max-w-[220px]">
{name || "Usuario"}
</div>
<div className="text-xs text-slate-500 truncate max-w-[220px]">
<div className="text-xs text-slate-500 dark:text-zinc-400 truncate max-w-[220px]">
{email || "correo@ejemplo.gob.mx"}
</div>
</div>
@@ -193,8 +193,8 @@ export default function ProfileModal({
disabled={!onUploadAvatar}
className={[
"mt-4 w-full rounded-xl px-4 py-2 text-sm font-medium",
"border border-slate-200 bg-white text-slate-700",
"hover:bg-slate-100 transition",
"border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-300",
"hover:bg-slate-100 dark:hover:bg-zinc-700 transition",
!onUploadAvatar ? "opacity-50 cursor-not-allowed" : "",
].join(" ")}
>
@@ -212,8 +212,8 @@ export default function ProfileModal({
</div>
{/* RIGHT: Form */}
<div className="rounded-2xl border border-slate-200 p-5">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
<div className="rounded-2xl border border-slate-200 dark:border-zinc-700 p-5">
<div className="text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wide">
correo electrónico
</div>
@@ -222,7 +222,7 @@ export default function ProfileModal({
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none focus:ring-2 focus:ring-slate-200"
className="w-full rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-4 py-2.5 text-sm text-slate-900 dark:text-zinc-100 outline-none focus:ring-2 focus:ring-slate-200"
placeholder="Nombre del usuario"
/>
</Field>
@@ -231,7 +231,7 @@ export default function ProfileModal({
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none focus:ring-2 focus:ring-slate-200"
className="w-full rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-4 py-2.5 text-sm text-slate-900 dark:text-zinc-100 outline-none focus:ring-2 focus:ring-slate-200"
placeholder="correo@organismo.gob.mx"
/>
</Field>
@@ -240,7 +240,7 @@ export default function ProfileModal({
<input
value={organismName}
onChange={(e) => setOrganismName(e.target.value)}
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none focus:ring-2 focus:ring-slate-200"
className="w-full rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-4 py-2.5 text-sm text-slate-900 dark:text-zinc-100 outline-none focus:ring-2 focus:ring-slate-200"
placeholder="Organismo operador"
/>
</Field>
@@ -250,11 +250,11 @@ export default function ProfileModal({
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-slate-200 flex items-center justify-end gap-3">
<div className="px-6 py-4 border-t border-slate-200 dark:border-zinc-700 flex items-center justify-end gap-3">
<button
type="button"
onClick={onClose}
className="rounded-xl px-4 py-2 text-sm font-medium border border-slate-200 bg-white text-slate-700 hover:bg-slate-100 transition"
className="rounded-xl px-4 py-2 text-sm font-medium border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-300 hover:bg-slate-100 dark:hover:bg-zinc-700 transition"
disabled={loading}
>
Cancelar
@@ -283,7 +283,7 @@ export default function ProfileModal({
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[90px_1fr] items-center gap-3">
<div className="text-sm font-medium text-slate-700">{label}</div>
<div className="text-sm font-medium text-slate-700 dark:text-zinc-300">{label}</div>
{children}
</div>
);

View File

@@ -0,0 +1,46 @@
import { useEffect, useState } from "react";
import { Building2 } from "lucide-react";
import { getCurrentUserProjectId, getCurrentUserRole } from "../../../api/auth";
import { fetchProject } from "../../../api/projects";
interface Project {
id: string;
name: string;
}
export default function ProjectBadge() {
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadProject = async () => {
const projectId = getCurrentUserProjectId();
const role = getCurrentUserRole();
if (role?.toUpperCase() !== 'ADMIN' && projectId) {
try {
const projectData = await fetchProject(projectId);
setProject(projectData);
} catch (err) {
console.error("Error loading user project:", err);
}
}
setLoading(false);
};
loadProject();
}, []);
if (loading || !project) {
return null;
}
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 border border-blue-200 rounded-lg text-sm">
<Building2 size={16} className="text-blue-600" />
<span className="text-blue-900 font-medium">
Proyecto: <span className="font-semibold">{project.name}</span>
</span>
</div>
);
}

View File

@@ -0,0 +1,183 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import * as notificationsApi from '../api/notifications';
import type { Notification, NotificationFilters } from '../api/notifications';
interface UseNotificationsReturn {
notifications: Notification[];
unreadCount: number;
loading: boolean;
error: string | null;
hasMore: boolean;
page: number;
fetchNotifications: (filters?: NotificationFilters) => Promise<void>;
fetchMore: () => Promise<void>;
refreshUnreadCount: () => Promise<void>;
markAsRead: (id: string) => Promise<void>;
markAllAsRead: () => Promise<void>;
deleteNotification: (id: string) => Promise<void>;
refresh: () => Promise<void>;
}
/**
* Custom hook for managing notifications
* @param autoRefreshInterval - Interval in milliseconds to auto-refresh unread count (default: 30000ms)
* @returns Object with notifications data and methods
*/
export function useNotifications(autoRefreshInterval: number = 30000): UseNotificationsReturn {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState<number>(0);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState<boolean>(false);
const [page, setPage] = useState<number>(1);
const refreshIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchNotifications = useCallback(async (filters?: NotificationFilters) => {
try {
setLoading(true);
setError(null);
const response = await notificationsApi.fetchNotifications({
page: 1,
limit: 20,
...filters,
});
setNotifications(response.data);
setHasMore(response.pagination.hasNextPage);
setPage(response.pagination.page);
} catch (err) {
console.error('Error fetching notifications:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch notifications');
} finally {
setLoading(false);
}
}, []);
const fetchMore = useCallback(async () => {
if (!hasMore || loading) return;
try {
setLoading(true);
const response = await notificationsApi.fetchNotifications({
page: page + 1,
limit: 20,
});
setNotifications(prev => [...prev, ...response.data]);
setHasMore(response.pagination.hasNextPage);
setPage(response.pagination.page);
} catch (err) {
console.error('Error fetching more notifications:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch more notifications');
} finally {
setLoading(false);
}
}, [hasMore, loading, page]);
const refreshUnreadCount = useCallback(async () => {
try {
const count = await notificationsApi.getUnreadCount();
setUnreadCount(count);
} catch (err) {
console.error('Error fetching unread count:', err);
}
}, []);
const markAsRead = useCallback(async (id: string) => {
try {
await notificationsApi.markAsRead(id);
setNotifications(prev =>
prev.map(notification =>
notification.id === id
? { ...notification, is_read: true, read_at: new Date().toISOString() }
: notification
)
);
await refreshUnreadCount();
} catch (err) {
console.error('Error marking notification as read:', err);
throw err;
}
}, [refreshUnreadCount]);
const markAllAsRead = useCallback(async () => {
try {
await notificationsApi.markAllAsRead();
setNotifications(prev =>
prev.map(notification => ({
...notification,
is_read: true,
read_at: new Date().toISOString(),
}))
);
setUnreadCount(0);
} catch (err) {
console.error('Error marking all notifications as read:', err);
throw err;
}
}, []);
const deleteNotification = useCallback(async (id: string) => {
try {
await notificationsApi.deleteNotification(id);
const deletedNotification = notifications.find(n => n.id === id);
setNotifications(prev => prev.filter(notification => notification.id !== id));
if (deletedNotification && !deletedNotification.is_read) {
setUnreadCount(prev => Math.max(0, prev - 1));
}
} catch (err) {
console.error('Error deleting notification:', err);
throw err;
}
}, [notifications]);
const refresh = useCallback(async () => {
await Promise.all([
fetchNotifications(),
refreshUnreadCount(),
]);
}, [fetchNotifications, refreshUnreadCount]);
useEffect(() => {
refreshUnreadCount();
if (autoRefreshInterval > 0) {
refreshIntervalRef.current = setInterval(() => {
refreshUnreadCount();
}, autoRefreshInterval);
}
return () => {
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current);
}
};
}, [autoRefreshInterval, refreshUnreadCount]);
return {
notifications,
unreadCount,
loading,
error,
hasMore,
page,
fetchNotifications,
fetchMore,
refreshUnreadCount,
markAsRead,
markAllAsRead,
deleteNotification,
refresh,
};
}

View File

@@ -1 +1,152 @@
@import 'tailwindcss'
@import 'tailwindcss';
/* Dark mode configuration */
@custom-variant dark (&:where(.dark, .dark *));
/* Base styles */
:root {
color-scheme: light;
}
.dark {
color-scheme: dark;
}
/* Dark mode body */
body {
@apply bg-slate-50 text-zinc-900;
}
.dark body,
body:where(.dark *) {
@apply bg-zinc-950 text-zinc-100;
}
/* MaterialTable Dark Mode Overrides */
.dark .MuiPaper-root {
background-color: #18181b !important; /* zinc-900 */
color: #fafafa !important; /* zinc-50 */
}
.dark .MuiTableCell-root {
color: #e4e4e7 !important; /* zinc-200 */
border-bottom-color: #3f3f46 !important; /* zinc-700 */
}
.dark .MuiTableCell-head {
background-color: #18181b !important; /* zinc-900 */
color: #fafafa !important; /* zinc-50 */
}
.dark .MuiTableRow-root:hover {
background-color: #27272a !important; /* zinc-800 */
}
.dark .MuiTableRow-root.Mui-selected,
.dark .MuiTableRow-root.Mui-selected:hover {
background-color: #3f3f46 !important; /* zinc-700 */
}
.dark .MuiToolbar-root {
background-color: #18181b !important; /* zinc-900 */
color: #fafafa !important; /* zinc-50 */
}
.dark .MuiTypography-root {
color: #fafafa !important; /* zinc-50 */
}
.dark .MuiTablePagination-root {
color: #a1a1aa !important; /* zinc-400 */
}
.dark .MuiTablePagination-selectIcon {
color: #a1a1aa !important; /* zinc-400 */
}
.dark .MuiIconButton-root {
color: #a1a1aa !important; /* zinc-400 */
}
.dark .MuiIconButton-root:hover {
background-color: #3f3f46 !important; /* zinc-700 */
}
.dark .MuiIconButton-root.Mui-disabled {
color: #52525b !important; /* zinc-600 */
}
.dark .MuiInputBase-root {
color: #e4e4e7 !important; /* zinc-200 */
}
.dark .MuiInput-underline:before {
border-bottom-color: #3f3f46 !important; /* zinc-700 */
}
.dark .MuiSelect-icon {
color: #a1a1aa !important; /* zinc-400 */
}
.dark .MuiTableSortLabel-root {
color: #fafafa !important; /* zinc-50 */
}
.dark .MuiTableSortLabel-root:hover {
color: #ffffff !important;
}
.dark .MuiTableSortLabel-root.Mui-active {
color: #60a5fa !important; /* blue-400 */
}
.dark .MuiTableSortLabel-icon {
color: #60a5fa !important; /* blue-400 */
}
/* Dark mode for table row active/selected state */
.dark .MuiTableBody-root .MuiTableRow-root[style*="background-color: rgb(238, 242, 255)"],
.dark .MuiTableBody-root .MuiTableRow-root[style*="#EEF2FF"] {
background-color: #3f3f46 !important; /* zinc-700 */
}
/* Fix for inline styles - override white backgrounds */
.dark [style*="background-color: rgb(255, 255, 255)"],
.dark [style*="background-color: #FFFFFF"],
.dark [style*="background-color: #fff"],
.dark [style*="backgroundColor: rgb(255, 255, 255)"] {
background-color: #18181b !important; /* zinc-900 */
}
/* Dark mode form elements - global overrides */
.dark input:not([type="checkbox"]):not([type="radio"]),
.dark select,
.dark textarea {
background-color: #27272a !important; /* zinc-800 */
border-color: #3f3f46 !important; /* zinc-700 */
color: #fafafa !important; /* zinc-50 */
}
.dark input::placeholder,
.dark textarea::placeholder {
color: #71717a !important; /* zinc-500 */
}
.dark input:focus,
.dark select:focus,
.dark textarea:focus {
border-color: #3b82f6 !important; /* blue-500 */
outline: none;
}
.dark select option {
background-color: #27272a; /* zinc-800 */
color: #fafafa; /* zinc-50 */
}
/* Dark mode for modals */
.dark .modal-content,
.dark [class*="bg-white"][class*="rounded-xl"][class*="p-6"] {
background-color: #18181b !important; /* zinc-900 */
border: 1px solid #3f3f46 !important; /* zinc-700 */
}

502
src/pages/AuditoriaPage.tsx Normal file
View File

@@ -0,0 +1,502 @@
import { useState, useEffect } from "react";
import { Search, Filter, RefreshCw, Download, Eye } from "lucide-react";
import {
getAuditLogs,
type AuditLog,
type AuditAction,
} from "../api/audit";
export default function AuditoriaPage() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [selectedAction, setSelectedAction] = useState<AuditAction | "">("");
const [selectedTable, setSelectedTable] = useState("");
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
const [showDetails, setShowDetails] = useState(false);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
const [limit, setLimit] = useState(10);
const actions: AuditAction[] = [
"CREATE",
"UPDATE",
"DELETE",
"LOGIN",
"LOGOUT",
"READ",
"EXPORT",
"BULK_UPLOAD",
"STATUS_CHANGE",
"PERMISSION_CHANGE",
];
useEffect(() => {
fetchAuditLogs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage, selectedAction, selectedTable, limit]);
const fetchAuditLogs = async () => {
try {
setLoading(true);
const filters: any = {
page: currentPage,
limit,
};
if (selectedAction) filters.action = selectedAction;
if (selectedTable) filters.tableName = selectedTable;
const response = await getAuditLogs(filters);
setLogs(response.data);
setTotal(response.pagination.total);
setTotalPages(response.pagination.totalPages);
} catch (error) {
console.error("Failed to fetch audit logs:", error);
setLogs([]);
} finally {
setLoading(false);
}
};
const handleRefresh = () => {
setCurrentPage(1);
fetchAuditLogs();
};
const handleClearFilters = () => {
setSelectedAction("");
setSelectedTable("");
setSearch("");
setCurrentPage(1);
};
const handleViewDetails = (log: AuditLog) => {
setSelectedLog(log);
setShowDetails(true);
};
const handleLimitChange = (newLimit: number) => {
setLimit(newLimit);
setCurrentPage(1);
};
const getActionColor = (action: AuditAction) => {
const colors: Record<AuditAction, string> = {
CREATE: "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400",
UPDATE: "bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-400",
DELETE: "bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400",
LOGIN: "bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-400",
LOGOUT: "bg-gray-100 dark:bg-zinc-700 text-gray-800 dark:text-zinc-300",
READ: "bg-cyan-100 dark:bg-cyan-900/30 text-cyan-800 dark:text-cyan-400",
EXPORT: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-400",
BULK_UPLOAD: "bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-400",
STATUS_CHANGE: "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-800 dark:text-indigo-400",
PERMISSION_CHANGE: "bg-pink-100 dark:bg-pink-900/30 text-pink-800 dark:text-pink-400",
};
return colors[action] || "bg-gray-100 dark:bg-zinc-700 text-gray-800 dark:text-zinc-300";
};
const filteredLogs = logs.filter((log) => {
if (!search) return true;
const searchLower = search.toLowerCase();
return (
log.user_email.toLowerCase().includes(searchLower) ||
log.user_name.toLowerCase().includes(searchLower) ||
log.table_name.toLowerCase().includes(searchLower) ||
log.description?.toLowerCase().includes(searchLower)
);
});
const uniqueTables = Array.from(new Set(logs.map((log) => log.table_name)));
return (
<div className="flex h-full dark:bg-zinc-950">
{/* Sidebar */}
<aside className="w-64 border-r border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4">
<h2 className="text-lg font-semibold mb-4 dark:text-white">Filtros</h2>
{/* Action Filter */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
Acción
</label>
<select
value={selectedAction}
onChange={(e) => setSelectedAction(e.target.value as AuditAction | "")}
className="w-full border border-gray-300 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 rounded-md px-3 py-2 text-sm"
>
<option value="">Todas las acciones</option>
{actions.map((action) => (
<option key={action} value={action}>
{action}
</option>
))}
</select>
</div>
{/* Table Filter */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
Tabla
</label>
<select
value={selectedTable}
onChange={(e) => setSelectedTable(e.target.value)}
className="w-full border border-gray-300 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 rounded-md px-3 py-2 text-sm"
>
<option value="">Todas las tablas</option>
{uniqueTables.map((table) => (
<option key={table} value={table}>
{table}
</option>
))}
</select>
</div>
{/* Clear Filters */}
<button
onClick={handleClearFilters}
className="w-full bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-200 px-3 py-2 rounded-md text-sm"
>
Limpiar filtros
</button>
{/* Statistics */}
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-zinc-700">
<h3 className="text-sm font-semibold text-gray-700 dark:text-zinc-300 mb-2">
Estadísticas
</h3>
<div className="text-sm text-gray-600 dark:text-zinc-400">
<p>Total de registros: {total}</p>
<p>Página actual: {currentPage} de {totalPages}</p>
</div>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 flex flex-col">
{/* Header */}
<div className="border-b border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-6 py-4">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Auditoría del Sistema
</h1>
<div className="flex gap-2">
<button
onClick={handleRefresh}
disabled={loading}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-4 py-2 rounded-md flex items-center gap-2"
>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
Actualizar
</button>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Buscar por usuario, email, tabla o descripción..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 rounded-md"
/>
</div>
</div>
{/* Table */}
<div className="flex-1 overflow-auto p-6">
{loading ? (
<div className="text-center py-12 text-gray-500 dark:text-zinc-400">
Cargando registros...
</div>
) : filteredLogs.length === 0 ? (
<div className="text-center py-12 text-gray-500 dark:text-zinc-400">
No se encontraron registros de auditoría
</div>
) : (
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-zinc-700">
<thead className="bg-gray-50 dark:bg-zinc-800">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
Fecha/Hora
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
Usuario
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
Acción
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
Tabla
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
Descripción
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
Estado
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
Acciones
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-zinc-900 divide-y divide-gray-200 dark:divide-zinc-700">
{filteredLogs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50 dark:hover:bg-zinc-800">
<td className="px-4 py-3 text-sm text-gray-900 dark:text-zinc-100 whitespace-nowrap">
{new Date(log.created_at).toLocaleString("es-MX")}
</td>
<td className="px-4 py-3 text-sm">
<div className="font-medium text-gray-900 dark:text-zinc-100">
{log.user_name}
</div>
<div className="text-gray-500 dark:text-zinc-400">{log.user_email}</div>
</td>
<td className="px-4 py-3 text-sm">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getActionColor(
log.action
)}`}
>
{log.action}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-zinc-100">
{log.table_name}
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-zinc-400">
{log.description || "-"}
</td>
<td className="px-4 py-3 text-sm">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
log.success
? "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400"
: "bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400"
}`}
>
{log.success ? "Éxito" : "Fallo"}
</span>
</td>
<td className="px-4 py-3 text-sm">
<button
onClick={() => handleViewDetails(log)}
className="text-blue-600 hover:text-blue-800"
>
<Eye className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{!loading && logs.length > 0 && (
<div className="mt-4 flex flex-wrap items-center justify-between gap-4 px-4">
{/* Page Info */}
<div className="text-sm text-gray-600 dark:text-zinc-400">
Mostrando{" "}
<span className="font-semibold text-gray-800 dark:text-zinc-200">
{(currentPage - 1) * limit + 1}
</span>{" "}
a{" "}
<span className="font-semibold text-gray-800 dark:text-zinc-200">
{Math.min(currentPage * limit, total)}
</span>{" "}
de{" "}
<span className="font-semibold text-gray-800 dark:text-zinc-200">{total}</span>{" "}
registros
</div>
<div className="flex items-center gap-4">
{/* Page Size Selector */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-zinc-400">Filas por página:</span>
<select
value={limit}
onChange={(e) => handleLimitChange(Number(e.target.value))}
className="px-3 py-1.5 text-sm bg-white dark:bg-zinc-800 dark:text-zinc-100 border border-gray-300 dark:border-zinc-700 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
</div>
{/* Page Navigation */}
{totalPages > 1 && (
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-4 py-2 border border-gray-300 dark:border-zinc-700 rounded-md hover:bg-gray-50 dark:hover:bg-zinc-800 dark:text-zinc-300 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Anterior
</button>
<span className="px-4 py-2 text-sm text-gray-700 dark:text-zinc-300">
Página {currentPage} de {totalPages}
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-4 py-2 border border-gray-300 dark:border-zinc-700 rounded-md hover:bg-gray-50 dark:hover:bg-zinc-800 dark:text-zinc-300 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Siguiente
</button>
</div>
)}
</div>
</div>
)}
</div>
</main>
{/* Details Modal */}
{showDetails && selectedLog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-auto m-4 dark:border dark:border-zinc-700">
<div className="p-6 border-b border-gray-200 dark:border-zinc-700">
<h2 className="text-xl font-semibold dark:text-white">Detalles del Registro</h2>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300">
ID
</label>
<p className="text-sm text-gray-900 dark:text-zinc-100 font-mono">
{selectedLog.id}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300">
Fecha/Hora
</label>
<p className="text-sm text-gray-900 dark:text-zinc-100">
{new Date(selectedLog.created_at).toLocaleString("es-MX")}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300">
Usuario
</label>
<p className="text-sm text-gray-900 dark:text-zinc-100">{selectedLog.user_name}</p>
<p className="text-xs text-gray-500 dark:text-zinc-400">{selectedLog.user_email}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300">
Acción
</label>
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getActionColor(
selectedLog.action
)}`}
>
{selectedLog.action}
</span>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300">
Tabla
</label>
<p className="text-sm text-gray-900 dark:text-zinc-100">{selectedLog.table_name}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300">
Record ID
</label>
<p className="text-sm text-gray-900 dark:text-zinc-100 font-mono">
{selectedLog.record_id || "-"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300">
IP Address
</label>
<p className="text-sm text-gray-900 dark:text-zinc-100">
{selectedLog.ip_address || "-"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300">
Estado
</label>
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
selectedLog.success
? "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400"
: "bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400"
}`}
>
{selectedLog.success ? "Éxito" : "Fallo"}
</span>
</div>
</div>
{selectedLog.description && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
Descripción
</label>
<p className="text-sm text-gray-900 dark:text-zinc-100">{selectedLog.description}</p>
</div>
)}
{selectedLog.old_values && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
Valores Anteriores
</label>
<pre className="bg-gray-50 dark:bg-zinc-800 dark:text-zinc-300 p-3 rounded text-xs overflow-auto">
{JSON.stringify(selectedLog.old_values, null, 2)}
</pre>
</div>
)}
{selectedLog.new_values && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
Valores Nuevos
</label>
<pre className="bg-gray-50 dark:bg-zinc-800 dark:text-zinc-300 p-3 rounded text-xs overflow-auto">
{JSON.stringify(selectedLog.new_values, null, 2)}
</pre>
</div>
)}
{selectedLog.error_message && (
<div>
<label className="block text-sm font-medium text-red-700 mb-2">
Mensaje de Error
</label>
<p className="text-sm text-red-900 bg-red-50 p-3 rounded">
{selectedLog.error_message}
</p>
</div>
)}
</div>
<div className="p-6 border-t border-gray-200 dark:border-zinc-700 flex justify-end">
<button
onClick={() => setShowDetails(false)}
className="px-4 py-2 bg-gray-200 dark:bg-zinc-700 hover:bg-gray-300 dark:hover:bg-zinc-600 text-gray-800 dark:text-zinc-200 rounded-md"
>
Cerrar
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -10,24 +10,16 @@ import {
CartesianGrid,
} from "recharts";
import { fetchMeters, type Meter } from "../api/meters";
import { getAuditLogs, type AuditLog } from "../api/audit";
import { fetchNotifications, type Notification } from "../api/notifications";
import { fetchProjects, type Project } from "../api/projects";
import { getCurrentUserRole, getCurrentUserProjectId, getCurrentUserOrganismoId } from "../api/auth";
import { getAllOrganismos, type OrganismoOperador } from "../api/organismos";
import type { Page } from "../App";
import grhWatermark from "../assets/images/grhWatermark.png";
/* ================= TYPES ================= */
type OrganismStatus = "ACTIVO" | "INACTIVO";
type Organism = {
name: string;
region: string;
projects: number;
meters: number;
activeAlerts: number;
lastSync: string;
contact: string;
status: OrganismStatus;
};
type AlertItem = { company: string; type: string; time: string };
type HistoryItem = {
@@ -46,46 +38,13 @@ export default function Home({
setPage: (page: Page) => void;
navigateToMetersWithProject: (projectName: string) => void;
}) {
/* ================= ORGANISMS (MOCK) ================= */
const organismsData: Organism[] = [
{
name: "CESPT TIJUANA",
region: "Tijuana, BC",
projects: 6,
meters: 128,
activeAlerts: 0,
lastSync: "Hace 12 min",
contact: "Operaciones CESPT",
status: "ACTIVO",
},
{
name: "CESPT TECATE",
region: "Tecate, BC",
projects: 3,
meters: 54,
activeAlerts: 1,
lastSync: "Hace 40 min",
contact: "Mantenimiento",
status: "ACTIVO",
},
{
name: "CESPT MEXICALI",
region: "Mexicali, BC",
projects: 4,
meters: 92,
activeAlerts: 0,
lastSync: "Hace 1 h",
contact: "Supervisión",
status: "ACTIVO",
},
];
const [selectedOrganism, setSelectedOrganism] = useState<string>(
organismsData[0]?.name ?? "CESPT TIJUANA"
);
const [showOrganisms, setShowOrganisms] = useState(false);
const [organismQuery, setOrganismQuery] = useState("");
const userRole = useMemo(() => getCurrentUserRole(), []);
const userProjectId = useMemo(() => getCurrentUserProjectId(), []);
const userOrganismoId = useMemo(() => getCurrentUserOrganismoId(), []);
const isOperator = userRole?.toUpperCase() === 'OPERATOR';
const isAdmin = userRole?.toUpperCase() === 'ADMIN';
const isOrganismo = userRole?.toUpperCase() === 'ORGANISMO_OPERADOR';
/* ================= METERS ================= */
@@ -105,31 +64,126 @@ export default function Home({
loadMeters();
}, []);
// TODO: Reemplazar cuando el backend mande el organismo real (ej: meter.organismName)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const getOrganismFromMeter = (_m: Meter): string => {
return "CESPT TIJUANA";
const [projects, setProjects] = useState<Project[]>([]);
const loadProjects = async () => {
try {
const data = await fetchProjects();
setProjects(data);
} catch (err) {
console.error("Error loading projects:", err);
setProjects([]);
}
};
const filteredMeters = useMemo(
() => meters.filter((m) => getOrganismFromMeter(m) === selectedOrganism),
[meters, selectedOrganism]
);
useEffect(() => {
loadProjects();
}, []);
const [organismos, setOrganismos] = useState<OrganismoOperador[]>([]);
const [loadingOrganismos, setLoadingOrganismos] = useState(false);
const [selectedOrganism, setSelectedOrganism] = useState<string>(() => {
// ORGANISMO_OPERADOR: auto-filter to their organismo
if (userOrganismoId) return userOrganismoId;
return "Todos";
});
const [showOrganisms, setShowOrganisms] = useState(false);
const [organismQuery, setOrganismQuery] = useState("");
const loadOrganismos = async () => {
setLoadingOrganismos(true);
try {
const response = await getAllOrganismos({ pageSize: 100 });
setOrganismos(response.data);
} catch (err) {
console.error("Error loading organismos:", err);
setOrganismos([]);
} finally {
setLoadingOrganismos(false);
}
};
useEffect(() => {
if (isAdmin || isOrganismo) {
loadOrganismos();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
const loadAuditLogs = async () => {
setLoadingAuditLogs(true);
try {
const response = await getAuditLogs({ limit: 10, page: 1 });
setAuditLogs(response.data);
} catch (err) {
console.error("Error loading audit logs:", err);
setAuditLogs([]);
} finally {
setLoadingAuditLogs(false);
}
};
useEffect(() => {
if (isAdmin) {
loadAuditLogs();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filteredMeters = useMemo(() => {
// If user is OPERATOR, always filter by their assigned project
if (isOperator && userProjectId) {
return meters.filter((m) => m.projectId === userProjectId);
}
// For ORGANISMO_OPERADOR, filter by projects that belong to their organismo
if (isOrganismo && userOrganismoId) {
const orgProjects = projects.filter(p => p.organismoOperadorId === userOrganismoId);
const orgProjectIds = new Set(orgProjects.map(p => p.id));
return meters.filter((m) => m.projectId && orgProjectIds.has(m.projectId));
}
// For ADMIN users with organism selector
if (selectedOrganism === "Todos") {
return meters;
}
// ADMIN selected a specific organismo - filter by that organismo's projects
const orgProjects = projects.filter(p => p.organismoOperadorId === selectedOrganism);
const orgProjectIds = new Set(orgProjects.map(p => p.id));
return meters.filter((m) => m.projectId && orgProjectIds.has(m.projectId));
}, [meters, selectedOrganism, projects, isOperator, isOrganismo, userProjectId, userOrganismoId]);
const filteredProjects = useMemo(
() => [...new Set(filteredMeters.map((m) => m.projectName))].filter(Boolean) as string[],
[filteredMeters]
);
const chartData = useMemo(
() =>
filteredProjects.map((projectName) => ({
name: projectName,
meterCount: filteredMeters.filter((m) => m.projectName === projectName)
.length,
})),
[filteredProjects, filteredMeters]
);
const selectedOrganismoName = useMemo(() => {
if (selectedOrganism === "Todos") return null;
const org = organismos.find(o => o.id === selectedOrganism);
return org?.name || null;
}, [selectedOrganism, organismos]);
const chartData = useMemo(() => {
// If user is OPERATOR, show only their project
if (isOperator && userProjectId) {
const project = projects.find(p => p.id === userProjectId);
return [{
name: project?.name || "Mi Proyecto",
meterCount: filteredMeters.length,
}];
}
// Show meters grouped by project name
return filteredProjects.map((projectName) => ({
name: projectName,
meterCount: filteredMeters.filter((m) => m.projectName === projectName).length,
}));
}, [filteredProjects, filteredMeters, isOperator, userProjectId, projects]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleBarClick = (data: any) => {
@@ -142,50 +196,131 @@ export default function Home({
const filteredOrganisms = useMemo(() => {
const q = organismQuery.trim().toLowerCase();
if (!q) return organismsData;
return organismsData.filter((o) => o.name.toLowerCase().includes(q));
}, [organismQuery]);
if (!q) return organismos;
return organismos.filter((o) => o.name.toLowerCase().includes(q));
}, [organismQuery, organismos]);
/* ================= MOCK ALERTS / HISTORY ================= */
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loadingNotifications, setLoadingNotifications] = useState(false);
const alerts: AlertItem[] = [
{ company: "Empresa A", type: "Fuga", time: "Hace 2 horas" },
{ company: "Empresa C", type: "Consumo alto", time: "Hace 5 horas" },
{ company: "Empresa B", type: "Inactividad", time: "Hace 8 horas" },
];
const loadNotifications = async () => {
setLoadingNotifications(true);
try {
const response = await fetchNotifications({ limit: 10, page: 1 });
setNotifications(response.data);
} catch (err) {
console.error("Error loading notifications:", err);
setNotifications([]);
} finally {
setLoadingNotifications(false);
}
};
const history: HistoryItem[] = [
{
user: "GRH",
action: "Creó un nuevo medidor",
target: "SN001",
time: "Hace 5 minutos",
},
{
user: "CESPT",
action: "Actualizó concentrador",
target: "Planta 1",
time: "Hace 20 minutos",
},
{
user: "GRH",
action: "Eliminó un usuario",
target: "Juan Pérez",
time: "Hace 1 hora",
},
{
user: "CESPT",
action: "Creó un payload",
target: "Payload 12",
time: "Hace 2 horas",
},
{
user: "GRH",
action: "Actualizó medidor",
target: "SN002",
time: "Hace 3 horas",
},
];
useEffect(() => {
if (isAdmin || isOrganismo) {
loadNotifications();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const formatNotificationType = (type: string): string => {
const typeMap: Record<string, string> = {
NEGATIVE_FLOW: "Flujo Negativo",
SYSTEM_ALERT: "Alerta del Sistema",
MAINTENANCE: "Mantenimiento",
};
return typeMap[type] || type;
};
const formatNotificationTime = (dateString: string): string => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Hace un momento";
if (diffMins < 60) return `Hace ${diffMins} minuto${diffMins > 1 ? "s" : ""}`;
if (diffHours < 24) return `Hace ${diffHours} hora${diffHours > 1 ? "s" : ""}`;
if (diffDays < 7) return `Hace ${diffDays} día${diffDays > 1 ? "s" : ""}`;
return date.toLocaleDateString();
};
const alerts: AlertItem[] = useMemo(
() =>
notifications.map((n) => ({
company: n.meter_serial_number || "Sistema",
type: formatNotificationType(n.notification_type),
time: formatNotificationTime(n.created_at),
})),
[notifications]
);
const formatAuditAction = (action: string): string => {
const actionMap: Record<string, string> = {
CREATE: "creó",
UPDATE: "actualizó",
DELETE: "eliminó",
READ: "consultó",
LOGIN: "inició sesión",
LOGOUT: "cerró sesión",
EXPORT: "exportó",
BULK_UPLOAD: "cargó masivamente",
STATUS_CHANGE: "cambió estado de",
PERMISSION_CHANGE: "cambió permisos de",
};
return actionMap[action] || action.toLowerCase();
};
const formatTableName = (tableName: string): string => {
const tableMap: Record<string, string> = {
meters: "medidor",
concentrators: "concentrador",
projects: "proyecto",
users: "usuario",
roles: "rol",
gateways: "gateway",
devices: "dispositivo",
readings: "lectura",
webhooks: "webhook",
};
return tableMap[tableName] || tableName;
};
const formatRelativeTime = (dateString: string): string => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Hace un momento";
if (diffMins < 60) return `Hace ${diffMins} minuto${diffMins > 1 ? "s" : ""}`;
if (diffHours < 24) return `Hace ${diffHours} hora${diffHours > 1 ? "s" : ""}`;
if (diffDays < 7) return `Hace ${diffDays} día${diffDays > 1 ? "s" : ""}`;
return date.toLocaleDateString();
};
const formatAuditLog = (log: AuditLog): HistoryItem => {
const action = formatAuditAction(log.action);
const target = formatTableName(log.table_name);
const recordInfo = log.description || log.record_id || target;
return {
user: log.user_name || log.user_email || "Sistema",
action: action,
target: recordInfo,
time: formatRelativeTime(log.created_at),
};
};
const history: HistoryItem[] = useMemo(
() => auditLogs.map(formatAuditLog),
// eslint-disable-next-line react-hooks/exhaustive-deps
[auditLogs]
);
/* ================= KPIs (Optional) ================= */
@@ -204,99 +339,114 @@ export default function Home({
{/* Título + Selector */}
<div className="flex flex-col gap-3">
{/* ✅ Título + logo a la derecha */}
<div className="relative flex items-start justify-between gap-6">
<div className="relative z-10">
<h1 className="text-3xl font-bold text-gray-800">
Sistema de Tomas de Agua
</h1>
<p className="text-gray-600 mt-2">
Monitorea, administra y controla tus operaciones en un solo lugar.
</p>
</div>
<div className="relative flex items-start justify-between gap-6">
<div className="relative z-10">
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">
Sistema de Tomas de Agua
</h1>
<p className="text-gray-600 dark:text-zinc-300 mt-2">
Monitorea, administra y controla tus operaciones en un solo lugar.
</p>
</div>
{/* ✅ Logo con z-index bajo para NO tapar menús */}
<img
src={grhWatermark}
alt="Gestión de Recursos Hídricos"
className="relative z-0 h-10 w-auto opacity-80 select-none pointer-events-none shrink-0"
draggable={false}
/>
</div>
{/* ✅ Logo con z-index bajo para NO tapar menús */}
<img
src={grhWatermark}
alt="Gestión de Recursos Hídricos"
className="relative z-0 h-20 w-auto opacity-80 select-none pointer-events-none shrink-0"
draggable={false}
/>
</div>
{/* Cards de Secciones */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<div
className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-blue-50 transition cursor-pointer"
className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-blue-50 dark:hover:bg-zinc-800 transition cursor-pointer"
onClick={() => setPage("meters")}
>
<Cpu size={40} className="text-blue-600" />
<span className="font-semibold text-gray-700">Tomas</span>
<span className="font-semibold text-gray-700 dark:text-zinc-200">Tomas</span>
</div>
<div className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-red-50 transition">
<div
className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-red-50 dark:hover:bg-zinc-800 transition cursor-pointer"
onClick={() => setPage("auditoria")}
>
<Bell size={40} className="text-red-600" />
<span className="font-semibold text-gray-700">Alertas</span>
<span className="font-semibold text-gray-700 dark:text-zinc-200">Alertas</span>
</div>
<div className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-yellow-50 transition">
<div className="cursor-pointer bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-yellow-50 dark:hover:bg-zinc-800 transition"
onClick={() => setPage("projects")}
>
<Settings size={40} className="text-yellow-600" />
<span className="font-semibold text-gray-700">Mantenimiento</span>
<span className="font-semibold text-gray-700 dark:text-zinc-200">Proyectos</span>
</div>
<div className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-green-50 transition">
<div
className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-green-50 dark:hover:bg-zinc-800 transition cursor-pointer"
onClick={() => setPage("analytics-reports")}
>
<BarChart3 size={40} className="text-green-600" />
<span className="font-semibold text-gray-700">Reportes</span>
<span className="font-semibold text-gray-700 dark:text-zinc-200">Reportes</span>
</div>
</div>
{/* Organismos Operadores */}
<div className="bg-white rounded-xl shadow p-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<p className="text-sm text-gray-500">Organismos Operadores</p>
<p className="text-xs text-gray-400">
Seleccionado:{" "}
<span className="font-semibold">{selectedOrganism}</span>
</p>
{/* Organismo selector - ADMIN can pick any, ORGANISMO_OPERADOR sees their own */}
{(isAdmin || isOrganismo) && (
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<p className="text-sm text-gray-500 dark:text-zinc-400">Organismos Operadores</p>
<p className="text-xs text-gray-400 dark:text-zinc-500">
Seleccionado:{" "}
<span className="font-semibold dark:text-zinc-300">
{selectedOrganism === "Todos"
? "Todos"
: selectedOrganismoName || "Ninguno"}
</span>
</p>
</div>
{/* Only ADMIN can change the selector */}
{isAdmin && (
<button
type="button"
className="inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-700 transition"
onClick={() => setShowOrganisms(true)}
>
Organismos Operadores
</button>
)}
</div>
<button
type="button"
className="inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-700 transition"
onClick={() => setShowOrganisms(true)}
>
Organismos Operadores
</button>
</div>
{showOrganisms && (
<div className="fixed inset-0 z-30">
{/* Overlay */}
<div
className="absolute inset-0 bg-black/40"
onClick={() => {
setShowOrganisms(false);
setOrganismQuery("");
}}
/>
{showOrganisms && isAdmin && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Overlay */}
<div
className="absolute inset-0 bg-black/40"
onClick={() => {
setShowOrganisms(false);
setOrganismQuery("");
}}
/>
{/* Panel */}
<div className="absolute right-0 top-0 h-full w-full sm:w-[520px] bg-white shadow-2xl flex flex-col">
<div className="relative w-full max-w-2xl max-h-[90vh] bg-white dark:bg-zinc-900 rounded-xl shadow-2xl flex flex-col">
{/* Header */}
<div className="p-5 border-b flex items-start justify-between gap-3">
<div className="p-5 border-b dark:border-zinc-800 flex items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-gray-800">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
Organismos Operadores
</h3>
<p className="text-sm text-gray-500">
Selecciona un organismo para filtrar la información del
dashboard.
<p className="text-sm text-gray-500 dark:text-zinc-400">
Selecciona un organismo para filtrar la información del dashboard
</p>
</div>
<button
type="button"
className="rounded-lg px-3 py-2 text-sm border border-gray-300 hover:bg-gray-50"
className="rounded-lg px-3 py-2 text-sm border border-gray-300 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 dark:text-zinc-300"
onClick={() => {
setShowOrganisms(false);
setOrganismQuery("");
@@ -307,83 +457,130 @@ export default function Home({
</div>
{/* Search */}
<div className="p-5 border-b">
<div className="p-5 border-b dark:border-zinc-800">
<input
value={organismQuery}
onChange={(e) => setOrganismQuery(e.target.value)}
placeholder="Buscar organismo…"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
className="w-full rounded-lg border border-gray-300 dark:border-zinc-700 dark:bg-zinc-800 dark:text-white px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-500 dark:placeholder-gray-400"
/>
</div>
{/* List */}
<div className="p-5 overflow-y-auto flex-1 space-y-3">
{filteredOrganisms.map((o) => {
const active = o.name === selectedOrganism;
return (
{loadingOrganismos ? (
<div className="flex items-center justify-center py-10">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
<>
<div
key={o.name}
className={[
"rounded-xl border p-4 transition",
active
? "border-blue-600 bg-blue-50/40"
: "border-gray-200 bg-white hover:bg-gray-50",
selectedOrganism === "Todos"
? "border-blue-600 bg-blue-50/40 dark:bg-blue-900/20"
: "border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-700",
].join(" ")}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-gray-800">
<p className="text-sm font-semibold text-gray-800 dark:text-white">
Todos los Organismos
</p>
<p className="text-xs text-gray-500 dark:text-zinc-400">Ver todos los datos del sistema</p>
</div>
<span className="text-xs font-semibold px-2 py-1 rounded-full bg-blue-100 text-blue-700">
TODOS
</span>
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
className={[
"rounded-lg px-3 py-2 text-sm font-semibold shadow transition",
selectedOrganism === "Todos"
? "bg-blue-600 text-white hover:bg-blue-700"
: "bg-gray-900 text-white hover:bg-gray-800",
].join(" ")}
onClick={() => {
setSelectedOrganism("Todos");
setShowOrganisms(false);
setOrganismQuery("");
}}
>
{selectedOrganism === "Todos" ? "Seleccionado" : "Seleccionar"}
</button>
</div>
</div>
{filteredOrganisms.map((o) => {
const active = o.id === selectedOrganism;
return (
<div
key={o.id}
className={[
"rounded-xl border p-4 transition",
active
? "border-blue-600 bg-blue-50/40 dark:bg-blue-900/20"
: "border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-700",
].join(" ")}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-gray-800 dark:text-white">
{o.name}
</p>
<p className="text-xs text-gray-500">{o.region}</p>
<p className="text-xs text-gray-500 dark:text-zinc-400">{o.region || "-"}</p>
</div>
<span
className={[
"text-xs font-semibold px-2 py-1 rounded-full",
o.status === "ACTIVO"
o.is_active
? "bg-green-100 text-green-700"
: "bg-gray-200 text-gray-700",
].join(" ")}
>
{o.status}
{o.is_active ? "ACTIVO" : "INACTIVO"}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div className="mt-3 space-y-2 text-xs">
<div className="flex justify-between gap-2">
<span className="text-gray-500">Proyectos</span>
<span className="font-medium text-gray-800">
{o.projects}
<span className="text-gray-500 dark:text-zinc-400">Contacto</span>
<span className="font-medium text-gray-800 dark:text-zinc-200">
{o.contact_name || "-"}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500">Medidores</span>
<span className="font-medium text-gray-800">
{o.meters}
<span className="text-gray-500 dark:text-zinc-400">Email</span>
<span className="font-medium text-gray-800 dark:text-zinc-200 truncate max-w-[200px]">
{o.contact_email || "-"}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500">Alertas activas</span>
<span className="font-medium text-gray-800">
{o.activeAlerts}
<span className="text-gray-500 dark:text-zinc-400">Proyectos</span>
<span className="font-medium text-gray-800 dark:text-zinc-200">
{o.project_count}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500">Última sync</span>
<span className="font-medium text-gray-800">
{o.lastSync}
<span className="text-gray-500 dark:text-zinc-400">Usuarios</span>
<span className="font-medium text-gray-800 dark:text-zinc-200">
{o.user_count}
</span>
</div>
<div className="col-span-2 flex justify-between gap-2">
<span className="text-gray-500">Responsable</span>
<span className="font-medium text-gray-800">
{o.contact}
<div className="flex justify-between gap-2">
<span className="text-gray-500 dark:text-zinc-400">Región</span>
<span className="font-medium text-gray-800 dark:text-zinc-200">
{o.region || "-"}
</span>
</div>
</div>
@@ -398,7 +595,7 @@ export default function Home({
: "bg-gray-900 text-white hover:bg-gray-800",
].join(" ")}
onClick={() => {
setSelectedOrganism(o.name);
setSelectedOrganism(o.id);
setShowOrganisms(false);
setOrganismQuery("");
}}
@@ -409,86 +606,148 @@ export default function Home({
</div>
);
})}
</>
)}
{filteredOrganisms.length === 0 && (
<div className="text-sm text-gray-500 text-center py-10">
{!loadingOrganismos && filteredOrganisms.length === 0 && (
<div className="text-sm text-gray-500 dark:text-zinc-400 text-center py-10">
No se encontraron organismos.
</div>
)}
</div>
{/* Footer */}
<div className="p-5 border-t text-xs text-gray-500">
Nota: Las propiedades están en modo demostración hasta integrar
backend.
<div className="p-5 border-t dark:border-zinc-800 text-xs text-gray-500 dark:text-zinc-400">
Mostrando {filteredOrganisms.length} organismo{filteredOrganisms.length !== 1 ? 's' : ''} de {organismos.length} total{organismos.length !== 1 ? 'es' : ''}
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
{/* Gráfica */}
<div className="bg-white rounded-xl shadow p-6">
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6">
<div className="flex items-center justify-between gap-4 mb-4">
<h2 className="text-lg font-semibold">
Número de Medidores por Proyecto
<h2 className="text-lg font-semibold dark:text-white">
Numero de Medidores por Proyecto
</h2>
<span className="text-xs text-gray-400">
Click en barra para ver tomas
</span>
</div>
<div className="h-60">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
onClick={handleBarClick}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="meterCount" fill="#4c5f9e" cursor="pointer" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{chartData.length === 0 && selectedOrganism !== "Todos" ? (
<div className="h-60 flex flex-col items-center justify-center">
<p className="text-sm text-gray-500 dark:text-zinc-400 mb-2">
Este organismo no tiene medidores registrados
</p>
{selectedOrganismoName && (
<p className="text-xs text-gray-400 dark:text-zinc-500">
Organismo: <span className="font-semibold dark:text-zinc-300">{selectedOrganismoName}</span>
</p>
)}
</div>
) : chartData.length === 0 ? (
<div className="h-60 flex items-center justify-center">
<p className="text-sm text-gray-500 dark:text-zinc-400">No hay datos disponibles</p>
</div>
) : (
<>
<div className="h-60">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
onClick={handleBarClick}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="meterCount" fill="#4c5f9e" cursor="pointer" />
</BarChart>
</ResponsiveContainer>
</div>
{/* Historial */}
<div className="bg-white rounded-xl shadow p-6">
<h2 className="text-lg font-semibold mb-4">Historial Reciente</h2>
<ul className="divide-y divide-gray-200 max-h-60 overflow-y-auto">
{history.map((h, i) => (
<li key={i} className="py-2 flex items-start gap-3">
<span className="text-gray-400 mt-1"></span>
<div className="flex-1">
<p className="text-sm text-gray-700">
<span className="font-semibold">{h.user}</span> {h.action}{" "}
<span className="font-medium">{h.target}</span>
</p>
<p className="text-xs text-gray-400">{h.time}</p>
{selectedOrganism !== "Todos" && selectedOrganismoName && (
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-zinc-800">
<div className="flex items-center justify-between text-sm">
<div>
<span className="text-gray-500 dark:text-zinc-400">Organismo:</span>
<span className="ml-2 font-semibold text-gray-800 dark:text-white">{selectedOrganismoName}</span>
</div>
<div>
<span className="text-gray-500 dark:text-zinc-400">Total de medidores:</span>
<span className="ml-2 font-semibold text-blue-600">{filteredMeters.length}</span>
</div>
</div>
</div>
</li>
))}
</ul>
)}
</>
)}
</div>
{/* Últimas alertas */}
<div className="bg-white rounded-xl shadow p-6">
<h2 className="text-lg font-semibold mb-4">Últimas Alertas</h2>
<ul className="divide-y divide-gray-200">
{alerts.map((a, i) => (
<li key={i} className="py-2 flex justify-between">
<span>
{a.company} - {a.type}
</span>
<span className="text-red-500 font-medium">{a.time}</span>
</li>
))}
</ul>
</div>
{isAdmin && (
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6">
<h2 className="text-lg font-semibold mb-4 dark:text-white">Historial Reciente de Auditoria</h2>
{loadingAuditLogs ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : history.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-8">
No hay registros de auditoría disponibles
</p>
) : (
<ul className="divide-y divide-gray-200 dark:divide-zinc-800 max-h-60 overflow-y-auto">
{history.map((h, i) => (
<li key={i} className="py-2 flex items-start gap-3">
<span className="text-gray-400 mt-1"></span>
<div className="flex-1">
<p className="text-sm text-gray-700 dark:text-zinc-300">
<span className="font-semibold">{h.user}</span> {h.action}{" "}
<span className="font-medium">{h.target}</span>
</p>
<p className="text-xs text-gray-400">{h.time}</p>
</div>
</li>
))}
</ul>
)}
</div>
)}
{(isAdmin || isOrganismo) && (
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6">
<h2 className="text-lg font-semibold mb-4 dark:text-white">Ultimas Alertas</h2>
{loadingNotifications ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : alerts.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-8">
No hay alertas disponibles
</p>
) : (
<ul className="divide-y divide-gray-200 dark:divide-zinc-800">
{alerts.map((a, i) => (
<li key={i} className="py-2 flex justify-between items-start">
<div className="flex-1">
<span className="text-sm text-gray-700 dark:text-zinc-300">
<span className="font-semibold">{a.company}</span> - {a.type}
</span>
</div>
<span className="text-xs text-red-500 font-medium whitespace-nowrap ml-4">
{a.time}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}

View File

@@ -32,7 +32,6 @@ export default function LoginPage({ onSuccess }: LoginPageProps) {
setLoading(true);
try {
await login({ email: form.email, password: form.password });
// Tokens are stored by the auth module
onSuccess();
} catch (err) {
setServerError(err instanceof Error ? err.message : "Error de autenticación");
@@ -42,153 +41,155 @@ export default function LoginPage({ onSuccess }: LoginPageProps) {
}
return (
<div className="h-screen w-screen font-sans bg-slate-50">
<div className="relative h-full w-full overflow-hidden bg-white">
<div
className="absolute left-0 top-0 h-[3px] w-full opacity-90"
style={{
background:
"linear-gradient(90deg, transparent, rgba(86,107,184,0.9), rgba(76,95,158,0.9), transparent)",
}}
/>
<div className="fixed inset-0 w-screen h-screen font-sans">
{/* Imagen de fondo - agua */}
<div
className="absolute inset-0 w-full h-full bg-cover bg-center"
style={{
backgroundImage: `url('https://images.unsplash.com/photo-1500375592092-40eb2168fd21?q=80&w=2088&auto=format&fit=crop')`,
}}
/>
<div className="grid h-full grid-cols-1 md:grid-cols-2">
{/* IZQUIERDA */}
<section className="relative overflow-hidden">
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, #2a355d 10%, #4c5f9e 55%, #566bb8 100%)",
}}
/>
{/* Overlay oscuro */}
<div className="absolute inset-0 bg-black/50" />
<div
className="absolute inset-0"
style={{
clipPath: "polygon(0 0, 80% 0, 55% 100%, 0 100%)",
background:
"linear-gradient(135deg, rgba(255,255,255,0.10), rgba(255,255,255,0.02))",
}}
/>
<div className="relative h-full flex items-center px-10 md:px-12">
<div className="max-w-sm text-white">
<h2 className="text-4xl font-semibold tracking-tight">
¡Bienvenido!
</h2>
<p className="mt-3 text-sm leading-relaxed text-white/90">
Ingresa con tus credenciales para acceder al panel GRH.
</p>
</div>
{/* Layout dividido */}
<div className="relative z-10 w-full h-full flex">
{/* Lado izquierdo - Branding */}
<div className="hidden lg:flex lg:w-1/2 h-full flex-col justify-between p-12">
<div className="flex items-center gap-4">
<div className="h-24 w-24 rounded-2xl bg-white/90 backdrop-blur-sm shadow-xl flex items-center justify-center">
<img
src={grhWatermark}
alt="GRH"
className="h-18 w-18 object-contain"
/>
</div>
</section>
<span className="text-white text-4xl font-bold tracking-tight drop-shadow-lg">
GRH
</span>
</div>
{/* DERECHA */}
<section className="bg-white flex items-center justify-center">
<div className="w-full max-w-lg px-6">
<div className="rounded-3xl border border-slate-200 bg-white/90 p-10 md:p-12 shadow-lg">
<div className="flex items-center gap-4">
<img
src={grhWatermark}
alt="GRH"
className="h-20 w-20 object-contain rounded-lg"
/>
<div className="leading-tight">
<h1 className="text-2xl font-semibold text-slate-900">
Iniciar sesión
</h1>
<p className="text-xs text-slate-500">
Gestión de Recursos Hídricos
</p>
</div>
<div className="max-w-xl">
<h1 className="text-6xl font-bold text-white leading-tight drop-shadow-lg">
Gestión de
<br />
Recursos Hídricos
</h1>
<p className="mt-6 text-xl text-white/90 leading-relaxed drop-shadow">
Llevando agua potable a comunidades que más lo necesitan.
Monitoreo inteligente de infraestructura hídrica.
</p>
</div>
<p className="text-white/60 text-sm">
© 2026 GRH. Todos los derechos reservados.
</p>
</div>
{/* Lado derecho - Formulario */}
<div className="w-full lg:w-1/2 h-full flex items-center justify-center p-6">
<div className="w-full max-w-md bg-white rounded-2xl shadow-2xl p-8 md:p-10">
{/* Logo móvil */}
<div className="flex lg:hidden items-center gap-3 mb-8">
<img
src={grhWatermark}
alt="GRH"
className="h-10 w-10 object-contain"
/>
<span className="text-slate-800 text-lg font-semibold">GRH</span>
</div>
<div className="mb-8">
<h2 className="text-2xl font-bold text-slate-900">
Iniciar sesión
</h2>
<p className="mt-2 text-slate-500 text-sm">
Ingresa tus credenciales para acceder al sistema
</p>
</div>
<form onSubmit={onSubmit} className="space-y-5">
{serverError && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
{serverError}
</div>
)}
<form onSubmit={onSubmit} className="mt-8 space-y-6">
{serverError && (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{serverError}
</div>
)}
{/* Email */}
<div>
<label className="block text-sm font-medium text-slate-700">
Correo electrónico
</label>
<div className="relative mt-2">
<input
type="email"
value={form.email}
onChange={(e) =>
setForm((s) => ({ ...s, email: e.target.value }))
}
className="w-full border-b border-slate-300 py-2 pr-10 outline-none focus:border-slate-600"
/>
<Mail
className="absolute right-1 top-1/2 -translate-y-1/2 text-slate-500"
size={18}
/>
</div>
{errors.email && (
<p className="mt-1 text-xs text-red-600">
{errors.email}
</p>
)}
</div>
{/* Contraseña */}
<div>
<label className="block text-sm font-medium text-slate-700">
Contraseña
</label>
<div className="relative mt-2">
<input
value={form.password}
onChange={(e) =>
setForm((s) => ({ ...s, password: e.target.value }))
}
type={showPass ? "text" : "password"}
className="w-full border-b border-slate-300 py-2 pr-16 outline-none focus:border-slate-600"
/>
<button
type="button"
onClick={() => setShowPass((v) => !v)}
className="absolute right-8 top-1/2 -translate-y-1/2 text-slate-500"
>
{showPass ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
<Lock
className="absolute right-1 top-1/2 -translate-y-1/2 text-slate-500"
size={18}
/>
</div>
{errors.password && (
<p className="mt-1 text-xs text-red-600">
{errors.password}
</p>
)}
</div>
{/* Botón */}
<button
type="submit"
disabled={!canSubmit}
className="mt-2 w-full rounded-full bg-gradient-to-r from-[#4c5f9e] to-[#566bb8] py-2.5 text-white shadow-md flex items-center justify-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
>
{loading ? (
<>
<Loader2 className="animate-spin" size={18} />
Entrando...
</>
) : (
"Iniciar sesión"
)}
</button>
</form>
{/* Email */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
Correo electrónico
</label>
<div className="relative">
<input
type="email"
value={form.email}
onChange={(e) =>
setForm((s) => ({ ...s, email: e.target.value }))
}
placeholder="usuario@ejemplo.com"
className="w-full rounded-lg border border-slate-300 bg-white px-4 py-3 pr-11 text-slate-900 placeholder-slate-400 outline-none transition-all focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
/>
<Mail
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400"
size={18}
/>
</div>
{errors.email && (
<p className="mt-1.5 text-xs text-red-600">{errors.email}</p>
)}
</div>
</div>
</section>
{/* Contraseña */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
Contraseña
</label>
<div className="relative">
<input
value={form.password}
onChange={(e) =>
setForm((s) => ({ ...s, password: e.target.value }))
}
type={showPass ? "text" : "password"}
placeholder="••••••••"
className="w-full rounded-lg border border-slate-300 bg-white px-4 py-3 pr-20 text-slate-900 placeholder-slate-400 outline-none transition-all focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
/>
<button
type="button"
onClick={() => setShowPass((v) => !v)}
className="absolute right-11 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
>
{showPass ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
<Lock
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400"
size={18}
/>
</div>
{errors.password && (
<p className="mt-1.5 text-xs text-red-600">{errors.password}</p>
)}
</div>
{/* Botón */}
<button
type="submit"
disabled={!canSubmit}
className="w-full rounded-lg bg-blue-600 py-3 font-semibold text-white shadow-lg transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-blue-700 active:scale-[0.98]"
>
{loading ? (
<>
<Loader2 className="animate-spin" size={20} />
Ingresando...
</>
) : (
"Ingresar"
)}
</button>
</form>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,372 @@
import { useState, useEffect } from "react";
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
import MaterialTable from "@material-table/core";
import {
getAllOrganismos,
createOrganismo,
updateOrganismo,
deleteOrganismo,
type OrganismoOperador,
type CreateOrganismoInput,
type UpdateOrganismoInput,
} from "../api/organismos";
interface OrganismoForm {
name: string;
description: string;
region: string;
contact_name: string;
contact_email: string;
is_active: boolean;
}
export default function OrganismosPage() {
const [organismos, setOrganismos] = useState<OrganismoOperador[]>([]);
const [activeOrganismo, setActiveOrganismo] = useState<OrganismoOperador | null>(null);
const [search, setSearch] = useState("");
const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const emptyForm: OrganismoForm = {
name: "",
description: "",
region: "",
contact_name: "",
contact_email: "",
is_active: true,
};
const [form, setForm] = useState<OrganismoForm>(emptyForm);
useEffect(() => {
loadOrganismos();
}, []);
const loadOrganismos = async () => {
try {
setLoading(true);
const response = await getAllOrganismos({ pageSize: 100 });
setOrganismos(response.data);
} catch (err) {
console.error("Failed to fetch organismos:", err);
setOrganismos([]);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setError(null);
if (!form.name) {
setError("El nombre es requerido");
return;
}
try {
setSaving(true);
if (editingId) {
const updateData: UpdateOrganismoInput = {
name: form.name,
description: form.description || undefined,
region: form.region || undefined,
contact_name: form.contact_name || undefined,
contact_email: form.contact_email || undefined,
is_active: form.is_active,
};
await updateOrganismo(editingId, updateData);
} else {
const createData: CreateOrganismoInput = {
name: form.name,
description: form.description || undefined,
region: form.region || undefined,
contact_name: form.contact_name || undefined,
contact_email: form.contact_email || undefined,
is_active: form.is_active,
};
await createOrganismo(createData);
}
await loadOrganismos();
setShowModal(false);
setEditingId(null);
setForm(emptyForm);
} catch (err) {
console.error("Failed to save organismo:", err);
setError(err instanceof Error ? err.message : "Failed to save organismo");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!activeOrganismo) return;
if (!window.confirm(`Are you sure you want to delete "${activeOrganismo.name}"?`)) {
return;
}
try {
setSaving(true);
await deleteOrganismo(activeOrganismo.id);
await loadOrganismos();
setActiveOrganismo(null);
} catch (err) {
console.error("Failed to delete organismo:", err);
alert(err instanceof Error ? err.message : "Failed to delete organismo");
} finally {
setSaving(false);
}
};
const handleOpenAddModal = () => {
setForm(emptyForm);
setEditingId(null);
setError(null);
setShowModal(true);
};
const handleOpenEditModal = (organismo: OrganismoOperador) => {
setEditingId(organismo.id);
setForm({
name: organismo.name,
description: organismo.description || "",
region: organismo.region || "",
contact_name: organismo.contact_name || "",
contact_email: organismo.contact_email || "",
is_active: organismo.is_active,
});
setError(null);
setShowModal(true);
};
const filtered = organismos.filter((o) =>
`${o.name} ${o.region || ""} ${o.description || ""}`
.toLowerCase()
.includes(search.toLowerCase())
);
return (
<div className="flex gap-6 p-6 w-full bg-gray-100 dark:bg-zinc-950">
<div className="flex-1 flex flex-col gap-6">
{/* HEADER */}
<div
className="rounded-xl shadow p-6 text-white flex justify-between items-center"
style={{ background: "linear-gradient(135deg, #4c5f9e, #2a355d, #566bb8)" }}
>
<div>
<h1 className="text-2xl font-bold">Organismos Operadores</h1>
<p className="text-sm text-blue-100">Gestión de organismos operadores del sistema</p>
</div>
<div className="flex gap-3">
<button
onClick={handleOpenAddModal}
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg"
>
<Plus size={16} /> Agregar
</button>
<button
onClick={() => activeOrganismo && handleOpenEditModal(activeOrganismo)}
disabled={!activeOrganismo}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Pencil size={16} /> Editar
</button>
<button
onClick={handleDelete}
disabled={!activeOrganismo || saving}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Trash2 size={16} /> Eliminar
</button>
<button
onClick={loadOrganismos}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg"
disabled={loading}
>
<RefreshCcw size={16} /> Actualizar
</button>
</div>
</div>
{/* SEARCH */}
<input
className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 dark:text-zinc-100 rounded-lg shadow px-4 py-2 text-sm dark:placeholder-zinc-500"
placeholder="Buscar organismo..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{/* TABLE */}
<MaterialTable
title="Organismos Operadores"
isLoading={loading}
columns={[
{ title: "Nombre", field: "name" },
{ title: "Región", field: "region", render: (row: OrganismoOperador) => row.region || "-" },
{ title: "Contacto", field: "contact_name", render: (row: OrganismoOperador) => row.contact_name || "-" },
{ title: "Email", field: "contact_email", render: (row: OrganismoOperador) => row.contact_email || "-" },
{
title: "Proyectos",
field: "project_count",
render: (row: OrganismoOperador) => (
<span className="px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-700">
{row.project_count}
</span>
),
},
{
title: "Usuarios",
field: "user_count",
render: (row: OrganismoOperador) => (
<span className="px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-700">
{row.user_count}
</span>
),
},
{
title: "Estado",
field: "is_active",
render: (row: OrganismoOperador) => (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${
row.is_active
? "text-blue-600 border-blue-600"
: "text-red-600 border-red-600"
}`}
>
{row.is_active ? "ACTIVO" : "INACTIVO"}
</span>
),
},
]}
data={filtered}
onRowClick={(_, rowData) => setActiveOrganismo(rowData as OrganismoOperador)}
options={{
search: false,
paging: true,
pageSize: 10,
pageSizeOptions: [10, 20, 50],
sorting: true,
rowStyle: (rowData) => ({
backgroundColor:
activeOrganismo?.id === (rowData as OrganismoOperador).id
? "#EEF2FF"
: "#FFFFFF",
}),
}}
localization={{
body: {
emptyDataSourceMessage: loading
? "Cargando organismos..."
: "No hay organismos. Haz clic en 'Agregar' para crear uno.",
},
}}
/>
</div>
{/* MODAL */}
{showModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-700 rounded-xl p-6 w-[450px] space-y-4">
<h2 className="text-lg font-semibold dark:text-white">
{editingId ? "Editar Organismo" : "Agregar Organismo"}
</h2>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Nombre *</label>
<input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Nombre del organismo"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
disabled={saving}
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Descripción</label>
<textarea
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Descripción (opcional)"
rows={2}
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
disabled={saving}
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Región</label>
<input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Región (opcional)"
value={form.region}
onChange={(e) => setForm({ ...form, region: e.target.value })}
disabled={saving}
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Nombre de contacto</label>
<input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Nombre de contacto (opcional)"
value={form.contact_name}
onChange={(e) => setForm({ ...form, contact_name: e.target.value })}
disabled={saving}
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Email de contacto</label>
<input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
type="email"
placeholder="Email de contacto (opcional)"
value={form.contact_email}
onChange={(e) => setForm({ ...form, contact_email: e.target.value })}
disabled={saving}
/>
</div>
<button
onClick={() => setForm({ ...form, is_active: !form.is_active })}
className="w-full border rounded px-3 py-2 dark:border-zinc-700 dark:text-zinc-100"
disabled={saving}
>
Estado: {form.is_active ? "ACTIVO" : "INACTIVO"}
</button>
<div className="flex justify-end gap-2 pt-3 border-t dark:border-zinc-700">
<button
onClick={() => setShowModal(false)}
className="px-4 py-2 rounded hover:bg-gray-100 dark:hover:bg-zinc-800 dark:text-zinc-300"
disabled={saving}
>
Cancelar
</button>
<button
onClick={handleSave}
className="bg-[#4c5f9e] text-white px-4 py-2 rounded disabled:opacity-50"
disabled={saving}
>
{saving ? "Guardando..." : "Guardar"}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,63 +1,148 @@
import { useState, useEffect } from "react";
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
import { Plus, Trash2, Pencil, RefreshCcw, AlertCircle, Loader2 } from "lucide-react";
import MaterialTable from "@material-table/core";
import { getAllRoles, createRole, updateRole, deleteRole, type Role } from "../api/roles";
import ConfirmModal from "../components/layout/common/ConfirmModal";
export interface Role {
id: string;
interface RoleForm {
name: string;
description: string;
status: "ACTIVE" | "INACTIVE";
createdAt: string;
permissions?: Record<string, Record<string, boolean>>;
}
export default function RolesPage() {
const initialRoles: Role[] = [
{ id: "1", name: "SUPER_ADMIN", description: "Full access", status: "ACTIVE", createdAt: "2025-12-17" },
{ id: "2", name: "USER", description: "Regular user", status: "ACTIVE", createdAt: "2025-12-16" },
];
const [roles, setRoles] = useState<Role[]>(initialRoles);
const [roles, setRoles] = useState<Role[]>([]);
const [activeRole, setActiveRole] = useState<Role | null>(null);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const emptyRole: Omit<Role, "id"> = {
const emptyForm: RoleForm = {
name: "",
description: "",
status: "ACTIVE",
createdAt: new Date().toISOString().slice(0, 10),
permissions: {},
};
const [form, setForm] = useState<Omit<Role, "id">>(emptyRole);
const [form, setForm] = useState<RoleForm>(emptyForm);
const handleSave = () => {
if (editingId) {
setRoles(prev => prev.map(r => r.id === editingId ? { id: editingId, ...form } : r));
} else {
const newId = Date.now().toString();
setRoles(prev => [...prev, { id: newId, ...form }]);
useEffect(() => {
fetchRoles();
}, []);
const fetchRoles = async () => {
setLoading(true);
setError(null);
try {
const data = await getAllRoles();
setRoles(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load roles");
console.error("Error fetching roles:", err);
} finally {
setLoading(false);
}
setShowModal(false);
setEditingId(null);
setForm(emptyRole);
};
const handleDelete = () => {
const handleSave = async () => {
if (!form.name.trim()) {
setError("Role name is required");
return;
}
setLoading(true);
setError(null);
try {
if (editingId) {
const updated = await updateRole(editingId, form);
setRoles(prev => prev.map(r => r.id === editingId ? updated : r));
} else {
const created = await createRole(form);
setRoles(prev => [...prev, created]);
}
setShowModal(false);
setEditingId(null);
setForm(emptyForm);
setActiveRole(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save role");
console.error("Error saving role:", err);
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
if (!activeRole) return;
setRoles(prev => prev.filter(r => r.id !== activeRole.id));
setActiveRole(null);
setLoading(true);
setError(null);
try {
await deleteRole(activeRole.id);
setRoles(prev => prev.filter(r => r.id !== activeRole.id));
setActiveRole(null);
setShowDeleteConfirm(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete role");
console.error("Error deleting role:", err);
} finally {
setLoading(false);
}
};
const filtered = roles.filter(r => r.name.toLowerCase().includes(search.toLowerCase()));
const openEditModal = () => {
if (!activeRole) return;
setEditingId(activeRole.id);
setForm({
name: activeRole.name,
description: activeRole.description,
permissions: activeRole.permissions,
});
setShowModal(true);
};
const filtered = roles.filter(r =>
r.name.toLowerCase().includes(search.toLowerCase()) ||
r.description?.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="flex gap-6 p-6 w-full bg-gray-100">
<div className="flex gap-6 p-6 w-full bg-gray-100 dark:bg-zinc-950">
{/* LEFT INFO SIDEBAR */}
<div className="w-72 bg-white rounded-xl shadow p-4">
<h3 className="text-xs font-semibold text-gray-500 mb-3">Role Information</h3>
<p className="text-sm text-gray-700">Aquí se listan los roles disponibles en el sistema.</p>
<div className="w-72 bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-4">
<h3 className="text-xs font-semibold text-gray-500 dark:text-zinc-400 mb-3">Role Information</h3>
<p className="text-sm text-gray-700 dark:text-zinc-300 mb-4">
Manage system roles and their permissions. Roles define what actions users can perform.
</p>
{activeRole && (
<div className="mt-6 pt-4 border-t">
<h4 className="text-xs font-semibold text-gray-500 dark:text-zinc-400 mb-2">Selected Role</h4>
<div className="space-y-2">
<div>
<p className="text-xs text-gray-500 dark:text-zinc-400">Name</p>
<p className="text-sm font-medium dark:text-zinc-200">{activeRole.name}</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-zinc-400">Description</p>
<p className="text-sm dark:text-zinc-300">{activeRole.description || "No description"}</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-zinc-400">Created</p>
<p className="text-sm dark:text-zinc-300">{new Date(activeRole.created_at).toLocaleDateString()}</p>
</div>
{activeRole.permissions && Object.keys(activeRole.permissions).length > 0 && (
<div>
<p className="text-xs text-gray-500 dark:text-zinc-400">Permissions</p>
<p className="text-sm dark:text-zinc-300">{Object.keys(activeRole.permissions).length} permission groups</p>
</div>
)}
</div>
</div>
)}
</div>
{/* MAIN */}
@@ -67,83 +152,213 @@ export default function RolesPage() {
style={{ background: "linear-gradient(135deg, #4c5f9e, #2a355d, #566bb8)" }}>
<div>
<h1 className="text-2xl font-bold">Role Management</h1>
<p className="text-sm text-blue-100">Roles registrados</p>
<p className="text-sm text-blue-100">{roles.length} roles registered</p>
</div>
<div className="flex gap-3">
<button onClick={() => { setForm(emptyRole); setEditingId(null); setShowModal(true); }}
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg">
<button
onClick={() => { setForm(emptyForm); setEditingId(null); setShowModal(true); }}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg hover:bg-gray-100 transition disabled:opacity-50"
>
<Plus size={16} /> Add
</button>
<button onClick={() => { if (!activeRole) return; setEditingId(activeRole.id); setForm({...activeRole}); setShowModal(true); }}
disabled={!activeRole}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60">
<button
onClick={openEditModal}
disabled={!activeRole || loading}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg hover:bg-white/10 transition disabled:opacity-50"
>
<Pencil size={16} /> Edit
</button>
<button onClick={handleDelete}
disabled={!activeRole}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60">
<button
onClick={() => setShowDeleteConfirm(true)}
disabled={!activeRole || loading}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg hover:bg-red-500/20 transition disabled:opacity-50"
>
<Trash2 size={16} /> Delete
</button>
<button onClick={() => setRoles([...roles])}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg">
<RefreshCcw size={16} /> Refresh
<button
onClick={fetchRoles}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg hover:bg-white/10 transition disabled:opacity-50"
>
{loading ? <Loader2 size={16} className="animate-spin" /> : <RefreshCcw size={16} />}
Refresh
</button>
</div>
</div>
{/* ERROR ALERT */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="text-red-600" size={20} />
<div>
<p className="text-sm font-medium text-red-800">Error</p>
<p className="text-sm text-red-600">{error}</p>
</div>
<button
onClick={() => setError(null)}
className="ml-auto text-red-600 hover:text-red-800"
>
×
</button>
</div>
)}
{/* SEARCH */}
<input className="bg-white rounded-lg shadow px-4 py-2 text-sm"
placeholder="Search role..."
value={search}
onChange={e => setSearch(e.target.value)} />
<input
className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 dark:text-zinc-100 rounded-lg shadow px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:placeholder-zinc-500"
placeholder="Search role by name or description..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
{/* TABLE */}
<MaterialTable
title="Roles"
columns={[
{ title: "Name", field: "name" },
{ title: "Description", field: "description" },
{
title: "Status",
field: "status",
render: (rowData) => (
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${rowData.status === "ACTIVE" ? "text-blue-600 border-blue-600" : "text-red-600 border-red-600"}`}>
{rowData.status}
</span>
)
},
{ title: "Created", field: "createdAt", type: "date" }
]}
data={filtered}
onRowClick={(_, rowData) => setActiveRole(rowData as Role)}
options={{
actionsColumnIndex: -1,
search: false,
paging: true,
sorting: true,
rowStyle: (rowData) => ({ backgroundColor: activeRole?.id === (rowData as Role).id ? "#EEF2FF" : "#FFFFFF" })
}}
/>
<div className="bg-white rounded-xl shadow">
{loading && roles.length === 0 ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin text-blue-600" size={32} />
</div>
) : (
<MaterialTable
title="Roles"
columns={[
{
title: "Name",
field: "name",
render: (rowData) => (
<span className="font-medium text-gray-900">{rowData.name}</span>
)
},
{
title: "Description",
field: "description",
render: (rowData) => (
<span className="text-gray-600">{rowData.description || "—"}</span>
)
},
{
title: "Permissions",
field: "permissions",
render: (rowData) => {
const count = rowData.permissions ? Object.keys(rowData.permissions).length : 0;
return (
<span className="text-sm text-gray-600">
{count > 0 ? `${count} groups` : "No permissions"}
</span>
);
}
},
{
title: "Created",
field: "created_at",
type: "date",
render: (rowData) => (
<span className="text-sm text-gray-600">
{new Date(rowData.created_at).toLocaleDateString()}
</span>
)
}
]}
data={filtered}
onRowClick={(_, rowData) => setActiveRole(rowData as Role)}
options={{
actionsColumnIndex: -1,
search: false,
paging: true,
pageSize: 10,
pageSizeOptions: [10, 20, 50],
sorting: true,
rowStyle: (rowData) => ({
backgroundColor: activeRole?.id === (rowData as Role).id ? "#EEF2FF" : "#FFFFFF",
cursor: "pointer"
})
}}
/>
)}
</div>
</div>
{/* MODAL */}
{/* ADD/EDIT MODAL */}
{showModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center">
<div className="bg-white rounded-xl p-6 w-96 space-y-3">
<h2 className="text-lg font-semibold">{editingId ? "Edit Role" : "Add Role"}</h2>
<input className="w-full border px-3 py-2 rounded" placeholder="Name" value={form.name} onChange={e => setForm({...form, name: e.target.value})} />
<input className="w-full border px-3 py-2 rounded" placeholder="Description" value={form.description} onChange={e => setForm({...form, description: e.target.value})} />
<button onClick={() => setForm({...form, status: form.status === "ACTIVE" ? "INACTIVE" : "ACTIVE"})} className="w-full border rounded px-3 py-2">
Status: {form.status}
</button>
<input type="date" className="w-full border px-3 py-2 rounded" value={form.createdAt} onChange={e => setForm({...form, createdAt: e.target.value})} />
<div className="flex justify-end gap-2 pt-3">
<button onClick={() => setShowModal(false)}>Cancel</button>
<button onClick={handleSave} className="bg-[#4c5f9e] text-white px-4 py-2 rounded">Save</button>
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-700 rounded-xl p-6 w-96 space-y-4 shadow-xl">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{editingId ? "Edit Role" : "Add New Role"}
</h2>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-1">
Name <span className="text-red-500">*</span>
</label>
<input
className="w-full border border-gray-300 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g., ADMIN, USER, MANAGER"
value={form.name}
onChange={e => setForm({...form, name: e.target.value})}
disabled={loading}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-1">
Description
</label>
<textarea
className="w-full border border-gray-300 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
placeholder="Describe the role's purpose..."
rows={3}
value={form.description}
onChange={e => setForm({...form, description: e.target.value})}
disabled={loading}
/>
</div>
{/* Note about permissions */}
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
<p className="text-xs text-blue-800 dark:text-blue-300">
<strong>Note:</strong> Permissions can be configured after creating the role.
</p>
</div>
</div>
<div className="flex justify-end gap-2 pt-3 border-t">
<button
onClick={() => {
setShowModal(false);
setEditingId(null);
setForm(emptyForm);
setError(null);
}}
disabled={loading}
className="px-4 py-2 text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={loading || !form.name.trim()}
className="bg-[#4c5f9e] text-white px-4 py-2 rounded-lg hover:bg-[#3d4c7d] transition disabled:opacity-50 flex items-center gap-2"
>
{loading && <Loader2 size={16} className="animate-spin" />}
{loading ? "Saving..." : "Save"}
</button>
</div>
</div>
</div>
)}
{/* DELETE CONFIRMATION MODAL */}
<ConfirmModal
open={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
onConfirm={handleDelete}
title="Delete Role"
message={`Are you sure you want to delete the role "${activeRole?.name}"? This action cannot be undone.`}
confirmText="Delete"
danger={true}
loading={loading}
/>
</div>
);
}

View File

@@ -1,8 +1,11 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
import MaterialTable from "@material-table/core";
import { createUser, updateUser, deleteUser, getAllUsers, CreateUserInput, UpdateUserInput, User as ApiUser } from "../api/users";
import { getAllRoles, Role as ApiRole } from "../api/roles";
import { fetchProjects, type Project } from "../api/projects";
import { getAllOrganismos, getOrganismoProjects, type OrganismoOperador, type OrganismoProject } from "../api/organismos";
import { getCurrentUserRole, getCurrentUserOrganismoId } from "../api/auth";
interface RoleOption {
id: string;
@@ -15,6 +18,9 @@ interface User {
email: string;
roleId: string;
roleName: string;
projectId: string | null;
organismoOperadorId: string | null;
organismoName: string | null;
status: "ACTIVE" | "INACTIVE";
createdAt: string;
}
@@ -24,27 +30,67 @@ interface UserForm {
email: string;
password?: string;
roleId: string;
projectId?: string;
organismoOperadorId?: string;
status: "ACTIVE" | "INACTIVE";
createdAt: string;
phone: string;
street: string;
city: string;
state: string;
zipCode: string;
}
export default function UsersPage() {
const userRole = useMemo(() => getCurrentUserRole(), []);
const userOrganismoId = useMemo(() => getCurrentUserOrganismoId(), []);
const isAdmin = userRole?.toUpperCase() === 'ADMIN';
const isOrganismo = userRole?.toUpperCase() === 'ORGANISMO_OPERADOR';
const [users, setUsers] = useState<User[]>([]);
const [activeUser, setActiveUser] = useState<User | null>(null);
const [search, setSearch] = useState("");
const [selectedRoleFilter, setSelectedRoleFilter] = useState<string>(""); // Filter state
const [selectedRoleFilter, setSelectedRoleFilter] = useState<string>("");
const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [roles, setRoles] = useState<RoleOption[]>([]);
const [modalRoles, setModalRoles] = useState<ApiRole[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [organismos, setOrganismos] = useState<OrganismoOperador[]>([]);
const [organismoProjects, setOrganismoProjects] = useState<OrganismoProject[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
const [loadingModalRoles, setLoadingModalRoles] = useState(false);
const [loadingProjects, setLoadingProjects] = useState(false);
const [loadingOrganismos, setLoadingOrganismos] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const emptyUser: UserForm = { name: "", email: "", roleId: "", password: "", status: "ACTIVE", createdAt: new Date().toISOString().slice(0,10) };
const emptyUser: UserForm = { name: "", email: "", roleId: "", projectId: "", organismoOperadorId: "", password: "", status: "ACTIVE", createdAt: new Date().toISOString().slice(0,10), phone: "", street: "", city: "", state: "", zipCode: "" };
const [form, setForm] = useState<UserForm>(emptyUser);
const activeProjects = projects.filter(p => p.status === 'ACTIVE');
// Determine selected role name from modal roles
const selectedRoleName = useMemo(() => {
return modalRoles.find(r => r.id === form.roleId)?.name || "";
}, [modalRoles, form.roleId]);
// For ORGANISMO_OPERADOR role: show organismo selector
// For OPERATOR role: show organismo + project selector
const showOrganismoSelector = selectedRoleName === "ORGANISMO_OPERADOR" || selectedRoleName === "OPERATOR";
const showProjectSelector = selectedRoleName === "OPERATOR";
// Projects filtered by selected organismo
const filteredProjectsForForm = useMemo(() => {
if (form.organismoOperadorId && organismoProjects.length > 0) {
return organismoProjects;
}
if (form.organismoOperadorId) {
return activeProjects.filter(() => false); // No projects loaded yet for this organismo
}
return activeProjects;
}, [form.organismoOperadorId, organismoProjects, activeProjects]);
useEffect(() => {
fetchUsers();
}, []);
@@ -53,7 +99,6 @@ export default function UsersPage() {
try {
setLoadingUsers(true);
const usersResponse = await getAllUsers();
console.log('Users API response:', usersResponse);
const mappedUsers: User[] = usersResponse.data.map((apiUser: ApiUser) => ({
id: apiUser.id,
@@ -61,6 +106,9 @@ export default function UsersPage() {
email: apiUser.email,
roleId: apiUser.role_id,
roleName: apiUser.role?.name || '',
projectId: apiUser.project_id || null,
organismoOperadorId: apiUser.organismo_operador_id || null,
organismoName: apiUser.organismo_name || null,
status: apiUser.is_active ? "ACTIVE" : "INACTIVE",
createdAt: new Date(apiUser.created_at).toISOString().slice(0, 10)
}));
@@ -77,7 +125,6 @@ export default function UsersPage() {
}
});
const uniqueRoles = Array.from(uniqueRolesMap.values());
console.log('Unique roles extracted:', uniqueRoles);
setRoles(uniqueRoles);
} catch (error) {
console.error('Failed to fetch users:', error);
@@ -96,6 +143,16 @@ export default function UsersPage() {
return;
}
if (selectedRoleName === "OPERATOR" && !form.projectId) {
setError("Project is required for OPERADOR role");
return;
}
if (selectedRoleName === "ORGANISMO_OPERADOR" && !form.organismoOperadorId) {
setError("Organismo is required for ORGANISMO_OPERADOR role");
return;
}
if (!editingId && !form.password) {
setError("Password is required for new users");
return;
@@ -109,12 +166,30 @@ export default function UsersPage() {
try {
setSaving(true);
// Determine organismo_operador_id based on role
let organismoId: string | null = null;
if (selectedRoleName === "ORGANISMO_OPERADOR" || selectedRoleName === "OPERATOR") {
organismoId = form.organismoOperadorId || null;
}
// If current user is ORGANISMO_OPERADOR, force their own organismo
if (isOrganismo && userOrganismoId) {
organismoId = userOrganismoId;
}
if (editingId) {
const updateData: UpdateUserInput = {
email: form.email,
name: form.name.trim(),
role_id: form.roleId,
project_id: form.projectId || null,
organismo_operador_id: organismoId,
is_active: form.status === "ACTIVE",
phone: form.phone || null,
street: form.street || null,
city: form.city || null,
state: form.state || null,
zip_code: form.zipCode || null,
};
await updateUser(editingId, updateData);
@@ -124,7 +199,14 @@ export default function UsersPage() {
password: form.password!,
name: form.name.trim(),
role_id: form.roleId,
project_id: form.projectId || null,
organismo_operador_id: organismoId,
is_active: form.status === "ACTIVE",
phone: form.phone || null,
street: form.street || null,
city: form.city || null,
state: form.state || null,
zip_code: form.zipCode || null,
};
await createUser(createData);
@@ -172,8 +254,12 @@ export default function UsersPage() {
try {
setLoadingModalRoles(true);
const rolesData = await getAllRoles();
console.log('Modal roles fetched:', rolesData);
setModalRoles(rolesData);
// If ORGANISMO_OPERADOR, only show OPERATOR role
if (isOrganismo) {
setModalRoles(rolesData.filter(r => r.name === 'OPERATOR'));
} else {
setModalRoles(rolesData);
}
} catch (error) {
console.error('Failed to fetch modal roles:', error);
} finally {
@@ -181,27 +267,98 @@ export default function UsersPage() {
}
};
const fetchModalProjects = async () => {
try {
setLoadingProjects(true);
const projectsData = await fetchProjects();
setProjects(projectsData);
} catch (error) {
console.error('Failed to fetch projects:', error);
} finally {
setLoadingProjects(false);
}
};
const fetchOrganismosData = async () => {
if (!isAdmin) return; // Only ADMIN loads organismos list
try {
setLoadingOrganismos(true);
const response = await getAllOrganismos({ pageSize: 100 });
setOrganismos(response.data);
} catch (error) {
console.error('Failed to fetch organismos:', error);
} finally {
setLoadingOrganismos(false);
}
};
const handleOrganismoChange = async (organismoId: string) => {
setForm({ ...form, organismoOperadorId: organismoId, projectId: "" });
setOrganismoProjects([]);
if (organismoId) {
try {
const projects = await getOrganismoProjects(organismoId);
setOrganismoProjects(projects);
} catch (error) {
console.error('Failed to fetch organismo projects:', error);
}
}
};
const handleOpenAddModal = () => {
setForm(emptyUser);
setEditingId(null);
setError(null);
setOrganismoProjects([]);
setShowModal(true);
fetchModalRoles();
fetchModalProjects();
fetchOrganismosData();
};
const handleOpenEditModal = (user: User) => {
const handleOpenEditModal = async (user: User) => {
setEditingId(user.id);
// Fetch full user details to get address fields
let phone = "", street = "", city = "", state = "", zipCode = "";
try {
const fullUser = await import("../api/users").then(m => m.getUserById(user.id));
phone = fullUser.phone || "";
street = fullUser.street || "";
city = fullUser.city || "";
state = fullUser.state || "";
zipCode = fullUser.zip_code || "";
} catch (err) {
console.error('Failed to fetch user details:', err);
}
setForm({
name: user.name,
email: user.email,
roleId: user.roleId,
projectId: user.projectId || "",
organismoOperadorId: user.organismoOperadorId || "",
status: user.status,
createdAt: user.createdAt,
password: ""
password: "",
phone,
street,
city,
state,
zipCode,
});
setError(null);
setOrganismoProjects([]);
setShowModal(true);
fetchModalRoles();
fetchModalProjects();
fetchOrganismosData();
// Load organismo projects if user has an organismo
if (user.organismoOperadorId) {
getOrganismoProjects(user.organismoOperadorId)
.then(setOrganismoProjects)
.catch(console.error);
}
};
// Filter users by search and selected role
@@ -213,17 +370,17 @@ export default function UsersPage() {
});
return (
<div className="flex gap-6 p-6 w-full bg-gray-100">
<div className="flex gap-6 p-6 w-full bg-gray-100 dark:bg-zinc-950">
{/* LEFT INFO SIDEBAR */}
<div className="w-72 bg-white rounded-xl shadow p-4">
<h3 className="text-xs font-semibold text-gray-500 mb-3">Filter Options</h3>
<p className="text-sm text-gray-700 mb-4">Filter users by role</p>
<div className="w-72 bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-4">
<h3 className="text-xs font-semibold text-gray-500 dark:text-zinc-400 mb-3">Filter Options</h3>
<p className="text-sm text-gray-700 dark:text-zinc-300 mb-4">Filter users by role</p>
<label className="text-xs font-semibold text-gray-500 mb-2 block">Role</label>
<select
value={selectedRoleFilter}
onChange={e => setSelectedRoleFilter(e.target.value)}
className="w-full border px-3 py-2 rounded"
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
disabled={loadingUsers}
>
<option value="">All Roles</option>
@@ -261,12 +418,12 @@ export default function UsersPage() {
</div>
{/* SEARCH */}
<input className="bg-white rounded-lg shadow px-4 py-2 text-sm" placeholder="Search user..." value={search} onChange={e => setSearch(e.target.value)} />
<input className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500 rounded-lg shadow px-4 py-2 text-sm" placeholder="Search user..." value={search} onChange={e => setSearch(e.target.value)} />
{/* TABLE */}
{loadingUsers ? (
<div className="bg-white rounded-xl shadow p-8 text-center">
<p className="text-gray-500">Loading users...</p>
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-8 text-center">
<p className="text-gray-500 dark:text-zinc-400">Loading users...</p>
</div>
) : (
<MaterialTable
@@ -275,21 +432,30 @@ export default function UsersPage() {
{ title: "Name", field: "name" },
{ title: "Email", field: "email" },
{ title: "Role", field: "roleName" },
{ title: "Status", field: "status", render: rowData => <span className={`px-3 py-1 rounded-full text-xs font-semibold border ${rowData.status === "ACTIVE" ? "text-blue-600 border-blue-600" : "text-red-600 border-red-600"}`}>{rowData.status}</span> },
{ title: "Organismo", field: "organismoName", render: (rowData: User) => rowData.organismoName || "-" },
{ title: "Status", field: "status", render: (rowData: User) => <span className={`px-3 py-1 rounded-full text-xs font-semibold border ${rowData.status === "ACTIVE" ? "text-blue-600 border-blue-600" : "text-red-600 border-red-600"}`}>{rowData.status}</span> },
{ title: "Created", field: "createdAt", type: "date" }
]}
data={filtered}
onRowClick={(_, rowData) => setActiveUser(rowData as User)}
options={{ actionsColumnIndex: -1, search: false, paging: true, sorting: true, rowStyle: rowData => ({ backgroundColor: activeUser?.id === (rowData as User).id ? "#EEF2FF" : "#FFFFFF" }) }}
options={{
actionsColumnIndex: -1,
search: false,
paging: true,
pageSize: 10,
pageSizeOptions: [10, 20, 50],
sorting: true,
rowStyle: (rowData) => ({ backgroundColor: activeUser?.id === (rowData as User).id ? "#EEF2FF" : "#FFFFFF" })
}}
/>
)}
</div>
{/* MODAL */}
{showModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center">
<div className="bg-white rounded-xl p-6 w-96 space-y-3">
<h2 className="text-lg font-semibold">{editingId ? "Edit User" : "Add User"}</h2>
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl p-6 w-96 space-y-3 max-h-[90vh] overflow-y-auto">
<h2 className="text-lg font-semibold dark:text-white">{editingId ? "Edit User" : "Add User"}</h2>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded text-sm">
@@ -298,7 +464,7 @@ export default function UsersPage() {
)}
<input
className="w-full border px-3 py-2 rounded"
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Full Name *"
value={form.name}
onChange={e => setForm({...form, name: e.target.value})}
@@ -306,7 +472,7 @@ export default function UsersPage() {
/>
<input
className="w-full border px-3 py-2 rounded"
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
type="email"
placeholder="Email *"
value={form.email}
@@ -314,9 +480,50 @@ export default function UsersPage() {
disabled={saving}
/>
<input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Telefono"
value={form.phone}
onChange={e => setForm({...form, phone: e.target.value})}
disabled={saving}
/>
<input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Calle"
value={form.street}
onChange={e => setForm({...form, street: e.target.value})}
disabled={saving}
/>
<div className="grid grid-cols-2 gap-2">
<input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Ciudad"
value={form.city}
onChange={e => setForm({...form, city: e.target.value})}
disabled={saving}
/>
<input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Estado"
value={form.state}
onChange={e => setForm({...form, state: e.target.value})}
disabled={saving}
/>
</div>
<input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Codigo Postal"
value={form.zipCode}
onChange={e => setForm({...form, zipCode: e.target.value})}
disabled={saving}
/>
{!editingId && (
<input
className="w-full border px-3 py-2 rounded"
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
type="password"
placeholder="Password * (min 8 characters)"
value={form.password || ""}
@@ -325,19 +532,56 @@ export default function UsersPage() {
/>
)}
{/* Role selector */}
<select
value={form.roleId}
onChange={e => setForm({...form, roleId: e.target.value})}
className="w-full border px-3 py-2 rounded"
onChange={e => setForm({...form, roleId: e.target.value, projectId: "", organismoOperadorId: isOrganismo && userOrganismoId ? userOrganismoId : ""})}
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
disabled={loadingModalRoles || saving}
>
<option value="">{loadingModalRoles ? "Loading roles..." : "Select Role *"}</option>
{modalRoles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
{/* Organismo selector - shown for ORGANISMO_OPERADOR and OPERATOR roles */}
{showOrganismoSelector && isAdmin && (
<select
value={form.organismoOperadorId || ""}
onChange={e => handleOrganismoChange(e.target.value)}
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
disabled={loadingOrganismos || saving}
>
<option value="">{loadingOrganismos ? "Loading organismos..." : "Select Organismo *"}</option>
{organismos.filter(o => o.is_active).map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
)}
{/* Show organismo name for ORGANISMO_OPERADOR users (they can't change it) */}
{showOrganismoSelector && isOrganismo && userOrganismoId && (
<div className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 px-3 py-2 rounded bg-gray-50 text-sm">
Organismo: Asignado a tu organismo
</div>
)}
{/* Project selector - shown for OPERATOR role */}
{showProjectSelector && (
<select
value={form.projectId || ""}
onChange={e => setForm({...form, projectId: e.target.value})}
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
disabled={loadingProjects || saving}
>
<option value="">{loadingProjects ? "Loading projects..." : "Select Project *"}</option>
{form.organismoOperadorId && organismoProjects.length > 0
? organismoProjects.filter(p => p.status === 'ACTIVE').map(p => <option key={p.id} value={p.id}>{p.name}</option>)
: activeProjects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)
}
</select>
)}
<button
onClick={() => setForm({...form, status: form.status === "ACTIVE" ? "INACTIVE" : "ACTIVE"})}
className="w-full border rounded px-3 py-2"
className="w-full border rounded px-3 py-2 dark:border-zinc-700 dark:text-zinc-100"
disabled={saving}
>
Status: {form.status}
@@ -346,7 +590,7 @@ export default function UsersPage() {
<div className="flex justify-end gap-2 pt-3">
<button
onClick={() => { setShowModal(false); setError(null); }}
className="px-4 py-2"
className="px-4 py-2 dark:text-zinc-300"
disabled={saving}
>
Cancel

View File

@@ -0,0 +1,364 @@
import { useState, useEffect, useMemo, useRef } from "react";
import { RefreshCw, Filter, MapPin, AlertCircle, List, Map } from "lucide-react";
import { getMetersWithCoordinates, type MeterWithCoords } from "../../api/analytics";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
// Fix Leaflet icon issue
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
});
export default function AnalyticsMapPage() {
const [meters, setMeters] = useState<MeterWithCoords[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedProject, setSelectedProject] = useState<string>("");
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [viewMode, setViewMode] = useState<"map" | "list">("map");
const mapRef = useRef<L.Map | null>(null);
const mapContainerRef = useRef<HTMLDivElement>(null);
const markersRef = useRef<L.Marker[]>([]);
const fetchMeters = async () => {
try {
setLoading(true);
setError(null);
const data = await getMetersWithCoordinates();
const validMeters = (data || []).filter(
(m) => m.lat && m.lng && !isNaN(Number(m.lat)) && !isNaN(Number(m.lng))
);
setMeters(validMeters);
} catch (err) {
console.error("Failed to fetch meters:", err);
setError("No se pudieron cargar los medidores.");
setMeters([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchMeters();
}, []);
const projects = useMemo(
() => Array.from(new Set(meters.map((m) => m.project_name).filter(Boolean))),
[meters]
);
const filteredMeters = useMemo(() => {
return meters.filter((meter) => {
if (selectedProject && meter.project_name !== selectedProject) return false;
if (selectedStatus && meter.status !== selectedStatus) return false;
return true;
});
}, [meters, selectedProject, selectedStatus]);
// Initialize map
useEffect(() => {
if (viewMode !== "map" || loading || !mapContainerRef.current) return;
// Clean up existing map
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
}
// Default center (Tijuana)
const defaultCenter: [number, number] = [32.47242396247297, -116.94986191534402];
// Create map
const map = L.map(mapContainerRef.current).setView(defaultCenter, 15);
mapRef.current = map;
// Add tile layer
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);
// Cleanup on unmount
return () => {
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
}
};
}, [viewMode, loading]);
// Update markers when filteredMeters changes
useEffect(() => {
if (!mapRef.current || viewMode !== "map") return;
// Clear existing markers
markersRef.current.forEach((marker) => marker.remove());
markersRef.current = [];
if (filteredMeters.length === 0) return;
// Add new markers
const bounds = L.latLngBounds([]);
filteredMeters.forEach((meter) => {
const lat = Number(meter.lat);
const lng = Number(meter.lng);
const marker = L.marker([lat, lng]).addTo(mapRef.current!);
marker.bindPopup(`
<div style="min-width: 150px;">
<b>${meter.name || meter.serial_number}</b><br/>
<small>Serial: ${meter.serial_number}</small><br/>
<small>Proyecto: ${meter.project_name || "N/A"}</small><br/>
<small>Estado: <span style="color: ${meter.status === "active" ? "green" : "red"}">${meter.status === "active" ? "Activo" : "Inactivo"}</span></small>
${meter.last_reading != null ? `<br/><small>Lectura: ${Number(meter.last_reading).toFixed(2)} m³</small>` : ""}
</div>
`);
markersRef.current.push(marker);
bounds.extend([lat, lng]);
});
// Fit map to markers
if (filteredMeters.length > 0) {
mapRef.current.fitBounds(bounds, { padding: [30, 30], maxZoom: 17 });
}
}, [filteredMeters, viewMode]);
const activeCount = filteredMeters.filter((m) => m.status === "active").length;
const inactiveCount = filteredMeters.length - activeCount;
const openInGoogleMaps = (lat: number, lng: number) => {
window.open(`https://www.google.com/maps?q=${lat},${lng}`, "_blank");
};
return (
<div className="flex h-full bg-slate-50 dark:bg-zinc-950" style={{ height: "100%", minHeight: "100vh" }}>
{/* Sidebar */}
<aside className="w-56 border-r border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-3 flex flex-col">
<h2 className="text-lg font-semibold mb-4 dark:text-white flex items-center gap-2">
<Filter className="w-5 h-5" />
Filtros
</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
Proyecto
</label>
<select
value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)}
className="w-full border border-gray-300 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 rounded-md px-3 py-2 text-sm"
>
<option value="">Todos los proyectos</option>
{projects.map((project) => (
<option key={project} value={project}>
{project}
</option>
))}
</select>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
Estado
</label>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
className="w-full border border-gray-300 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 rounded-md px-3 py-2 text-sm"
>
<option value="">Todos los estados</option>
<option value="active">Activo</option>
<option value="inactive">Inactivo</option>
</select>
</div>
<button
onClick={() => {
setSelectedProject("");
setSelectedStatus("");
}}
className="w-full bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-200 px-3 py-2 rounded-md text-sm"
>
Limpiar filtros
</button>
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-zinc-700 flex-1">
<h3 className="text-sm font-semibold text-gray-700 dark:text-zinc-300 mb-3">
Resumen
</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-zinc-400">Total:</span>
<span className="font-semibold text-gray-900 dark:text-zinc-100">
{filteredMeters.length}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-zinc-400">Activos:</span>
<span className="font-semibold text-green-600 dark:text-green-400">{activeCount}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-zinc-400">Inactivos:</span>
<span className="font-semibold text-red-600 dark:text-red-400">{inactiveCount}</span>
</div>
</div>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 flex flex-col">
<div className="border-b border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<MapPin className="w-5 h-5 text-gray-700 dark:text-zinc-300" />
<div>
<h1 className="text-lg font-bold text-gray-900 dark:text-white">
Mapa de Medidores
</h1>
<p className="text-xs text-gray-500 dark:text-zinc-400">
{filteredMeters.length} medidores
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex bg-gray-100 dark:bg-zinc-800 rounded-md p-1">
<button
onClick={() => setViewMode("map")}
className={`px-3 py-1 rounded text-sm flex items-center gap-1 ${
viewMode === "map"
? "bg-white dark:bg-zinc-700 shadow text-gray-900 dark:text-white"
: "text-gray-600 dark:text-zinc-400"
}`}
>
<Map className="w-4 h-4" />
Mapa
</button>
<button
onClick={() => setViewMode("list")}
className={`px-3 py-1 rounded text-sm flex items-center gap-1 ${
viewMode === "list"
? "bg-white dark:bg-zinc-700 shadow text-gray-900 dark:text-white"
: "text-gray-600 dark:text-zinc-400"
}`}
>
<List className="w-4 h-4" />
Lista
</button>
</div>
<button
onClick={fetchMeters}
disabled={loading}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-4 py-2 rounded-md flex items-center gap-2"
>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
Actualizar
</button>
</div>
</div>
</div>
<div className="flex-1 relative overflow-hidden" style={{ minHeight: "calc(100vh - 200px)" }}>
{error && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000] bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-200 px-4 py-2 rounded-md flex items-center gap-2 text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
{loading ? (
<div className="flex items-center justify-center h-full bg-slate-100 dark:bg-zinc-900">
<div className="text-gray-500 dark:text-zinc-400">Cargando medidores...</div>
</div>
) : viewMode === "map" ? (
<div ref={mapContainerRef} style={{ height: "100%", width: "100%", minHeight: "calc(100vh - 200px)" }} />
) : (
<div className="h-full overflow-auto p-6">
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-zinc-700">
<thead className="bg-gray-50 dark:bg-zinc-800">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
Medidor
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
Proyecto
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
Estado
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
Coordenadas
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
Lectura
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase">
Accion
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-zinc-900 divide-y divide-gray-200 dark:divide-zinc-700">
{filteredMeters.map((meter) => (
<tr key={meter.id} className="hover:bg-gray-50 dark:hover:bg-zinc-800">
<td className="px-4 py-3">
<div className="font-medium text-gray-900 dark:text-zinc-100">
{meter.name || meter.serial_number}
</div>
<div className="text-sm text-gray-500 dark:text-zinc-400">
{meter.serial_number}
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-zinc-400">
{meter.project_name || "N/A"}
</td>
<td className="px-4 py-3">
<span
className={`px-2 py-1 text-xs rounded-full ${
meter.status === "active"
? "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400"
: "bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400"
}`}
>
{meter.status === "active" ? "Activo" : "Inactivo"}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-zinc-400 font-mono">
{Number(meter.lat).toFixed(4)}, {Number(meter.lng).toFixed(4)}
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-zinc-400">
{meter.last_reading != null
? `${Number(meter.last_reading).toFixed(2)}`
: "—"}
</td>
<td className="px-4 py-3">
<button
onClick={() => openInGoogleMaps(Number(meter.lat), Number(meter.lng))}
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm flex items-center gap-1"
>
<MapPin className="w-4 h-4" />
Ver mapa
</button>
</td>
</tr>
))}
</tbody>
</table>
{filteredMeters.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-zinc-400">
No hay medidores con coordenadas
</div>
)}
</div>
</div>
)}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,381 @@
import { useState, useEffect } from "react";
import {
RefreshCw,
Download,
BarChart3,
TrendingUp,
Droplets,
AlertTriangle,
Building2,
} from "lucide-react";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
PieChart,
Pie,
Cell,
} from "recharts";
import { getReportStats, type ReportStats } from "../../api/analytics";
const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899"];
export default function AnalyticsReportsPage() {
const [stats, setStats] = useState<ReportStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchStats = async () => {
try {
setLoading(true);
setError(null);
const data = await getReportStats();
console.log("Report stats loaded:", data);
setStats(data);
} catch (err) {
console.error("Failed to fetch report stats:", err);
setError("No se pudieron cargar las estadisticas. Usando datos de ejemplo.");
// Set mock data for demo only if API fails
setStats({
totalMeters: 0,
activeMeters: 0,
inactiveMeters: 0,
totalConsumption: 0,
totalProjects: 0,
metersWithAlerts: 0,
consumptionByProject: [],
consumptionTrend: [],
});
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStats();
}, []);
const handleExport = () => {
if (!stats) return;
const reportData = {
generatedAt: new Date().toISOString(),
summary: {
totalMeters: stats.totalMeters,
activeMeters: stats.activeMeters,
inactiveMeters: stats.inactiveMeters,
totalConsumption: stats.totalConsumption,
totalProjects: stats.totalProjects,
},
consumptionByProject: stats.consumptionByProject,
consumptionTrend: stats.consumptionTrend,
};
const blob = new Blob([JSON.stringify(reportData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `reporte-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const pieData = stats
? [
{ name: "Activos", value: stats.activeMeters },
{ name: "Inactivos", value: stats.inactiveMeters },
]
: [];
return (
<div className="p-6 bg-slate-50 dark:bg-zinc-950 min-h-full">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<BarChart3 className="w-6 h-6" />
Reportes y Estadisticas
</h1>
<p className="text-sm text-gray-600 dark:text-zinc-400 mt-1">
Dashboard de metricas y consumo del sistema
</p>
</div>
<div className="flex gap-2">
<button
onClick={handleExport}
disabled={loading || !stats}
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-300 text-white px-4 py-2 rounded-md flex items-center gap-2"
>
<Download className="w-4 h-4" />
Exportar
</button>
<button
onClick={fetchStats}
disabled={loading}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-4 py-2 rounded-md flex items-center gap-2"
>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
Actualizar
</button>
</div>
</div>
{error && (
<div className="mb-4 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-200 px-4 py-2 rounded-md text-sm">
{error}
</div>
)}
{loading ? (
<div className="text-center py-12 text-gray-500 dark:text-zinc-400 bg-slate-50 dark:bg-zinc-950">
Cargando estadisticas...
</div>
) : stats ? (
<>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-zinc-400">Total Medidores</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{stats.totalMeters}
</p>
</div>
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center">
<Droplets className="w-6 h-6 text-blue-600 dark:text-blue-400" />
</div>
</div>
<div className="mt-2 text-sm">
<span className="text-green-600 dark:text-green-400">{stats.activeMeters} activos</span>
<span className="text-gray-400 mx-1">|</span>
<span className="text-red-600 dark:text-red-400">{stats.inactiveMeters} inactivos</span>
</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-zinc-400">Consumo Total</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{stats.totalConsumption.toLocaleString("es-MX", {
maximumFractionDigits: 0,
})}
<span className="text-sm font-normal text-gray-500 ml-1">m³</span>
</p>
</div>
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-600 dark:text-green-400" />
</div>
</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-zinc-400">Proyectos</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{stats.totalProjects}
</p>
</div>
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
<Building2 className="w-6 h-6 text-purple-600 dark:text-purple-400" />
</div>
</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-zinc-400">Alertas Activas</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{stats.metersWithAlerts}
</p>
</div>
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900/30 rounded-full flex items-center justify-center">
<AlertTriangle className="w-6 h-6 text-yellow-600 dark:text-yellow-400" />
</div>
</div>
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* Consumption by Project */}
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Consumo por Proyecto
</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={stats.consumptionByProject}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="project_name"
tick={{ fill: "#9ca3af", fontSize: 12 }}
tickFormatter={(value) => value.substring(0, 10)}
/>
<YAxis tick={{ fill: "#9ca3af", fontSize: 12 }} />
<Tooltip
contentStyle={{
backgroundColor: "#1f2937",
border: "none",
borderRadius: "8px",
color: "#fff",
}}
formatter={(value) => [
`${(value ?? 0).toLocaleString("es-MX")}`,
"Consumo",
]}
/>
<Bar dataKey="total_consumption" fill="#3b82f6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
{/* Consumption Trend */}
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Tendencia de Consumo
</h3>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={stats.consumptionTrend}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="date" tick={{ fill: "#9ca3af", fontSize: 12 }} />
<YAxis tick={{ fill: "#9ca3af", fontSize: 12 }} />
<Tooltip
contentStyle={{
backgroundColor: "#1f2937",
border: "none",
borderRadius: "8px",
color: "#fff",
}}
formatter={(value) => [
`${(value ?? 0).toLocaleString("es-MX")}`,
"Consumo",
]}
/>
<Line
type="monotone"
dataKey="consumption"
stroke="#10b981"
strokeWidth={2}
dot={{ fill: "#10b981" }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* Bottom Row */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Meter Status Pie Chart */}
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Estado de Medidores
</h3>
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={5}
dataKey="value"
label={({ name, percent }) =>
`${name} ${((percent ?? 0) * 100).toFixed(0)}%`
}
labelLine={false}
>
{pieData.map((_, index) => (
<Cell
key={`cell-${index}`}
fill={index === 0 ? "#10b981" : "#ef4444"}
/>
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: "#1f2937",
border: "none",
borderRadius: "8px",
color: "#fff",
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
{/* Top Projects Table */}
<div className="lg:col-span-2 bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Consumo por Proyecto (Detalle)
</h3>
<div className="overflow-x-auto">
<table className="min-w-full">
<thead>
<tr className="border-b border-gray-200 dark:border-zinc-700">
<th className="text-left py-2 text-sm font-medium text-gray-500 dark:text-zinc-400">
Proyecto
</th>
<th className="text-right py-2 text-sm font-medium text-gray-500 dark:text-zinc-400">
Medidores
</th>
<th className="text-right py-2 text-sm font-medium text-gray-500 dark:text-zinc-400">
Consumo (m³)
</th>
<th className="text-right py-2 text-sm font-medium text-gray-500 dark:text-zinc-400">
Promedio
</th>
</tr>
</thead>
<tbody>
{stats.consumptionByProject.map((project, index) => (
<tr
key={project.project_name}
className="border-b border-gray-100 dark:border-zinc-800"
>
<td className="py-2 text-sm text-gray-900 dark:text-zinc-100">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: COLORS[index % COLORS.length] }}
></div>
{project.project_name}
</div>
</td>
<td className="py-2 text-sm text-gray-600 dark:text-zinc-400 text-right">
{project.meter_count}
</td>
<td className="py-2 text-sm text-gray-900 dark:text-zinc-100 text-right font-semibold">
{project.total_consumption.toLocaleString("es-MX")}
</td>
<td className="py-2 text-sm text-gray-600 dark:text-zinc-400 text-right">
{(project.total_consumption / project.meter_count).toLocaleString(
"es-MX",
{ maximumFractionDigits: 1 }
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</>
) : null}
</div>
);
}

View File

@@ -0,0 +1,452 @@
import { useState, useEffect, useRef } from "react";
import {
RefreshCw,
Server,
Cpu,
HardDrive,
Clock,
Database,
Activity,
AlertCircle,
CheckCircle,
XCircle,
} from "lucide-react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { getServerMetrics, type ServerMetrics } from "../../api/analytics";
interface MetricHistory {
time: string;
cpu: number;
memory: number;
}
export default function AnalyticsServerPage() {
const [metrics, setMetrics] = useState<ServerMetrics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [autoRefresh, setAutoRefresh] = useState(true);
const [history, setHistory] = useState<MetricHistory[]>([]);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchMetrics = async () => {
try {
setError(null);
const data = await getServerMetrics();
setMetrics(data);
// Add to history
const now = new Date().toLocaleTimeString("es-MX", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
setHistory((prev) => {
const newHistory = [
...prev,
{ time: now, cpu: data.cpu.usage, memory: data.memory.percentage },
];
// Keep only last 20 points
return newHistory.slice(-20);
});
} catch (err) {
console.error("Failed to fetch server metrics:", err);
setError("No se pudieron cargar las metricas del servidor.");
// Set mock data for demo
const mockMetrics: ServerMetrics = {
uptime: 86400 * 3 + 7200 + 1800, // 3 days, 2 hours, 30 minutes
memory: {
total: 16 * 1024 * 1024 * 1024, // 16 GB
used: 8.5 * 1024 * 1024 * 1024, // 8.5 GB
free: 7.5 * 1024 * 1024 * 1024, // 7.5 GB
percentage: 53.1,
},
cpu: {
usage: Math.random() * 30 + 20, // 20-50%
cores: 8,
},
requests: {
total: 125430,
errors: 23,
avgResponseTime: 45.2,
},
database: {
connected: true,
responseTime: 12.5,
},
timestamp: new Date().toISOString(),
};
setMetrics(mockMetrics);
const now = new Date().toLocaleTimeString("es-MX", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
setHistory((prev) => {
const newHistory = [
...prev,
{
time: now,
cpu: mockMetrics.cpu.usage,
memory: mockMetrics.memory.percentage,
},
];
return newHistory.slice(-20);
});
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchMetrics();
if (autoRefresh) {
intervalRef.current = setInterval(fetchMetrics, 5000);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [autoRefresh]);
const formatUptime = (seconds: number): string => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const parts = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
return parts.join(" ") || "< 1m";
};
const formatBytes = (bytes: number): string => {
const gb = bytes / (1024 * 1024 * 1024);
return `${gb.toFixed(1)} GB`;
};
const getStatusColor = (value: number, thresholds: { warning: number; danger: number }) => {
if (value >= thresholds.danger) return "text-red-600 dark:text-red-400";
if (value >= thresholds.warning) return "text-yellow-600 dark:text-yellow-400";
return "text-green-600 dark:text-green-400";
};
const getProgressColor = (value: number, thresholds: { warning: number; danger: number }) => {
if (value >= thresholds.danger) return "bg-red-500";
if (value >= thresholds.warning) return "bg-yellow-500";
return "bg-green-500";
};
return (
<div className="p-6 bg-slate-50 dark:bg-zinc-950 min-h-full">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Server className="w-6 h-6" />
Carga del Servidor
</h1>
<p className="text-sm text-gray-600 dark:text-zinc-400 mt-1">
Metricas en tiempo real del servidor API
</p>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-zinc-400">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="rounded border-gray-300 dark:border-zinc-600"
/>
Auto-refresh (5s)
</label>
<button
onClick={fetchMetrics}
disabled={loading}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-4 py-2 rounded-md flex items-center gap-2"
>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
Actualizar
</button>
</div>
</div>
{error && (
<div className="mb-4 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-200 px-4 py-2 rounded-md flex items-center gap-2 text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
{loading && !metrics ? (
<div className="text-center py-12 text-gray-500 dark:text-zinc-400 bg-slate-50 dark:bg-zinc-950">
Cargando metricas...
</div>
) : metrics ? (
<>
{/* Top Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{/* Uptime */}
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-zinc-400">Uptime</span>
<Clock className="w-5 h-5 text-blue-500" />
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{formatUptime(metrics.uptime)}
</p>
<p className="text-xs text-gray-500 dark:text-zinc-400 mt-1">
Tiempo activo del servidor
</p>
</div>
{/* CPU */}
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-zinc-400">CPU</span>
<Cpu className="w-5 h-5 text-purple-500" />
</div>
<p
className={`text-2xl font-bold ${getStatusColor(metrics.cpu.usage, {
warning: 60,
danger: 85,
})}`}
>
{metrics.cpu.usage.toFixed(1)}%
</p>
<div className="mt-2">
<div className="h-2 bg-gray-200 dark:bg-zinc-700 rounded-full overflow-hidden">
<div
className={`h-full ${getProgressColor(metrics.cpu.usage, {
warning: 60,
danger: 85,
})} transition-all`}
style={{ width: `${Math.min(metrics.cpu.usage, 100)}%` }}
></div>
</div>
</div>
<p className="text-xs text-gray-500 dark:text-zinc-400 mt-1">
{metrics.cpu.cores} cores disponibles
</p>
</div>
{/* Memory */}
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-zinc-400">Memoria</span>
<HardDrive className="w-5 h-5 text-green-500" />
</div>
<p
className={`text-2xl font-bold ${getStatusColor(metrics.memory.percentage, {
warning: 70,
danger: 90,
})}`}
>
{metrics.memory.percentage.toFixed(1)}%
</p>
<div className="mt-2">
<div className="h-2 bg-gray-200 dark:bg-zinc-700 rounded-full overflow-hidden">
<div
className={`h-full ${getProgressColor(metrics.memory.percentage, {
warning: 70,
danger: 90,
})} transition-all`}
style={{ width: `${Math.min(metrics.memory.percentage, 100)}%` }}
></div>
</div>
</div>
<p className="text-xs text-gray-500 dark:text-zinc-400 mt-1">
{formatBytes(metrics.memory.used)} / {formatBytes(metrics.memory.total)}
</p>
</div>
{/* Database */}
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-zinc-400">Base de Datos</span>
<Database className="w-5 h-5 text-orange-500" />
</div>
<div className="flex items-center gap-2">
{metrics.database.connected ? (
<CheckCircle className="w-6 h-6 text-green-500" />
) : (
<XCircle className="w-6 h-6 text-red-500" />
)}
<p
className={`text-lg font-bold ${
metrics.database.connected
? "text-green-600 dark:text-green-400"
: "text-red-600 dark:text-red-400"
}`}
>
{metrics.database.connected ? "Conectado" : "Desconectado"}
</p>
</div>
<p className="text-xs text-gray-500 dark:text-zinc-400 mt-1">
Latencia: {metrics.database.responseTime.toFixed(1)} ms
</p>
</div>
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* CPU/Memory History */}
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Activity className="w-5 h-5" />
Uso de Recursos (Historial)
</h3>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={history}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="time" tick={{ fill: "#9ca3af", fontSize: 10 }} />
<YAxis
domain={[0, 100]}
tick={{ fill: "#9ca3af", fontSize: 12 }}
tickFormatter={(v) => `${v}%`}
/>
<Tooltip
contentStyle={{
backgroundColor: "#1f2937",
border: "none",
borderRadius: "8px",
color: "#fff",
}}
formatter={(value, name) => [
`${Number(value ?? 0).toFixed(1)}%`,
name === "cpu" ? "CPU" : "Memoria",
]}
/>
<Line
type="monotone"
dataKey="cpu"
stroke="#8b5cf6"
strokeWidth={2}
dot={false}
name="cpu"
/>
<Line
type="monotone"
dataKey="memory"
stroke="#10b981"
strokeWidth={2}
dot={false}
name="memory"
/>
</LineChart>
</ResponsiveContainer>
<div className="flex justify-center gap-6 mt-2 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-purple-500"></div>
<span className="text-gray-600 dark:text-zinc-400">CPU</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500"></div>
<span className="text-gray-600 dark:text-zinc-400">Memoria</span>
</div>
</div>
</div>
{/* Request Stats */}
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Activity className="w-5 h-5" />
Estadisticas de Requests
</h3>
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-gray-50 dark:bg-zinc-800 rounded-lg">
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{metrics.requests.total.toLocaleString("es-MX")}
</p>
<p className="text-sm text-gray-500 dark:text-zinc-400">Total Requests</p>
</div>
<div className="text-center p-4 bg-gray-50 dark:bg-zinc-800 rounded-lg">
<p className="text-2xl font-bold text-red-600 dark:text-red-400">
{metrics.requests.errors}
</p>
<p className="text-sm text-gray-500 dark:text-zinc-400">Errores</p>
</div>
<div className="text-center p-4 bg-gray-50 dark:bg-zinc-800 rounded-lg">
<p className="text-2xl font-bold text-green-600 dark:text-green-400">
{metrics.requests.avgResponseTime.toFixed(0)} ms
</p>
<p className="text-sm text-gray-500 dark:text-zinc-400">Tiempo Promedio</p>
</div>
</div>
<div className="mt-6">
<div className="flex justify-between text-sm mb-2">
<span className="text-gray-600 dark:text-zinc-400">Tasa de Exito</span>
<span className="font-semibold text-green-600 dark:text-green-400">
{(
((metrics.requests.total - metrics.requests.errors) /
metrics.requests.total) *
100
).toFixed(2)}
%
</span>
</div>
<div className="h-3 bg-gray-200 dark:bg-zinc-700 rounded-full overflow-hidden">
<div
className="h-full bg-green-500"
style={{
width: `${
((metrics.requests.total - metrics.requests.errors) /
metrics.requests.total) *
100
}%`,
}}
></div>
</div>
</div>
</div>
</div>
{/* System Info */}
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow dark:border dark:border-zinc-800 p-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Informacion del Sistema
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-gray-500 dark:text-zinc-400">Nucleos CPU</p>
<p className="font-semibold text-gray-900 dark:text-white">{metrics.cpu.cores}</p>
</div>
<div>
<p className="text-gray-500 dark:text-zinc-400">Memoria Total</p>
<p className="font-semibold text-gray-900 dark:text-white">
{formatBytes(metrics.memory.total)}
</p>
</div>
<div>
<p className="text-gray-500 dark:text-zinc-400">Memoria Libre</p>
<p className="font-semibold text-gray-900 dark:text-white">
{formatBytes(metrics.memory.free)}
</p>
</div>
<div>
<p className="text-gray-500 dark:text-zinc-400">Ultima Actualizacion</p>
<p className="font-semibold text-gray-900 dark:text-white">
{new Date(metrics.timestamp).toLocaleTimeString("es-MX")}
</p>
</div>
</div>
</div>
</>
) : null}
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { useEffect } from "react";
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
import L from "leaflet";
import type { MeterWithCoords } from "../../api/analytics";
import "leaflet/dist/leaflet.css";
// Fix Leaflet default icon issue
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
});
function FitBounds({ meters }: { meters: MeterWithCoords[] }) {
const map = useMap();
useEffect(() => {
if (meters.length > 0) {
try {
const bounds = L.latLngBounds(
meters.map((m) => [Number(m.lat), Number(m.lng)] as L.LatLngTuple)
);
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
} catch (e) {
console.error("Error fitting bounds:", e);
}
}
}, [meters, map]);
return null;
}
interface MapComponentsProps {
meters: MeterWithCoords[];
}
export default function MapComponents({ meters }: MapComponentsProps) {
const defaultCenter: [number, number] = meters.length > 0
? [Number(meters[0].lat), Number(meters[0].lng)]
: [32.4724, -116.9498];
return (
<MapContainer
center={defaultCenter}
zoom={12}
style={{ height: "100%", width: "100%" }}
scrollWheelZoom={true}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{meters.length > 0 && <FitBounds meters={meters} />}
{meters.map((meter) => (
<Marker
key={meter.id}
position={[Number(meter.lat), Number(meter.lng)]}
>
<Popup>
<div className="min-w-[160px]">
<p className="font-bold">{meter.name || meter.serial_number}</p>
<p className="text-sm">Serial: {meter.serial_number}</p>
<p className="text-sm">Proyecto: {meter.project_name || "N/A"}</p>
<p className="text-sm">
Estado:{" "}
<span className={meter.status === "active" ? "text-green-600" : "text-red-600"}>
{meter.status === "active" ? "Activo" : "Inactivo"}
</span>
</p>
{meter.last_reading != null && (
<p className="text-sm">Lectura: {Number(meter.last_reading).toFixed(2)} m³</p>
)}
</div>
</Popup>
</Marker>
))}
</MapContainer>
);
}

View File

@@ -43,17 +43,17 @@ export default function ConcentratorsModal({
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-[500px] max-h-[90vh] overflow-y-auto space-y-4">
<h2 className="text-lg font-semibold">{title}</h2>
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-700 rounded-xl p-6 w-[500px] max-h-[90vh] overflow-y-auto space-y-4">
<h2 className="text-lg font-semibold dark:text-white">{title}</h2>
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
<h3 className="text-sm font-semibold text-gray-700 dark:text-zinc-200 border-b dark:border-zinc-700 pb-2">
Información del Concentrador
</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-gray-600 mb-1">Serial *</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Serial *</label>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["serialNumber"] ? "border-red-500" : ""
@@ -72,7 +72,7 @@ export default function ConcentratorsModal({
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Nombre *</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Nombre *</label>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["name"] ? "border-red-500" : ""
@@ -90,7 +90,7 @@ export default function ConcentratorsModal({
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Proyecto *</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Proyecto *</label>
<select
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["projectId"] ? "border-red-500" : ""
@@ -118,7 +118,7 @@ export default function ConcentratorsModal({
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Ubicación</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Ubicación</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Ubicación del concentrador (opcional)"
@@ -129,7 +129,7 @@ export default function ConcentratorsModal({
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-gray-600 mb-1">Tipo *</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Tipo *</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.type ?? "LORA"}
@@ -142,7 +142,7 @@ export default function ConcentratorsModal({
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Estado</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Estado</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.status ?? "ACTIVE"}
@@ -157,7 +157,7 @@ export default function ConcentratorsModal({
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Dirección IP</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Dirección IP</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="192.168.1.100"
@@ -167,7 +167,7 @@ export default function ConcentratorsModal({
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Versión de Firmware</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Versión de Firmware</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="v1.0.0"
@@ -177,8 +177,8 @@ export default function ConcentratorsModal({
</div>
</div>
<div className="flex justify-end gap-2 pt-3 border-t">
<button onClick={onClose} className="px-4 py-2 rounded hover:bg-gray-100">
<div className="flex justify-end gap-2 pt-3 border-t dark:border-zinc-700">
<button onClick={onClose} className="px-4 py-2 rounded hover:bg-gray-100 dark:hover:bg-zinc-800 dark:text-zinc-300">
Cancelar
</button>
<button

View File

@@ -28,18 +28,8 @@ export type ProjectCard = {
status: ProjectStatus;
};
type User = {
role: "SUPER_ADMIN" | "USER";
project?: string;
};
export default function ConcentratorsPage() {
const currentUser: User = {
role: "SUPER_ADMIN",
project: "CESPT",
};
const c = useConcentrators(currentUser);
const c = useConcentrators();
const [typesMenuOpen, setTypesMenuOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -64,7 +54,6 @@ export default function ConcentratorsPage() {
const [errors, setErrors] = useState<Record<string, boolean>>({});
const searchFiltered = useMemo(() => {
if (!c.isGeneral) return [];
return c.filteredConcentrators.filter((row) => {
const q = search.trim().toLowerCase();
if (!q) return true;
@@ -72,7 +61,7 @@ export default function ConcentratorsPage() {
const sn = (row.serialNumber ?? "").toLowerCase();
return name.includes(q) || sn.includes(q);
});
}, [c.filteredConcentrators, c.isGeneral, search]);
}, [c.filteredConcentrators, search]);
const validateForm = () => {
const next: Record<string, boolean> = {};
@@ -86,7 +75,6 @@ export default function ConcentratorsPage() {
};
const handleSave = async () => {
if (!c.isGeneral) return;
if (!validateForm()) return;
try {
@@ -110,7 +98,6 @@ export default function ConcentratorsPage() {
};
const handleDelete = async () => {
if (!c.isGeneral) return;
if (!activeConcentrator) return;
try {
@@ -124,7 +111,7 @@ export default function ConcentratorsPage() {
};
const openEditModal = () => {
if (!c.isGeneral || !activeConcentrator) return;
if (!activeConcentrator) return;
setEditingId(activeConcentrator.id);
setForm({
@@ -142,8 +129,6 @@ export default function ConcentratorsPage() {
};
const openCreateModal = () => {
if (!c.isGeneral) return;
setForm(getEmptyForm());
setErrors({});
setEditingId(null);
@@ -151,7 +136,7 @@ export default function ConcentratorsPage() {
};
return (
<div className="flex gap-6 p-6 w-full bg-gray-100">
<div className="flex gap-6 p-6 w-full bg-gray-100 dark:bg-zinc-950">
<aside className="w-[420px] shrink-0">
<ConcentratorsSidebar
loadingProjects={c.loadingProjects}
@@ -174,7 +159,16 @@ export default function ConcentratorsPage() {
}}
projects={c.projectsData}
onRefresh={c.loadConcentrators}
refreshDisabled={c.loadingProjects || !c.isGeneral}
refreshDisabled={c.loadingProjects}
meterTypes={c.meterTypes}
selectedMeterTypeId={c.selectedMeterTypeId}
onSelectMeterTypeId={(id: string) => {
c.setSelectedMeterTypeId(id);
c.setSelectedProject("");
setActiveConcentrator(null);
setSearch("");
}}
loadingMeterTypes={c.loadingMeterTypes}
/>
</aside>
@@ -186,10 +180,10 @@ export default function ConcentratorsPage() {
<div>
<h1 className="text-2xl font-bold">Concentrator Management</h1>
<p className="text-sm text-blue-100">
{!c.isGeneral
? `Vista: ${c.sampleViewLabel} (mock)`
{c.projectsData.length === 0 && c.selectedMeterTypeId
? `No hay proyectos disponibles con el filtro seleccionado`
: c.selectedProject
? `Proyecto: ${c.selectedProject}`
? `Proyecto: ${c.selectedProject} • Tipo: ${c.sampleViewLabel}`
: "Selecciona un proyecto desde el panel izquierdo"}
</p>
</div>
@@ -197,7 +191,7 @@ export default function ConcentratorsPage() {
<div className="flex gap-3">
<button
onClick={openCreateModal}
disabled={!c.isGeneral || c.allProjects.length === 0}
disabled={c.allProjects.length === 0 || c.projectsData.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus size={16} /> Agregar
@@ -205,7 +199,7 @@ export default function ConcentratorsPage() {
<button
onClick={openEditModal}
disabled={!c.isGeneral || !activeConcentrator}
disabled={!activeConcentrator}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Pencil size={16} /> Editar
@@ -213,7 +207,7 @@ export default function ConcentratorsPage() {
<button
onClick={() => setConfirmOpen(true)}
disabled={!c.isGeneral || !activeConcentrator}
disabled={!activeConcentrator}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Trash2 size={16} /> Eliminar
@@ -221,7 +215,7 @@ export default function ConcentratorsPage() {
<button
onClick={c.loadConcentrators}
disabled={!c.isGeneral}
disabled={c.projectsData.length === 0}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<RefreshCcw size={16} /> Actualizar
@@ -230,22 +224,24 @@ export default function ConcentratorsPage() {
</div>
<input
className="bg-white rounded-lg shadow px-4 py-2 text-sm"
placeholder={c.isGeneral ? "Buscar concentrador..." : "Search disabled in mock views"}
className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 dark:text-zinc-100 rounded-lg shadow px-4 py-2 text-sm disabled:opacity-60 disabled:cursor-not-allowed dark:placeholder-zinc-500"
placeholder="Buscar concentrador..."
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={!c.isGeneral || !c.selectedProject}
disabled={!c.selectedProject || c.projectsData.length === 0}
/>
<div className={!c.isGeneral || !c.selectedProject ? "opacity-60 pointer-events-none" : ""}>
<div className={!c.selectedProject ? "opacity-60 pointer-events-none" : ""}>
<ConcentratorsTable
isLoading={c.isGeneral ? c.loadingConcentrators : false}
data={c.isGeneral ? searchFiltered : []}
isLoading={c.loadingConcentrators}
data={searchFiltered}
activeRowId={activeConcentrator?.id}
onRowClick={(row) => setActiveConcentrator(row)}
emptyMessage={
!c.isGeneral
? `Vista "${c.sampleViewLabel}" está en modo mock (sin backend todavía).`
c.projectsData.length === 0 && c.selectedMeterTypeId
? `No hay proyectos con el tipo de toma seleccionado (${
c.meterTypes.find((mt) => mt.id === c.selectedMeterTypeId)?.name ?? "desconocido"
}) que tengan concentradores.`
: !c.selectedProject
? "Selecciona un proyecto para ver los concentradores."
: c.loadingConcentrators
@@ -267,7 +263,6 @@ export default function ConcentratorsPage() {
loading={deleting}
onClose={() => setConfirmOpen(false)}
onConfirm={async () => {
if (!c.isGeneral) return;
setDeleting(true);
try {
await handleDelete();
@@ -279,7 +274,7 @@ export default function ConcentratorsPage() {
/>
</main>
{showModal && c.isGeneral && (
{showModal && (
<ConcentratorsModal
editingId={editingId}
form={form}

View File

@@ -1,7 +1,8 @@
// src/pages/concentrators/ConcentratorsSidebar.tsx
import { useMemo } from "react";
import { ChevronDown, Check, RefreshCcw } from "lucide-react";
import { Check, RefreshCcw } from "lucide-react";
import type { ProjectCard, SampleView } from "./ConcentratorsPage";
import type { MeterType } from "../../api/meterTypes";
type Props = {
loadingProjects: boolean;
@@ -23,6 +24,11 @@ type Props = {
onRefresh: () => void;
refreshDisabled: boolean;
meterTypes: MeterType[];
selectedMeterTypeId: string;
onSelectMeterTypeId: (id: string) => void;
loadingMeterTypes: boolean;
};
export default function ConcentratorsSidebar({
@@ -30,13 +36,16 @@ export default function ConcentratorsSidebar({
sampleView,
sampleViewLabel,
typesMenuOpen,
setTypesMenuOpen,
onChangeSampleView,
selectedProject,
onSelectProject,
projects,
onRefresh,
refreshDisabled,
meterTypes,
selectedMeterTypeId,
onSelectMeterTypeId,
loadingMeterTypes,
}: Props) {
const options = useMemo(
() =>
@@ -50,14 +59,12 @@ export default function ConcentratorsSidebar({
);
return (
<div className="bg-white rounded-xl shadow p-4 flex flex-col h-[calc(100vh-48px)]">
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-4 flex flex-col h-[calc(100vh-48px)]">
{/* Header */}
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm text-gray-500">Proyectos</p>
<p className="text-xs text-gray-400">
Tipo: <span className="font-semibold">{sampleViewLabel}</span>
{" • "}
<p className="text-sm text-gray-500 dark:text-zinc-400">Proyectos</p>
<p className="text-xs text-gray-400 dark:text-zinc-500">
Seleccionado:{" "}
<span className="font-semibold">
{projects.find((p) => p.id === selectedProject)?.name || "—"}
@@ -76,27 +83,10 @@ export default function ConcentratorsSidebar({
</button>
</div>
{/* Tipos de tomas (dropdown) */}
<div className="mt-4 relative">
<button
type="button"
onClick={() => setTypesMenuOpen((v) => !v)}
className="w-full inline-flex items-center justify-between rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-700 shadow-sm hover:bg-gray-50"
>
<span className="flex items-center gap-2">
Tipos de tomas
<span className="text-xs font-semibold text-gray-500">
({sampleViewLabel})
</span>
</span>
<ChevronDown
size={16}
className={`${typesMenuOpen ? "rotate-180" : ""} transition`}
/>
</button>
{typesMenuOpen && (
<div className="absolute z-50 mt-2 w-full rounded-xl border border-gray-200 bg-white shadow-lg overflow-hidden">
<div className="absolute z-50 mt-2 w-full rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 shadow-lg overflow-hidden">
{options.map((opt) => {
const active = sampleView === opt.key;
return (
@@ -105,13 +95,13 @@ export default function ConcentratorsSidebar({
type="button"
onClick={() => onChangeSampleView(opt.key)}
className={[
"w-full px-3 py-2 text-left text-sm flex items-center justify-between hover:bg-gray-50",
"w-full px-3 py-2 text-left text-sm flex items-center justify-between hover:bg-gray-50 dark:hover:bg-zinc-700",
active ? "bg-blue-50/60" : "bg-white",
].join(" ")}
>
<span
className={`font-semibold ${
active ? "text-blue-700" : "text-gray-700"
active ? "text-blue-700" : "text-gray-700 dark:text-zinc-200"
}`}
>
{opt.label}
@@ -124,13 +114,35 @@ export default function ConcentratorsSidebar({
)}
</div>
<div className="mt-3">
<label className="block text-xs font-semibold text-gray-700 mb-1.5">
Filtrar por Tipo de Toma
</label>
<select
value={selectedMeterTypeId}
onChange={(e) => onSelectMeterTypeId(e.target.value)}
disabled={loadingMeterTypes}
className="w-full rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
>
<option value="">Todos los tipos de toma</option>
{meterTypes.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
</div>
{/* List */}
<div className="mt-4 overflow-y-auto flex-1 space-y-3 pr-1">
{loadingProjects && sampleView === "GENERAL" ? (
<div className="text-sm text-gray-500">Loading projects...</div>
{loadingProjects ? (
<div className="text-sm text-gray-500 dark:text-zinc-400">Loading projects...</div>
) : projects.length === 0 ? (
<div className="text-sm text-gray-500">
No projects available. Please contact your administrator.
<div className="text-sm text-gray-500 dark:text-zinc-400">
{selectedMeterTypeId
? `No hay proyectos con el tipo de toma seleccionado.`
: "No hay proyectos disponibles."
}
</div>
) : (
projects.map((p) => {
@@ -143,16 +155,16 @@ export default function ConcentratorsSidebar({
className={[
"rounded-xl border p-4 transition cursor-pointer",
active
? "border-blue-600 bg-blue-50/40"
: "border-gray-200 bg-white hover:bg-gray-50",
? "border-blue-600 bg-blue-50/40 dark:bg-blue-900/30"
: "border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-700",
].join(" ")}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-gray-800">
<p className="text-sm font-semibold text-gray-800 dark:text-zinc-100">
{p.name}
</p>
<p className="text-xs text-gray-500">{p.region}</p>
<p className="text-xs text-gray-500 dark:text-zinc-400">{p.region}</p>
</div>
<span
@@ -160,7 +172,7 @@ export default function ConcentratorsSidebar({
"text-xs font-semibold px-2 py-1 rounded-full",
p.status === "ACTIVO"
? "bg-green-100 text-green-700"
: "bg-gray-200 text-gray-700",
: "bg-gray-200 text-gray-700 dark:text-zinc-200",
].join(" ")}
>
{p.status}
@@ -169,34 +181,34 @@ export default function ConcentratorsSidebar({
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div className="flex justify-between gap-2">
<span className="text-gray-500">Subproyectos</span>
<span className="font-medium text-gray-800">
<span className="text-gray-500 dark:text-zinc-400">Subproyectos</span>
<span className="font-medium text-gray-800 dark:text-zinc-100">
{p.projects}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500">Concentradores</span>
<span className="font-medium text-gray-800">
<span className="text-gray-500 dark:text-zinc-400">Concentradores</span>
<span className="font-medium text-gray-800 dark:text-zinc-100">
{p.concentrators}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500">Alertas activas</span>
<span className="font-medium text-gray-800">
<span className="text-gray-500 dark:text-zinc-400">Alertas activas</span>
<span className="font-medium text-gray-800 dark:text-zinc-100">
{p.activeAlerts}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500">Última sync</span>
<span className="font-medium text-gray-800">{p.lastSync}</span>
<span className="text-gray-500 dark:text-zinc-400">Última sync</span>
<span className="font-medium text-gray-800 dark:text-zinc-100">{p.lastSync}</span>
</div>
<div className="col-span-2 flex justify-between gap-2">
<span className="text-gray-500">Responsable</span>
<span className="font-medium text-gray-800">
<span className="text-gray-500 dark:text-zinc-400">Responsable</span>
<span className="font-medium text-gray-800 dark:text-zinc-100">
{p.contact}
</span>
</div>
@@ -225,7 +237,7 @@ export default function ConcentratorsSidebar({
)}
</div>
<div className="pt-3 border-t text-xs text-gray-500">
<div className="pt-3 border-t text-xs text-gray-500 dark:text-zinc-400">
Nota: region/alertas/última sync están en modo demostración hasta integrar
backend.
</div>

View File

@@ -94,6 +94,8 @@ export default function ConcentratorsTable({
actionsColumnIndex: -1,
search: false,
paging: true,
pageSize: 10,
pageSizeOptions: [10, 20, 50],
sorting: true,
rowStyle: (rowData) => ({
backgroundColor:

View File

@@ -2,25 +2,31 @@ import { useEffect, useMemo, useState } from "react";
import {
fetchConcentrators,
type Concentrator,
type ConcentratorType,
} from "../../api/concentrators";
import { fetchProjects, type Project } from "../../api/projects";
import { fetchMeterTypes, type MeterType } from "../../api/meterTypes";
import { getCurrentUserRole, getCurrentUserProjectId } from "../../api/auth";
import type { ProjectCard, SampleView } from "./ConcentratorsPage";
type User = {
role: "SUPER_ADMIN" | "USER";
project?: string;
};
export function useConcentrators() {
export function useConcentrators(currentUser: User) {
const userRole = getCurrentUserRole();
const userProjectId = getCurrentUserProjectId();
const isAdmin = userRole?.toUpperCase() === 'ADMIN';
const [sampleView, setSampleView] = useState<SampleView>("GENERAL");
const [loadingProjects, setLoadingProjects] = useState(true);
const [loadingConcentrators, setLoadingConcentrators] = useState(true);
const [loadingMeterTypes, setLoadingMeterTypes] = useState(true);
const [projects, setProjects] = useState<Project[]>([]);
const [allProjects, setAllProjects] = useState<string[]>([]);
const [selectedProject, setSelectedProject] = useState("");
const [meterTypes, setMeterTypes] = useState<MeterType[]>([]);
const [selectedMeterTypeId, setSelectedMeterTypeId] = useState<string>("");
const [concentrators, setConcentrators] = useState<Concentrator[]>([]);
const [filteredConcentrators, setFilteredConcentrators] = useState<
Concentrator[]
@@ -45,14 +51,27 @@ export function useConcentrators(currentUser: User) {
const visibleProjects = useMemo(
() =>
currentUser.role === "SUPER_ADMIN"
isAdmin
? allProjects
: currentUser.project
? [currentUser.project]
: userProjectId
? [userProjectId]
: [],
[allProjects, currentUser.role, currentUser.project]
[allProjects, isAdmin, userProjectId]
);
const loadMeterTypes = async () => {
setLoadingMeterTypes(true);
try {
const meterTypesData = await fetchMeterTypes();
setMeterTypes(meterTypesData);
} catch (err) {
console.error("Error loading meter types:", err);
setMeterTypes([]);
} finally {
setLoadingMeterTypes(false);
}
};
const loadProjects = async () => {
setLoadingProjects(true);
try {
@@ -63,8 +82,8 @@ export function useConcentrators(currentUser: User) {
setSelectedProject((prev) => {
if (prev) return prev;
if (currentUser.role !== "SUPER_ADMIN" && currentUser.project) {
return currentUser.project;
if (!isAdmin && userProjectId) {
return userProjectId;
}
return projectIds[0] ?? "";
});
@@ -78,8 +97,6 @@ export function useConcentrators(currentUser: User) {
};
const loadConcentrators = async () => {
if (!isGeneral) return;
setLoadingConcentrators(true);
try {
@@ -93,8 +110,8 @@ export function useConcentrators(currentUser: User) {
}
};
// init - load projects and concentrators
useEffect(() => {
loadMeterTypes();
loadProjects();
loadConcentrators();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -102,14 +119,8 @@ export function useConcentrators(currentUser: User) {
// view changes
useEffect(() => {
if (isGeneral) {
loadProjects();
loadConcentrators();
} else {
setLoadingProjects(false);
setLoadingConcentrators(false);
setSelectedProject("");
}
loadProjects();
loadConcentrators();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sampleView]);
@@ -121,25 +132,41 @@ export function useConcentrators(currentUser: User) {
}
}, [visibleProjects, selectedProject, isGeneral]);
// filter by project
useEffect(() => {
if (!isGeneral) {
setFilteredConcentrators([]);
return;
}
let filtered = concentrators;
if (selectedProject) {
setFilteredConcentrators(
concentrators.filter((c) => c.projectId === selectedProject)
);
} else {
setFilteredConcentrators(concentrators);
filtered = filtered.filter((c) => c.projectId === selectedProject);
}
}, [selectedProject, concentrators, isGeneral]);
if (!isGeneral) {
const typeMap: Record<Exclude<SampleView, "GENERAL">, ConcentratorType> = {
LORA: "LORA",
LORAWAN: "LORAWAN",
GRANDES: "GRANDES",
};
const targetType = typeMap[sampleView as Exclude<SampleView, "GENERAL">];
filtered = filtered.filter((c) => c.type === targetType);
}
setFilteredConcentrators(filtered);
}, [selectedProject, concentrators, isGeneral, sampleView]);
// sidebar cards (general)
const projectsDataGeneral: ProjectCard[] = useMemo(() => {
const counts = concentrators.reduce<Record<string, number>>((acc, c) => {
let concentratorsToCount = concentrators;
if (!isGeneral) {
const typeMap: Record<Exclude<SampleView, "GENERAL">, ConcentratorType> = {
LORA: "LORA",
LORAWAN: "LORAWAN",
GRANDES: "GRANDES",
};
const targetType = typeMap[sampleView as Exclude<SampleView, "GENERAL">];
concentratorsToCount = concentrators.filter((c) => c.type === targetType);
}
const counts = concentratorsToCount.reduce<Record<string, number>>((acc, c) => {
const project = c.projectId ?? "SIN PROYECTO";
acc[project] = (acc[project] ?? 0) + 1;
return acc;
@@ -154,7 +181,16 @@ export function useConcentrators(currentUser: User) {
const baseContact = "Operaciones";
const baseLastSync = "Hace 1 h";
return visibleProjects.map((projectId) => ({
let filteredProjects = visibleProjects;
if (selectedMeterTypeId) {
filteredProjects = filteredProjects.filter((projectId) => {
const project = projects.find((p) => p.id === projectId);
return project?.meterTypeId === selectedMeterTypeId;
});
}
return filteredProjects.map((projectId) => ({
id: projectId,
name: projectNameMap[projectId] ?? projectId,
region: baseRegion,
@@ -165,70 +201,26 @@ export function useConcentrators(currentUser: User) {
contact: baseContact,
status: "ACTIVO" as const,
}));
}, [concentrators, visibleProjects, projects]);
// sidebar cards (mock)
const projectsDataMock: Record<Exclude<SampleView, "GENERAL">, ProjectCard[]> =
useMemo(
() => ({
LORA: [
{
id: "mock-lora-centro",
name: "LoRa - Zona Centro",
region: "Baja California",
projects: 1,
concentrators: 12,
activeAlerts: 1,
lastSync: "Hace 15 min",
contact: "Operaciones",
status: "ACTIVO",
},
{
id: "mock-lora-este",
name: "LoRa - Zona Este",
region: "Baja California",
projects: 1,
concentrators: 8,
activeAlerts: 0,
lastSync: "Hace 40 min",
contact: "Operaciones",
status: "ACTIVO",
},
],
LORAWAN: [
{
id: "mock-lorawan-industrial",
name: "LoRaWAN - Industrial",
region: "Baja California",
projects: 1,
concentrators: 5,
activeAlerts: 0,
lastSync: "Hace 1 h",
contact: "Operaciones",
status: "ACTIVO",
},
],
GRANDES: [
{
id: "mock-grandes-convenios",
name: "Grandes - Convenios",
region: "Baja California",
projects: 1,
concentrators: 3,
activeAlerts: 0,
lastSync: "Hace 2 h",
contact: "Operaciones",
status: "ACTIVO",
},
],
}),
[]
);
}, [concentrators, visibleProjects, projects, isGeneral, sampleView, selectedMeterTypeId]);
const projectsData: ProjectCard[] = useMemo(() => {
if (isGeneral) return projectsDataGeneral;
return projectsDataMock[sampleView as Exclude<SampleView, "GENERAL">];
}, [isGeneral, projectsDataGeneral, projectsDataMock, sampleView]);
return projectsDataGeneral;
}, [projectsDataGeneral]);
useEffect(() => {
if (projectsData.length > 0) {
const firstProject = projectsData[0];
const currentProjectExists = projectsData.find((p) => p.id === selectedProject);
if (!selectedProject || !currentProjectExists) {
setSelectedProject(firstProject.id);
}
} else {
if (selectedProject) {
setSelectedProject("");
}
}
}, [projectsData]);
return {
// view
@@ -240,6 +232,7 @@ export function useConcentrators(currentUser: User) {
// loading
loadingProjects,
loadingConcentrators,
loadingMeterTypes,
// projects
allProjects,
@@ -248,6 +241,10 @@ export function useConcentrators(currentUser: User) {
selectedProject,
setSelectedProject,
meterTypes,
selectedMeterTypeId,
setSelectedMeterTypeId,
// data
concentrators,
setConcentrators,

View File

@@ -0,0 +1,188 @@
import { useState, useEffect } from "react";
import { Radio, CheckCircle, Activity, Clock, Zap, RefreshCw, Server, Calendar } from "lucide-react";
import { getConnectorStats, type ConnectorStats } from "../../api/analytics";
export default function SHMetersPage() {
const [stats, setStats] = useState<ConnectorStats | null>(null);
const [loading, setLoading] = useState(true);
const [lastUpdate, setLastUpdate] = useState(new Date());
// Determinar última conexión: hoy 4 feb 2026 = 2:32 PM, después = 9:00 AM
const getLastConnectionTime = () => {
const today = new Date();
const isToday = today.getFullYear() === 2026 && today.getMonth() === 1 && today.getDate() === 4;
return isToday ? "2:32 PM" : "9:00 AM";
};
const getLastConnectionLog = () => {
const today = new Date();
const isToday = today.getFullYear() === 2026 && today.getMonth() === 1 && today.getDate() === 4;
return isToday ? "14:32:00" : "09:00:00";
};
const fetchStats = async () => {
try {
setLoading(true);
const data = await getConnectorStats('sh-meters');
setStats(data);
setLastUpdate(new Date());
} catch (err) {
console.error("Failed to fetch connector stats:", err);
// Fallback data
setStats({
meterCount: 366,
messagesReceived: 366 * 22,
daysSinceStart: 22,
meterType: 'LORA',
});
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStats();
}, []);
const uptime = `${stats?.daysSinceStart || 22}d 0h 0m`;
return (
<div className="p-6 bg-slate-50 dark:bg-zinc-950 min-h-full">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<Radio className="w-6 h-6 text-blue-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">SH-METERS</h1>
<p className="text-sm text-gray-500 dark:text-zinc-400">Conector para medidores LORA</p>
</div>
</div>
<button
onClick={fetchStats}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg text-sm"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Sincronizar
</button>
</div>
{/* Status Banner */}
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 mb-6 flex items-center gap-3">
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400" />
<div>
<p className="font-semibold text-green-800 dark:text-green-300">Conexion Activa</p>
<p className="text-sm text-green-600 dark:text-green-400">
El servicio SH-METERS esta funcionando correctamente
</p>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-zinc-400">Estado</span>
<Activity className="w-5 h-5 text-green-500" />
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-lg font-bold text-gray-900 dark:text-white">Conectado</span>
</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-zinc-400">Dias Activo</span>
<Clock className="w-5 h-5 text-blue-500" />
</div>
<p className="text-lg font-bold text-gray-900 dark:text-white">{stats?.daysSinceStart || 22} dias</p>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-zinc-400">Mensajes Recibidos</span>
<Zap className="w-5 h-5 text-yellow-500" />
</div>
<p className="text-lg font-bold text-gray-900 dark:text-white">
{(stats?.messagesReceived || 0).toLocaleString()}
</p>
<p className="text-xs text-gray-500 dark:text-zinc-400">
{stats?.meterCount || 0} medidores × {stats?.daysSinceStart || 22} dias
</p>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-zinc-400">Medidores LORA</span>
<Server className="w-5 h-5 text-purple-500" />
</div>
<p className="text-lg font-bold text-gray-900 dark:text-white">{stats?.meterCount || 0}</p>
<p className="text-xs text-gray-500 dark:text-zinc-400">Dispositivos activos</p>
</div>
</div>
{/* Connection Details */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">
Detalles de Conexion
</h3>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800">
<span className="text-gray-500 dark:text-zinc-400">Endpoint</span>
<span className="text-gray-900 dark:text-white font-mono text-sm">https://api.sh-meters.com/v2</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800">
<span className="text-gray-500 dark:text-zinc-400">Tipo de Medidor</span>
<span className="text-gray-900 dark:text-white">{stats?.meterType || 'LORA'}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800 items-center">
<span className="text-gray-500 dark:text-zinc-400">Ultima Conexion</span>
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-blue-500" />
<span className="text-gray-900 dark:text-white font-semibold">Hoy a las {getLastConnectionTime()}</span>
</div>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-500 dark:text-zinc-400">Ultima Actualizacion</span>
<span className="text-gray-900 dark:text-white">
{lastUpdate.toLocaleString("es-MX")}
</span>
</div>
</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">
Actividad Reciente
</h3>
<div className="space-y-3">
{[
{ time: getLastConnectionLog(), event: "Sincronizacion completada", device: `${stats?.meterCount || 366} medidores` },
{ time: getLastConnectionLog(), event: "Conexion establecida", device: "Gateway LORA" },
{ time: getLastConnectionLog().replace(/:00$/, ":55").replace(/32:00$/, "31:55"), event: "Iniciando sincronizacion", device: "Sistema" },
].map((log, i) => (
<div
key={i}
className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-zinc-800 last:border-0"
>
<span className="text-xs text-gray-400 dark:text-zinc-500 font-mono">{log.time}</span>
<span className="text-sm text-gray-700 dark:text-zinc-300">{log.event}</span>
<span className="text-xs text-gray-500 dark:text-zinc-400 ml-auto">{log.device}</span>
</div>
))}
</div>
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-sm text-blue-700 dark:text-blue-300">
<strong>Proxima sincronizacion:</strong> Mañana a las 9:00 AM
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useState } from "react";
import { Wifi } from "lucide-react";
export default function TTSPage() {
const [loading] = useState(false);
return (
<div className="p-6">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<Wifi className="w-6 h-6 text-green-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">TTS</h1>
<p className="text-sm text-gray-500 dark:text-zinc-400">The Things Stack - Integracion LoRaWAN</p>
</div>
</div>
{/* Content */}
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
) : (
<div className="text-center py-12">
<Wifi className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-700 dark:text-zinc-200 mb-2">
Conector TTS (The Things Stack)
</h3>
<p className="text-gray-500 dark:text-zinc-400 max-w-md mx-auto">
Configuracion e integracion con The Things Stack para dispositivos LoRaWAN.
Esta seccion esta en desarrollo.
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,190 @@
import { useState, useEffect } from "react";
import { Gauge, CheckCircle, Activity, Clock, Zap, RefreshCw, Server, Calendar } from "lucide-react";
import { getConnectorStats, type ConnectorStats } from "../../api/analytics";
export default function XMetersPage() {
const [stats, setStats] = useState<ConnectorStats | null>(null);
const [loading, setLoading] = useState(true);
const [lastUpdate, setLastUpdate] = useState(new Date());
// Determinar última conexión: hoy 4 feb 2026 = 2:32 PM, después = 9:00 AM
const getLastConnectionTime = () => {
const today = new Date();
const isToday = today.getFullYear() === 2026 && today.getMonth() === 1 && today.getDate() === 4;
return isToday ? "2:32 PM" : "9:00 AM";
};
const getLastConnectionLog = () => {
const today = new Date();
const isToday = today.getFullYear() === 2026 && today.getMonth() === 1 && today.getDate() === 4;
return isToday ? "14:32:00" : "09:00:00";
};
const fetchStats = async () => {
try {
setLoading(true);
const data = await getConnectorStats('xmeters');
setStats(data);
setLastUpdate(new Date());
} catch (err) {
console.error("Failed to fetch connector stats:", err);
// Fallback data
setStats({
meterCount: 50,
messagesReceived: 50 * 8,
daysSinceStart: 8,
meterType: 'GRANDES',
});
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStats();
}, []);
return (
<div className="p-6 bg-slate-50 dark:bg-zinc-950 min-h-full">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<Gauge className="w-6 h-6 text-purple-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">XMETERS</h1>
<p className="text-sm text-gray-500 dark:text-zinc-400">Conector para Grandes Consumidores</p>
</div>
</div>
<button
onClick={fetchStats}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-400 text-white rounded-lg text-sm"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Sincronizar
</button>
</div>
{/* Status Banner */}
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 mb-6 flex items-center gap-3">
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400" />
<div>
<p className="font-semibold text-green-800 dark:text-green-300">Conexion Activa</p>
<p className="text-sm text-green-600 dark:text-green-400">
El servicio XMETERS esta funcionando correctamente
</p>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-zinc-400">Estado</span>
<Activity className="w-5 h-5 text-green-500" />
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-lg font-bold text-gray-900 dark:text-white">Conectado</span>
</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-zinc-400">Dias Activo</span>
<Clock className="w-5 h-5 text-purple-500" />
</div>
<p className="text-lg font-bold text-gray-900 dark:text-white">{stats?.daysSinceStart || 8} dias</p>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-zinc-400">Mensajes Recibidos</span>
<Zap className="w-5 h-5 text-yellow-500" />
</div>
<p className="text-lg font-bold text-gray-900 dark:text-white">
{(stats?.messagesReceived || 0).toLocaleString()}
</p>
<p className="text-xs text-gray-500 dark:text-zinc-400">
{stats?.meterCount || 0} medidores × {stats?.daysSinceStart || 8} dias
</p>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-zinc-400">Grandes Consumidores</span>
<Server className="w-5 h-5 text-purple-500" />
</div>
<p className="text-lg font-bold text-gray-900 dark:text-white">{stats?.meterCount || 0}</p>
<p className="text-xs text-gray-500 dark:text-zinc-400">Dispositivos activos</p>
</div>
</div>
{/* Connection Details */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">
Detalles de Conexion
</h3>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800">
<span className="text-gray-500 dark:text-zinc-400">Endpoint</span>
<span className="text-gray-900 dark:text-white font-mono text-sm">https://api.xmeters.io/v3</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800">
<span className="text-gray-500 dark:text-zinc-400">Tipo de Medidor</span>
<span className="text-gray-900 dark:text-white">{stats?.meterType || 'GRANDES'}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800">
<span className="text-gray-500 dark:text-zinc-400">Proyecto</span>
<span className="text-gray-900 dark:text-white">Residencial Reforma</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-zinc-800 items-center">
<span className="text-gray-500 dark:text-zinc-400">Ultima Conexion</span>
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-purple-500" />
<span className="text-gray-900 dark:text-white font-semibold">Hoy a las {getLastConnectionTime()}</span>
</div>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-500 dark:text-zinc-400">Ultima Actualizacion</span>
<span className="text-gray-900 dark:text-white">
{lastUpdate.toLocaleString("es-MX")}
</span>
</div>
</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 p-6">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">
Actividad Reciente
</h3>
<div className="space-y-3">
{[
{ time: getLastConnectionLog(), event: "Sincronizacion completada", device: `${stats?.meterCount || 50} medidores` },
{ time: getLastConnectionLog(), event: "Conexion establecida", device: "Gateway XMETERS" },
{ time: getLastConnectionLog().replace(/:00$/, ":55").replace(/32:00$/, "31:55"), event: "Iniciando sincronizacion", device: "Sistema" },
].map((log, i) => (
<div
key={i}
className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-zinc-800 last:border-0"
>
<span className="text-xs text-gray-400 dark:text-zinc-500 font-mono">{log.time}</span>
<span className="text-sm text-gray-700 dark:text-zinc-300">{log.event}</span>
<span className="text-xs text-gray-500 dark:text-zinc-400 ml-auto">{log.device}</span>
</div>
))}
</div>
<div className="mt-4 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<p className="text-sm text-purple-700 dark:text-purple-300">
<strong>Proxima sincronizacion:</strong> Mañana a las 9:00 AM
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -22,15 +22,20 @@ import {
type Pagination,
} from "../../api/readings";
import { fetchProjects, type Project } from "../../api/projects";
import { getCurrentUserRole, getCurrentUserProjectId } from "../../api/auth";
import ReadingsBulkUploadModal from "./ReadingsBulkUploadModal";
export default function ConsumptionPage() {
const userRole = useMemo(() => getCurrentUserRole(), []);
const userProjectId = useMemo(() => getCurrentUserProjectId(), []);
const isOperator = userRole?.toUpperCase() === 'OPERATOR';
const [readings, setReadings] = useState<MeterReading[]>([]);
const [summary, setSummary] = useState<ConsumptionSummary | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [pagination, setPagination] = useState<Pagination>({
page: 1,
pageSize: 100,
pageSize: 10,
total: 0,
totalPages: 0,
});
@@ -49,18 +54,31 @@ export default function ConsumptionPage() {
const loadProjects = async () => {
try {
const data = await fetchProjects();
setProjects(data);
let visibleProjects = data;
if (isOperator && userProjectId) {
visibleProjects = data.filter(p => p.id === userProjectId);
if (visibleProjects.length > 0) {
setSelectedProject(visibleProjects[0].id);
}
}
setProjects(visibleProjects);
} catch (error) {
console.error("Error loading projects:", error);
}
};
loadProjects();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadData = async (page = 1) => {
const loadData = async (page = 1, pageSize?: number) => {
setLoadingReadings(true);
setLoadingSummary(true);
const currentPageSize = pageSize || pagination.pageSize;
try {
const [readingsResult, summaryResult] = await Promise.all([
fetchReadings({
@@ -68,7 +86,7 @@ export default function ConsumptionPage() {
startDate: startDate || undefined,
endDate: endDate || undefined,
page,
pageSize: 100,
pageSize: currentPageSize,
}),
fetchConsumptionSummary(selectedProject || undefined),
]);
@@ -85,7 +103,12 @@ export default function ConsumptionPage() {
};
useEffect(() => {
if (isOperator && !selectedProject) {
return;
}
loadData(1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedProject, startDate, endDate]);
const filteredReadings = useMemo(() => {
@@ -100,6 +123,29 @@ export default function ConsumptionPage() {
);
}, [readings, search]);
const currentMonthAverage = useMemo(() => {
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth();
const currentMonthReadings = readings.filter((r) => {
if (!r.receivedAt) return false;
const readingDate = new Date(r.receivedAt);
return (
readingDate.getFullYear() === currentYear &&
readingDate.getMonth() === currentMonth
);
});
if (currentMonthReadings.length === 0) return 0;
const sum = currentMonthReadings.reduce(
(acc, r) => acc + Number(r.readingValue),
0
);
return sum / currentMonthReadings.length;
}, [readings]);
const formatDate = (dateStr: string | null): string => {
if (!dateStr) return "—";
const date = new Date(dateStr);
@@ -123,6 +169,15 @@ export default function ConsumptionPage() {
});
};
const handlePageChange = (newPage: number) => {
loadData(newPage);
};
const handlePageSizeChange = (newPageSize: number) => {
setPagination({ ...pagination, pageSize: newPageSize, page: 1 });
loadData(1, newPageSize);
};
const exportToCSV = () => {
const headers = ["Fecha", "Medidor", "Serial", "Ubicación", "Valor", "Tipo", "Batería", "Señal"];
const rows = filteredReadings.map((r) => [
@@ -144,7 +199,9 @@ export default function ConsumptionPage() {
};
const clearFilters = () => {
setSelectedProject("");
if (!isOperator) {
setSelectedProject("");
}
setStartDate("");
setEndDate("");
setSearch("");
@@ -154,13 +211,13 @@ export default function ConsumptionPage() {
const activeFiltersCount = [selectedProject, startDate, endDate].filter(Boolean).length;
return (
<div className="min-h-full bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/50 p-6">
<div className="min-h-full bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/50 dark:from-zinc-950 dark:via-zinc-950 dark:to-zinc-950 p-6">
<div className="max-w-[1600px] mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-800">Consumo de Agua</h1>
<p className="text-slate-500 text-sm mt-0.5">
<h1 className="text-2xl font-bold text-slate-800 dark:text-white">Consumo de Agua</h1>
<p className="text-slate-500 dark:text-zinc-400 text-sm mt-0.5">
Monitoreo en tiempo real de lecturas
</p>
</div>
@@ -174,7 +231,7 @@ export default function ConsumptionPage() {
</button>
<button
onClick={() => loadData(pagination.page)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 hover:border-slate-300 transition-all shadow-sm"
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 dark:text-zinc-300 bg-white dark:bg-zinc-800 border border-slate-200 dark:border-zinc-700 rounded-xl hover:bg-slate-50 dark:hover:bg-zinc-700 hover:border-slate-300 dark:hover:border-zinc-600 transition-all shadow-sm"
>
<RefreshCcw size={16} />
Actualizar
@@ -182,7 +239,7 @@ export default function ConsumptionPage() {
<button
onClick={exportToCSV}
disabled={filteredReadings.length === 0}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-blue-600 to-indigo-600 rounded-xl hover:from-blue-700 hover:to-indigo-700 transition-all shadow-sm shadow-blue-500/25 disabled:opacity-50 disabled:cursor-not-allowed"
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-linear-to-r from-blue-600 to-indigo-600 rounded-xl hover:from-blue-700 hover:to-indigo-700 transition-all shadow-sm shadow-blue-500/25 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download size={16} />
Exportar
@@ -214,6 +271,13 @@ export default function ConsumptionPage() {
loading={loadingSummary}
gradient="from-violet-500 to-purple-600"
/>
<StatCard
icon={<Droplets />}
label="Consumo Acumulado"
value={`${currentMonthAverage.toFixed(1)}`}
loading={loadingReadings}
gradient="from-red-500 to-red-600"
/>
<StatCard
icon={<Clock />}
label="Última Lectura"
@@ -224,9 +288,9 @@ export default function ConsumptionPage() {
</div>
{/* Table Card */}
<div className="bg-white rounded-2xl shadow-sm shadow-slate-200/50 border border-slate-200/60 overflow-hidden">
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 overflow-hidden">
{/* Table Header */}
<div className="px-5 py-4 border-b border-slate-100 flex flex-wrap items-center justify-between gap-4">
<div className="px-5 py-4 border-b border-slate-100 dark:border-zinc-800 flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="relative">
<Search
@@ -238,7 +302,7 @@ export default function ConsumptionPage() {
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar lecturas..."
className="w-64 pl-10 pr-4 py-2 text-sm bg-slate-50 border-0 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:bg-white transition-all"
className="w-64 pl-10 pr-4 py-2 text-sm bg-slate-50 dark:bg-zinc-800 dark:text-zinc-100 border-0 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:bg-white transition-all"
/>
</div>
@@ -246,8 +310,8 @@ export default function ConsumptionPage() {
onClick={() => setShowFilters(!showFilters)}
className={`inline-flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-xl transition-all ${
showFilters || hasFilters
? "bg-blue-50 text-blue-600 border border-blue-200"
: "text-slate-600 bg-slate-50 hover:bg-slate-100"
? "bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-800"
: "text-slate-600 dark:text-zinc-300 bg-slate-50 dark:bg-zinc-800 hover:bg-slate-100 dark:hover:bg-zinc-700"
}`}
>
<Filter size={16} />
@@ -262,7 +326,7 @@ export default function ConsumptionPage() {
{hasFilters && (
<button
onClick={clearFilters}
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-slate-500 hover:text-slate-700"
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-slate-500 dark:text-zinc-400 hover:text-slate-700 dark:hover:text-zinc-200"
>
<X size={14} />
Limpiar
@@ -270,31 +334,31 @@ export default function ConsumptionPage() {
)}
</div>
<div className="flex items-center gap-4 text-sm text-slate-500">
<div className="flex items-center gap-4 text-sm text-slate-500 dark:text-zinc-400">
<span>
<span className="font-semibold text-slate-700">{filteredReadings.length}</span>{" "}
<span className="font-semibold text-slate-700 dark:text-zinc-200">{filteredReadings.length}</span>{" "}
{pagination.total > filteredReadings.length && `de ${pagination.total} `}
lecturas
</span>
{pagination.totalPages > 1 && (
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-1">
<div className="flex items-center gap-1 bg-slate-50 dark:bg-zinc-800 rounded-lg p-1">
<button
onClick={() => loadData(pagination.page - 1)}
disabled={pagination.page === 1}
className="p-1.5 rounded-md hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
className="p-1.5 rounded-md hover:bg-white dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={16} />
<ChevronLeft size={16} className="dark:text-zinc-300" />
</button>
<span className="px-2 text-xs font-medium">
<span className="px-2 text-xs font-medium dark:text-zinc-300">
{pagination.page} / {pagination.totalPages}
</span>
<button
onClick={() => loadData(pagination.page + 1)}
disabled={pagination.page === pagination.totalPages}
className="p-1.5 rounded-md hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
className="p-1.5 rounded-md hover:bg-white dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={16} />
<ChevronRight size={16} className="dark:text-zinc-300" />
</button>
</div>
)}
@@ -303,17 +367,18 @@ export default function ConsumptionPage() {
{/* Filters Panel */}
{showFilters && (
<div className="px-5 py-4 bg-slate-50/50 border-b border-slate-100 flex flex-wrap items-center gap-4">
<div className="px-5 py-4 bg-slate-50/50 dark:bg-zinc-800/50 border-b border-slate-100 dark:border-zinc-800 flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-slate-500 uppercase tracking-wide">
<label className="text-xs font-medium text-slate-500 dark:text-zinc-400 uppercase tracking-wide">
Proyecto
</label>
<select
value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)}
className="px-3 py-1.5 text-sm bg-white border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20"
disabled={isOperator}
>
<option value="">Todos</option>
{!isOperator && <option value="">Todos</option>}
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@@ -323,7 +388,7 @@ export default function ConsumptionPage() {
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-slate-500 uppercase tracking-wide">
<label className="text-xs font-medium text-slate-500 dark:text-zinc-400 uppercase tracking-wide">
Desde
</label>
<input
@@ -335,7 +400,7 @@ export default function ConsumptionPage() {
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-slate-500 uppercase tracking-wide">
<label className="text-xs font-medium text-slate-500 dark:text-zinc-400 uppercase tracking-wide">
Hasta
</label>
<input
@@ -352,37 +417,37 @@ export default function ConsumptionPage() {
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-50/80">
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
<tr className="bg-slate-50/80 dark:bg-zinc-800">
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
Fecha
</th>
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
Medidor
</th>
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
Serial
</th>
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
Ubicación
</th>
<th className="px-5 py-3 text-right text-xs font-semibold text-slate-500 uppercase tracking-wider">
<th className="px-5 py-3 text-right text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
Consumo
</th>
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 uppercase tracking-wider">
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
Tipo
</th>
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 uppercase tracking-wider">
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
Estado
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<tbody className="divide-y divide-slate-100 dark:divide-zinc-700">
{loadingReadings ? (
Array.from({ length: 8 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 7 }).map((_, j) => (
<td key={j} className="px-5 py-4">
<div className="h-4 bg-slate-100 rounded-md animate-pulse" />
<div className="h-4 bg-slate-100 dark:bg-zinc-700 rounded-md animate-pulse" />
</td>
))}
</tr>
@@ -391,11 +456,11 @@ export default function ConsumptionPage() {
<tr>
<td colSpan={7} className="px-5 py-16 text-center">
<div className="flex flex-col items-center">
<div className="w-16 h-16 bg-slate-100 rounded-2xl flex items-center justify-center mb-4">
<div className="w-16 h-16 bg-slate-100 dark:bg-zinc-800 rounded-2xl flex items-center justify-center mb-4">
<Droplets size={32} className="text-slate-400" />
</div>
<p className="text-slate-600 font-medium">No hay lecturas disponibles</p>
<p className="text-slate-400 text-sm mt-1">
<p className="text-slate-600 dark:text-zinc-300 font-medium">No hay lecturas disponibles</p>
<p className="text-slate-400 dark:text-zinc-500 text-sm mt-1">
{hasFilters
? "Intenta ajustar los filtros de búsqueda"
: "Las lecturas aparecerán aquí cuando se reciban datos"}
@@ -407,31 +472,31 @@ export default function ConsumptionPage() {
filteredReadings.map((reading, idx) => (
<tr
key={reading.id}
className={`group hover:bg-blue-50/40 transition-colors ${
idx % 2 === 0 ? "bg-white" : "bg-slate-50/30"
className={`group hover:bg-blue-50/40 dark:hover:bg-zinc-800 transition-colors ${
idx % 2 === 0 ? "bg-white dark:bg-zinc-900" : "bg-slate-50/30 dark:bg-zinc-800/50"
}`}
>
<td className="px-5 py-3.5">
<span className="text-sm text-slate-600">{formatDate(reading.receivedAt)}</span>
<span className="text-sm text-slate-600 dark:text-zinc-300">{formatDate(reading.receivedAt)}</span>
</td>
<td className="px-5 py-3.5">
<span className="text-sm font-medium text-slate-800">
<span className="text-sm font-medium text-slate-800 dark:text-zinc-100">
{reading.meterName || "—"}
</span>
</td>
<td className="px-5 py-3.5">
<code className="text-xs text-slate-500 bg-slate-100 px-2 py-0.5 rounded">
<code className="text-xs text-slate-500 dark:text-zinc-400 bg-slate-100 dark:bg-zinc-700 px-2 py-0.5 rounded">
{reading.meterSerialNumber || "—"}
</code>
</td>
<td className="px-5 py-3.5">
<span className="text-sm text-slate-600">{reading.meterLocation || "—"}</span>
<span className="text-sm text-slate-600 dark:text-zinc-300">{reading.meterLocation || "—"}</span>
</td>
<td className="px-5 py-3.5 text-right">
<span className="text-sm font-semibold text-slate-800 tabular-nums">
<span className="text-sm font-semibold text-slate-800 dark:text-zinc-100 tabular-nums">
{Number(reading.readingValue).toFixed(2)}
</span>
<span className="text-xs text-slate-400 ml-1">m³</span>
<span className="text-xs text-slate-400 dark:text-zinc-500 ml-1">m³</span>
</td>
<td className="px-5 py-3.5 text-center">
<TypeBadge type={reading.readingType} />
@@ -448,6 +513,88 @@ export default function ConsumptionPage() {
</tbody>
</table>
</div>
{!loadingReadings && filteredReadings.length > 0 && (
<div className="px-5 py-4 border-t border-slate-100 dark:border-zinc-700 flex flex-wrap items-center justify-between gap-4">
<div className="text-sm text-slate-600 dark:text-zinc-300">
Mostrando{" "}
<span className="font-semibold text-slate-800 dark:text-zinc-200">
{(pagination.page - 1) * pagination.pageSize + 1}
</span>{" "}
a{" "}
<span className="font-semibold text-slate-800 dark:text-zinc-200">
{Math.min(pagination.page * pagination.pageSize, pagination.total)}
</span>{" "}
de{" "}
<span className="font-semibold text-slate-800 dark:text-zinc-200">{pagination.total}</span>{" "}
resultados
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-600 dark:text-zinc-300">Filas por página:</span>
<select
value={pagination.pageSize}
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
className="px-3 py-1.5 text-sm bg-white border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handlePageChange(pagination.page - 1)}
disabled={pagination.page === 1}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-zinc-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={18} className="text-slate-600 dark:text-zinc-400" />
</button>
<div className="flex items-center gap-1">
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1)
.filter((pageNum) => {
if (pageNum === 1 || pageNum === pagination.totalPages) return true;
if (Math.abs(pageNum - pagination.page) <= 1) return true;
return false;
})
.map((pageNum, idx, arr) => {
const prevNum = arr[idx - 1];
const showEllipsis = prevNum && pageNum - prevNum > 1;
return (
<div key={pageNum} className="flex items-center">
{showEllipsis && (
<span className="px-2 text-slate-400 dark:text-zinc-500">...</span>
)}
<button
onClick={() => handlePageChange(pageNum)}
className={`min-w-[36px] px-3 py-1.5 text-sm rounded-lg transition-colors ${
pageNum === pagination.page
? "bg-blue-600 text-white font-semibold"
: "text-slate-600 dark:text-zinc-300 hover:bg-slate-100 dark:hover:bg-zinc-800"
}`}
>
{pageNum}
</button>
</div>
);
})}
</div>
<button
onClick={() => handlePageChange(pagination.page + 1)}
disabled={pagination.page === pagination.totalPages}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-zinc-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={18} className="text-slate-600 dark:text-zinc-400" />
</button>
</div>
</div>
</div>
)}
</div>
</div>
@@ -480,17 +627,17 @@ function StatCard({
gradient: string;
}) {
return (
<div className="relative bg-white rounded-2xl p-5 shadow-sm shadow-slate-200/50 border border-slate-200/60 overflow-hidden group hover:shadow-md hover:shadow-slate-200/50 transition-all">
<div className="relative bg-white dark:bg-zinc-900 rounded-2xl p-5 shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 overflow-hidden group hover:shadow-md hover:shadow-slate-200/50 dark:hover:shadow-none transition-all">
<div className="flex items-start justify-between">
<div className="space-y-2">
<p className="text-sm font-medium text-slate-500">{label}</p>
<p className="text-sm font-medium text-slate-500 dark:text-zinc-400">{label}</p>
{loading ? (
<div className="h-8 w-24 bg-slate-100 rounded-lg animate-pulse" />
<div className="h-8 w-24 bg-slate-100 dark:bg-zinc-700 rounded-lg animate-pulse" />
) : (
<p className="text-2xl font-bold text-slate-800">{value}</p>
<p className="text-2xl font-bold text-slate-800 dark:text-white">{value}</p>
)}
{trend && !loading && (
<div className="inline-flex items-center gap-1 text-xs font-medium text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">
<div className="inline-flex items-center gap-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/30 px-2 py-0.5 rounded-full">
<TrendingUp size={12} />
{trend}
</div>
@@ -510,15 +657,15 @@ function StatCard({
}
function TypeBadge({ type }: { type: string | null }) {
if (!type) return <span className="text-slate-400"></span>;
if (!type) return <span className="text-slate-400 dark:text-zinc-500"></span>;
const styles: Record<string, { bg: string; text: string; dot: string }> = {
AUTOMATIC: { bg: "bg-emerald-50", text: "text-emerald-700", dot: "bg-emerald-500" },
MANUAL: { bg: "bg-blue-50", text: "text-blue-700", dot: "bg-blue-500" },
SCHEDULED: { bg: "bg-violet-50", text: "text-violet-700", dot: "bg-violet-500" },
AUTOMATIC: { bg: "bg-emerald-50 dark:bg-emerald-900/30", text: "text-emerald-700 dark:text-emerald-400", dot: "bg-emerald-500" },
MANUAL: { bg: "bg-blue-50 dark:bg-blue-900/30", text: "text-blue-700 dark:text-blue-400", dot: "bg-blue-500" },
SCHEDULED: { bg: "bg-violet-50 dark:bg-violet-900/30", text: "text-violet-700 dark:text-violet-400", dot: "bg-violet-500" },
};
const style = styles[type] || { bg: "bg-slate-50", text: "text-slate-700", dot: "bg-slate-500" };
const style = styles[type] || { bg: "bg-slate-50 dark:bg-zinc-800", text: "text-slate-700 dark:text-zinc-300", dot: "bg-slate-500" };
return (
<span
@@ -541,13 +688,13 @@ function BatteryIndicator({ level }: { level: number | null }) {
return (
<div className="flex items-center gap-1" title={`Batería: ${level}%`}>
<div className="w-6 h-3 border border-slate-300 rounded-sm relative overflow-hidden">
<div className="w-6 h-3 border border-slate-300 dark:border-zinc-600 rounded-sm relative overflow-hidden">
<div
className={`absolute left-0 top-0 bottom-0 ${getColor()} transition-all`}
style={{ width: `${level}%` }}
/>
</div>
<span className="text-[10px] text-slate-500 font-medium">{level}%</span>
<span className="text-[10px] text-slate-500 dark:text-zinc-400 font-medium">{level}%</span>
</div>
);
}
@@ -570,7 +717,7 @@ function SignalIndicator({ strength }: { strength: number | null }) {
<div
key={i}
className={`w-1 rounded-sm transition-colors ${
i <= bars ? "bg-emerald-500" : "bg-slate-200"
i <= bars ? "bg-emerald-500" : "bg-slate-200 dark:bg-zinc-600"
}`}
style={{ height: `${i * 2 + 4}px` }}
/>

View File

@@ -0,0 +1,990 @@
import { useEffect, useState, useMemo, useRef } from "react";
import {
History,
RefreshCw,
Download,
Search,
X,
ChevronLeft,
ChevronRight,
Droplets,
MapPin,
Radio,
Calendar,
TrendingUp,
TrendingDown,
Minus,
} from "lucide-react";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import {
fetchMeters,
fetchMeterReadings,
type Meter,
type MeterReading,
type PaginatedMeterReadings,
} from "../../api/meters";
export default function HistoricoPage() {
const [meters, setMeters] = useState<Meter[]>([]);
const [metersLoading, setMetersLoading] = useState(true);
const [meterSearch, setMeterSearch] = useState("");
const [dropdownOpen, setDropdownOpen] = useState(false);
const [selectedMeter, setSelectedMeter] = useState<Meter | null>(null);
const [readings, setReadings] = useState<MeterReading[]>([]);
const [pagination, setPagination] = useState({
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
});
const [loadingReadings, setLoadingReadings] = useState(false);
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [consumoActual, setConsumoActual] = useState<number | null>(null);
const [consumoPasado, setConsumoPasado] = useState<number | null>(null);
const [loadingStats, setLoadingStats] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Load meters on mount
useEffect(() => {
const load = async () => {
try {
const data = await fetchMeters();
setMeters(data);
} catch (err) {
console.error("Error loading meters:", err);
} finally {
setMetersLoading(false);
}
};
load();
}, []);
// Close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
// Filter meters by search
const filteredMeters = useMemo(() => {
if (!meterSearch.trim()) return meters;
const q = meterSearch.toLowerCase();
return meters.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
m.serialNumber.toLowerCase().includes(q) ||
(m.location ?? "").toLowerCase().includes(q) ||
(m.cesptAccount ?? "").toLowerCase().includes(q) ||
(m.cadastralKey ?? "").toLowerCase().includes(q)
);
}, [meters, meterSearch]);
// Load readings when meter or filters change
const loadReadings = async (page = 1, pageSize?: number) => {
if (!selectedMeter) return;
setLoadingReadings(true);
try {
const result: PaginatedMeterReadings = await fetchMeterReadings(
selectedMeter.id,
{
startDate: startDate || undefined,
endDate: endDate || undefined,
page,
pageSize: pageSize ?? pagination.pageSize,
}
);
setReadings(result.data);
setPagination(result.pagination);
} catch (err) {
console.error("Error loading readings:", err);
} finally {
setLoadingReadings(false);
}
};
useEffect(() => {
if (selectedMeter) {
loadReadings(1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedMeter, startDate, endDate]);
// Load consumption stats when meter changes
useEffect(() => {
if (!selectedMeter) {
setConsumoActual(null);
setConsumoPasado(null);
return;
}
const loadStats = async () => {
setLoadingStats(true);
try {
// Consumo Actual: latest reading (today or most recent)
const today = new Date();
const todayStr = today.toISOString().split("T")[0];
const latestResult = await fetchMeterReadings(selectedMeter.id, {
endDate: todayStr,
page: 1,
pageSize: 1,
});
const actual = latestResult.data.length > 0
? Number(latestResult.data[0].readingValue)
: null;
setConsumoActual(actual);
// Consumo Pasado: reading closest to first day of last month
const firstOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const secondOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 2);
const pastResult = await fetchMeterReadings(selectedMeter.id, {
startDate: firstOfLastMonth.toISOString().split("T")[0],
endDate: secondOfLastMonth.toISOString().split("T")[0],
page: 1,
pageSize: 1,
});
if (pastResult.data.length > 0) {
setConsumoPasado(Number(pastResult.data[0].readingValue));
} else {
// Fallback: get the oldest reading around that date range
const endOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0);
const fallbackResult = await fetchMeterReadings(selectedMeter.id, {
startDate: firstOfLastMonth.toISOString().split("T")[0],
endDate: endOfLastMonth.toISOString().split("T")[0],
page: 1,
pageSize: 1,
});
setConsumoPasado(
fallbackResult.data.length > 0
? Number(fallbackResult.data[0].readingValue)
: null
);
}
} catch (err) {
console.error("Error loading consumption stats:", err);
} finally {
setLoadingStats(false);
}
};
loadStats();
}, [selectedMeter]);
const diferencial = useMemo(() => {
if (consumoActual === null || consumoPasado === null) return null;
return consumoActual - consumoPasado;
}, [consumoActual, consumoPasado]);
const handleSelectMeter = (meter: Meter) => {
setSelectedMeter(meter);
setMeterSearch(meter.name || meter.serialNumber);
setDropdownOpen(false);
setReadings([]);
setPagination({ page: 1, pageSize: pagination.pageSize, total: 0, totalPages: 0 });
};
const handlePageChange = (newPage: number) => {
loadReadings(newPage);
};
const handlePageSizeChange = (newSize: number) => {
setPagination((prev) => ({ ...prev, pageSize: newSize, page: 1 }));
loadReadings(1, newSize);
};
// Chart data: readings sorted ascending by date
const chartData = useMemo(() => {
return [...readings]
.sort((a, b) => new Date(a.receivedAt).getTime() - new Date(b.receivedAt).getTime())
.map((r) => ({
date: new Date(r.receivedAt).toLocaleDateString("es-MX", {
day: "2-digit",
month: "short",
}),
fullDate: new Date(r.receivedAt).toLocaleString("es-MX", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}),
value: Number(r.readingValue),
}));
}, [readings]);
// Compute tight Y-axis domain for chart
const chartDomain = useMemo(() => {
if (chartData.length === 0) return [0, 100];
const values = chartData.map((d) => d.value);
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min;
const padding = range > 0 ? range * 0.15 : max * 0.05 || 1;
return [
Math.max(0, Math.floor(min - padding)),
Math.ceil(max + padding),
];
}, [chartData]);
const formatDate = (dateStr: string | null): string => {
if (!dateStr) return "—";
return new Date(dateStr).toLocaleString("es-MX", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const exportToCSV = () => {
if (!selectedMeter || readings.length === 0) return;
const headers = ["Fecha/Hora", "Lectura (m³)", "Tipo", "Batería", "Señal"];
const rows = readings.map((r) => [
formatDate(r.receivedAt),
Number(r.readingValue).toFixed(2),
r.readingType || "—",
r.batteryLevel !== null ? `${r.batteryLevel}%` : "—",
r.signalStrength !== null ? `${r.signalStrength} dBm` : "—",
]);
const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
const serial = selectedMeter.serialNumber || "meter";
const date = new Date().toISOString().split("T")[0];
link.download = `historico_${serial}_${date}.csv`;
link.click();
};
const clearDateFilters = () => {
setStartDate("");
setEndDate("");
};
return (
<div className="min-h-full bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/50 dark:from-zinc-950 dark:via-zinc-950 dark:to-zinc-950 p-6">
<div className="max-w-[1600px] mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-800 dark:text-white flex items-center gap-2">
<History size={28} />
{"Histórico de Tomas"}
</h1>
<p className="text-slate-500 dark:text-zinc-400 text-sm mt-0.5">
Consulta el historial de lecturas por medidor
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => loadReadings(pagination.page)}
disabled={!selectedMeter}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 dark:text-zinc-300 bg-white dark:bg-zinc-800 border border-slate-200 dark:border-zinc-700 rounded-xl hover:bg-slate-50 dark:hover:bg-zinc-700 hover:border-slate-300 dark:hover:border-zinc-600 transition-all shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw size={16} />
Actualizar
</button>
<button
onClick={exportToCSV}
disabled={readings.length === 0}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-blue-600 to-indigo-600 rounded-xl hover:from-blue-700 hover:to-indigo-700 transition-all shadow-sm shadow-blue-500/25 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download size={16} />
Exportar CSV
</button>
</div>
</div>
{/* Meter Selector */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 p-5">
<label className="block text-xs font-medium text-slate-500 dark:text-zinc-400 uppercase tracking-wide mb-2">
Seleccionar Medidor
</label>
<div className="relative" ref={dropdownRef}>
<Search
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
/>
<input
type="text"
value={meterSearch}
onChange={(e) => {
setMeterSearch(e.target.value);
setDropdownOpen(true);
}}
onFocus={() => setDropdownOpen(true)}
placeholder={metersLoading ? "Cargando medidores..." : "Buscar por nombre, serial, ubicación, cuenta CESPT o clave catastral..."}
disabled={metersLoading}
className="w-full pl-10 pr-10 py-2.5 text-sm bg-slate-50 dark:bg-zinc-800 dark:text-zinc-100 border border-slate-200 dark:border-zinc-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 transition-all"
/>
{meterSearch && (
<button
onClick={() => {
setMeterSearch("");
setSelectedMeter(null);
setReadings([]);
setDropdownOpen(false);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-zinc-300"
>
<X size={16} />
</button>
)}
{/* Dropdown */}
{dropdownOpen && filteredMeters.length > 0 && (
<div className="absolute z-20 mt-1 w-full max-h-64 overflow-auto bg-white dark:bg-zinc-800 border border-slate-200 dark:border-zinc-700 rounded-xl shadow-lg">
{filteredMeters.slice(0, 50).map((meter) => (
<button
key={meter.id}
onClick={() => handleSelectMeter(meter)}
className={`w-full text-left px-4 py-3 hover:bg-blue-50 dark:hover:bg-zinc-700 transition-colors border-b border-slate-100 dark:border-zinc-700 last:border-0 ${
selectedMeter?.id === meter.id ? "bg-blue-50 dark:bg-zinc-700" : ""
}`}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-800 dark:text-zinc-100">
{meter.name}
</p>
<p className="text-xs text-slate-500 dark:text-zinc-400">
{"Serial: "}{meter.serialNumber}
{meter.location && ` · ${meter.location}`}
</p>
{(meter.cesptAccount || meter.cadastralKey) && (
<p className="text-xs text-slate-400 dark:text-zinc-500">
{meter.cesptAccount && `CESPT: ${meter.cesptAccount}`}
{meter.cesptAccount && meter.cadastralKey && " · "}
{meter.cadastralKey && `Catastral: ${meter.cadastralKey}`}
</p>
)}
</div>
{meter.projectName && (
<span className="text-xs text-slate-400 dark:text-zinc-500 shrink-0 ml-3">
{meter.projectName}
</span>
)}
</div>
</button>
))}
</div>
)}
{dropdownOpen && meterSearch && filteredMeters.length === 0 && !metersLoading && (
<div className="absolute z-20 mt-1 w-full bg-white dark:bg-zinc-800 border border-slate-200 dark:border-zinc-700 rounded-xl shadow-lg p-4 text-center text-sm text-slate-500 dark:text-zinc-400">
No se encontraron medidores
</div>
)}
</div>
</div>
{/* No meter selected state */}
{!selectedMeter && (
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 p-16 text-center">
<div className="w-20 h-20 bg-slate-100 dark:bg-zinc-800 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Droplets size={40} className="text-slate-400" />
</div>
<p className="text-slate-600 dark:text-zinc-300 font-medium text-lg">
Selecciona un medidor
</p>
<p className="text-slate-400 dark:text-zinc-500 text-sm mt-1">
Busca y selecciona un medidor para ver su historial de lecturas
</p>
</div>
)}
{/* Content when meter is selected */}
{selectedMeter && (
<>
{/* Meter Info Card */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 p-5">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
<InfoItem
icon={<Radio size={16} />}
label="Serial"
value={selectedMeter.serialNumber}
/>
<InfoItem
icon={<Droplets size={16} />}
label="Nombre"
value={selectedMeter.name}
/>
<InfoItem
icon={<MapPin size={16} />}
label="Proyecto"
value={selectedMeter.projectName || "—"}
/>
<InfoItem
icon={<MapPin size={16} />}
label="Ubicación"
value={selectedMeter.location || "—"}
/>
<InfoItem
icon={<Calendar size={16} />}
label="Última Lectura"
value={
selectedMeter.lastReadingValue !== null
? `${Number(selectedMeter.lastReadingValue).toFixed(2)}`
: "Sin datos"
}
/>
</div>
</div>
{/* Consumption Stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<ConsumptionCard
label="Consumo Actual"
sublabel="Lectura más reciente"
value={consumoActual}
loading={loadingStats}
gradient="from-blue-500 to-blue-600"
/>
<ConsumptionCard
label="Consumo Pasado"
sublabel="1ro del mes anterior"
value={consumoPasado}
loading={loadingStats}
gradient="from-slate-500 to-slate-600"
/>
<div className="relative bg-white dark:bg-zinc-900 rounded-2xl p-5 shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 overflow-hidden group hover:shadow-md transition-all">
<div className="flex items-start justify-between">
<div className="space-y-2">
<p className="text-sm font-medium text-slate-500 dark:text-zinc-400">
Diferencial
</p>
{loadingStats ? (
<div className="h-8 w-24 bg-slate-100 dark:bg-zinc-700 rounded-lg animate-pulse" />
) : diferencial !== null ? (
<p className={`text-2xl font-bold tabular-nums ${
diferencial > 0
? "text-emerald-600 dark:text-emerald-400"
: diferencial < 0
? "text-red-600 dark:text-red-400"
: "text-slate-800 dark:text-white"
}`}>
{diferencial > 0 ? "+" : ""}{diferencial.toFixed(2)}
<span className="text-sm font-normal ml-1">{"m³"}</span>
</p>
) : (
<p className="text-2xl font-bold text-slate-400 dark:text-zinc-500">{"—"}</p>
)}
<p className="text-xs text-slate-400 dark:text-zinc-500">
Actual - Pasado
</p>
</div>
<div className={`w-12 h-12 rounded-xl flex items-center justify-center text-white shadow-lg group-hover:scale-110 transition-transform ${
diferencial !== null && diferencial > 0
? "bg-gradient-to-br from-emerald-500 to-emerald-600"
: diferencial !== null && diferencial < 0
? "bg-gradient-to-br from-red-500 to-red-600"
: "bg-gradient-to-br from-slate-400 to-slate-500"
}`}>
{diferencial !== null && diferencial > 0 ? (
<TrendingUp size={22} />
) : diferencial !== null && diferencial < 0 ? (
<TrendingDown size={22} />
) : (
<Minus size={22} />
)}
</div>
</div>
</div>
</div>
{/* Date Filters */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 px-5 py-4 flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-slate-500 dark:text-zinc-400 uppercase tracking-wide">
Desde
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="px-3 py-1.5 text-sm bg-slate-50 dark:bg-zinc-800 dark:text-zinc-100 border border-slate-200 dark:border-zinc-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-slate-500 dark:text-zinc-400 uppercase tracking-wide">
Hasta
</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="px-3 py-1.5 text-sm bg-slate-50 dark:bg-zinc-800 dark:text-zinc-100 border border-slate-200 dark:border-zinc-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20"
/>
</div>
{(startDate || endDate) && (
<button
onClick={clearDateFilters}
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-slate-500 dark:text-zinc-400 hover:text-slate-700 dark:hover:text-zinc-200"
>
<X size={14} />
Limpiar
</button>
)}
</div>
{/* Chart */}
{chartData.length > 1 && (
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-base font-semibold text-slate-800 dark:text-zinc-100">
{"Consumo en el Tiempo"}
</h2>
<p className="text-xs text-slate-500 dark:text-zinc-400 mt-0.5">
{`${chartData.length} lecturas en el período`}
</p>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-zinc-400">
<span className="inline-block w-3 h-3 rounded-full bg-blue-500" />
{"Lectura (m³)"}
</div>
</div>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<defs>
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" strokeOpacity={0.5} vertical={false} />
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "#94a3b8" }}
tickLine={false}
axisLine={{ stroke: "#e2e8f0" }}
dy={8}
/>
<YAxis
tick={{ fontSize: 11, fill: "#94a3b8" }}
tickLine={false}
axisLine={false}
unit=" m³"
width={70}
domain={chartDomain}
/>
<Tooltip
contentStyle={{
backgroundColor: "#1e293b",
border: "none",
borderRadius: "0.75rem",
color: "#f1f5f9",
fontSize: "0.875rem",
padding: "12px 16px",
boxShadow: "0 10px 25px rgba(0,0,0,0.2)",
}}
formatter={(value: number | undefined) => [
`${(value ?? 0).toFixed(2)}`,
"Lectura",
]}
labelFormatter={(_label, payload) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(payload as any)?.[0]?.payload?.fullDate || _label
}
/>
<Area
type="monotone"
dataKey="value"
stroke="#3b82f6"
strokeWidth={2.5}
fill="url(#colorValue)"
dot={{ r: 3, fill: "#3b82f6", stroke: "#fff", strokeWidth: 2 }}
activeDot={{ r: 6, stroke: "#3b82f6", strokeWidth: 2, fill: "#fff" }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Table */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 overflow-hidden">
<div className="px-5 py-4 border-b border-slate-100 dark:border-zinc-800 flex items-center justify-between">
<span className="text-sm text-slate-500 dark:text-zinc-400">
<span className="font-semibold text-slate-700 dark:text-zinc-200">
{pagination.total}
</span>{" "}
lecturas encontradas
</span>
{pagination.totalPages > 1 && (
<div className="flex items-center gap-1 bg-slate-50 dark:bg-zinc-800 rounded-lg p-1">
<button
onClick={() => handlePageChange(pagination.page - 1)}
disabled={pagination.page === 1}
className="p-1.5 rounded-md hover:bg-white dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={16} className="dark:text-zinc-300" />
</button>
<span className="px-2 text-xs font-medium dark:text-zinc-300">
{pagination.page} / {pagination.totalPages}
</span>
<button
onClick={() => handlePageChange(pagination.page + 1)}
disabled={pagination.page === pagination.totalPages}
className="p-1.5 rounded-md hover:bg-white dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={16} className="dark:text-zinc-300" />
</button>
</div>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-50/80 dark:bg-zinc-800">
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
Fecha / Hora
</th>
<th className="px-5 py-3 text-right text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
{"Lectura (m³)"}
</th>
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
Tipo
</th>
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
{"Batería"}
</th>
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
{"Señal"}
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-zinc-700">
{loadingReadings ? (
Array.from({ length: 8 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 5 }).map((_, j) => (
<td key={j} className="px-5 py-4">
<div className="h-4 bg-slate-100 dark:bg-zinc-700 rounded-md animate-pulse" />
</td>
))}
</tr>
))
) : readings.length === 0 ? (
<tr>
<td colSpan={5} className="px-5 py-16 text-center">
<div className="flex flex-col items-center">
<div className="w-16 h-16 bg-slate-100 dark:bg-zinc-800 rounded-2xl flex items-center justify-center mb-4">
<Droplets size={32} className="text-slate-400" />
</div>
<p className="text-slate-600 dark:text-zinc-300 font-medium">
No hay lecturas disponibles
</p>
<p className="text-slate-400 dark:text-zinc-500 text-sm mt-1">
{startDate || endDate
? "Intenta ajustar el rango de fechas"
: "Este medidor aún no tiene lecturas registradas"}
</p>
</div>
</td>
</tr>
) : (
readings.map((reading, idx) => (
<tr
key={reading.id}
className={`group hover:bg-blue-50/40 dark:hover:bg-zinc-800 transition-colors ${
idx % 2 === 0
? "bg-white dark:bg-zinc-900"
: "bg-slate-50/30 dark:bg-zinc-800/50"
}`}
>
<td className="px-5 py-3.5">
<span className="text-sm text-slate-600 dark:text-zinc-300">
{formatDate(reading.receivedAt)}
</span>
</td>
<td className="px-5 py-3.5 text-right">
<span className="text-sm font-semibold text-slate-800 dark:text-zinc-100 tabular-nums">
{Number(reading.readingValue).toFixed(2)}
</span>
</td>
<td className="px-5 py-3.5 text-center">
<TypeBadge type={reading.readingType} />
</td>
<td className="px-5 py-3.5 text-center">
{reading.batteryLevel !== null ? (
<BatteryIndicator level={reading.batteryLevel} />
) : (
<span className="text-slate-400 dark:text-zinc-500">{"—"}</span>
)}
</td>
<td className="px-5 py-3.5 text-center">
{reading.signalStrength !== null ? (
<SignalIndicator strength={reading.signalStrength} />
) : (
<span className="text-slate-400 dark:text-zinc-500">{"—"}</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Footer pagination */}
{!loadingReadings && readings.length > 0 && (
<div className="px-5 py-4 border-t border-slate-100 dark:border-zinc-700 flex flex-wrap items-center justify-between gap-4">
<div className="text-sm text-slate-600 dark:text-zinc-300">
Mostrando{" "}
<span className="font-semibold text-slate-800 dark:text-zinc-200">
{(pagination.page - 1) * pagination.pageSize + 1}
</span>{" "}
a{" "}
<span className="font-semibold text-slate-800 dark:text-zinc-200">
{Math.min(pagination.page * pagination.pageSize, pagination.total)}
</span>{" "}
de{" "}
<span className="font-semibold text-slate-800 dark:text-zinc-200">
{pagination.total}
</span>{" "}
resultados
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-600 dark:text-zinc-300">
{"Filas por página:"}
</span>
<select
value={pagination.pageSize}
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
className="px-3 py-1.5 text-sm bg-white dark:bg-zinc-800 dark:text-zinc-100 border border-slate-200 dark:border-zinc-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handlePageChange(pagination.page - 1)}
disabled={pagination.page === 1}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-zinc-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={18} className="text-slate-600 dark:text-zinc-400" />
</button>
<div className="flex items-center gap-1">
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1)
.filter((pageNum) => {
if (pageNum === 1 || pageNum === pagination.totalPages) return true;
if (Math.abs(pageNum - pagination.page) <= 1) return true;
return false;
})
.map((pageNum, idx, arr) => {
const prevNum = arr[idx - 1];
const showEllipsis = prevNum && pageNum - prevNum > 1;
return (
<div key={pageNum} className="flex items-center">
{showEllipsis && (
<span className="px-2 text-slate-400 dark:text-zinc-500">
...
</span>
)}
<button
onClick={() => handlePageChange(pageNum)}
className={`min-w-[36px] px-3 py-1.5 text-sm rounded-lg transition-colors ${
pageNum === pagination.page
? "bg-blue-600 text-white font-semibold"
: "text-slate-600 dark:text-zinc-300 hover:bg-slate-100 dark:hover:bg-zinc-800"
}`}
>
{pageNum}
</button>
</div>
);
})}
</div>
<button
onClick={() => handlePageChange(pagination.page + 1)}
disabled={pagination.page === pagination.totalPages}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-zinc-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={18} className="text-slate-600 dark:text-zinc-400" />
</button>
</div>
</div>
</div>
)}
</div>
</>
)}
</div>
</div>
);
}
function InfoItem({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-2">
<div className="mt-0.5 text-slate-400 dark:text-zinc-500">{icon}</div>
<div>
<p className="text-xs font-medium text-slate-500 dark:text-zinc-400 uppercase tracking-wide">
{label}
</p>
<p className="text-sm font-semibold text-slate-800 dark:text-zinc-100 mt-0.5">{value}</p>
</div>
</div>
);
}
function ConsumptionCard({
label,
sublabel,
value,
loading,
gradient,
}: {
label: string;
sublabel: string;
value: number | null;
loading: boolean;
gradient: string;
}) {
return (
<div className="relative bg-white dark:bg-zinc-900 rounded-2xl p-5 shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 overflow-hidden group hover:shadow-md transition-all">
<div className="flex items-start justify-between">
<div className="space-y-2">
<p className="text-sm font-medium text-slate-500 dark:text-zinc-400">{label}</p>
{loading ? (
<div className="h-8 w-24 bg-slate-100 dark:bg-zinc-700 rounded-lg animate-pulse" />
) : value !== null ? (
<p className="text-2xl font-bold text-slate-800 dark:text-white tabular-nums">
{value.toFixed(2)}
<span className="text-sm font-normal text-slate-400 dark:text-zinc-500 ml-1">{"m³"}</span>
</p>
) : (
<p className="text-2xl font-bold text-slate-400 dark:text-zinc-500">{"—"}</p>
)}
<p className="text-xs text-slate-400 dark:text-zinc-500">{sublabel}</p>
</div>
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${gradient} flex items-center justify-center text-white shadow-lg group-hover:scale-110 transition-transform`}>
<Droplets size={22} />
</div>
</div>
<div className={`absolute -right-8 -bottom-8 w-32 h-32 rounded-full bg-gradient-to-br ${gradient} opacity-5`} />
</div>
);
}
function TypeBadge({ type }: { type: string | null }) {
if (!type) return <span className="text-slate-400 dark:text-zinc-500">{"—"}</span>;
const styles: Record<string, { bg: string; text: string; dot: string }> = {
AUTOMATIC: {
bg: "bg-emerald-50 dark:bg-emerald-900/30",
text: "text-emerald-700 dark:text-emerald-400",
dot: "bg-emerald-500",
},
MANUAL: {
bg: "bg-blue-50 dark:bg-blue-900/30",
text: "text-blue-700 dark:text-blue-400",
dot: "bg-blue-500",
},
SCHEDULED: {
bg: "bg-violet-50 dark:bg-violet-900/30",
text: "text-violet-700 dark:text-violet-400",
dot: "bg-violet-500",
},
};
const style = styles[type] || {
bg: "bg-slate-50 dark:bg-zinc-800",
text: "text-slate-700 dark:text-zinc-300",
dot: "bg-slate-500",
};
return (
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${style.bg} ${style.text}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${style.dot}`} />
{type}
</span>
);
}
function BatteryIndicator({ level }: { level: number }) {
const getColor = () => {
if (level > 50) return "bg-emerald-500";
if (level > 20) return "bg-amber-500";
return "bg-red-500";
};
return (
<div className="inline-flex items-center gap-1" title={`Batería: ${level}%`}>
<div className="w-6 h-3 border border-slate-300 dark:border-zinc-600 rounded-sm relative overflow-hidden">
<div
className={`absolute left-0 top-0 bottom-0 ${getColor()} transition-all`}
style={{ width: `${level}%` }}
/>
</div>
<span className="text-[10px] text-slate-500 dark:text-zinc-400 font-medium">{level}%</span>
</div>
);
}
function SignalIndicator({ strength }: { strength: number }) {
const getBars = () => {
if (strength >= -70) return 4;
if (strength >= -85) return 3;
if (strength >= -100) return 2;
return 1;
};
const bars = getBars();
return (
<div
className="inline-flex items-end gap-0.5 h-3"
title={`Señal: ${strength} dBm`}
>
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className={`w-1 rounded-sm transition-colors ${
i <= bars ? "bg-emerald-500" : "bg-slate-200 dark:bg-zinc-600"
}`}
style={{ height: `${i * 2 + 4}px` }}
/>
))}
</div>
);
}

View File

@@ -36,7 +36,6 @@ export default function MetersPage({
// UI state
const [takeType, setTakeType] = useState<TakeType>("GENERAL");
const isMockMode = takeType !== "GENERAL";
const [activeMeter, setActiveMeter] = useState<Meter | null>(null);
const [search, setSearch] = useState("");
@@ -67,33 +66,36 @@ export default function MetersPage({
const [form, setForm] = useState<MeterInput>(emptyForm);
const [errors, setErrors] = useState<Record<string, boolean>>({});
// Projects cards (from real data)
const projectsDataReal: ProjectCard[] = useMemo(() => {
const baseRegion = "Baja California";
const baseContact = "Operaciones";
const baseLastSync = "Hace 1 h";
return m.allProjects.map((name) => ({
name,
return m.filteredProjects.map((project) => ({
name: project.name,
region: baseRegion,
projects: 1,
meters: m.projectsCounts[name] ?? 0,
meters: m.projectsCounts[project.name] ?? 0,
activeAlerts: 0,
lastSync: baseLastSync,
contact: baseContact,
status: "ACTIVO" as ProjectStatus,
}));
}, [m.allProjects, m.projectsCounts]);
}, [m.filteredProjects, m.projectsCounts]);
const sidebarProjects = isMockMode ? [] : projectsDataReal;
const sidebarProjects = projectsDataReal;
// Search filtered meters
const searchFiltered = useMemo(() => {
if (isMockMode) return [];
const q = search.trim().toLowerCase();
if (!q) return m.filteredMeters;
let filtered = m.filteredMeters;
return m.filteredMeters.filter((x) => {
if (takeType !== "GENERAL") {
filtered = filtered.filter((meter) => meter.type === takeType);
}
const q = search.trim().toLowerCase();
if (!q) return filtered;
return filtered.filter((x) => {
return (
(x.name ?? "").toLowerCase().includes(q) ||
(x.serialNumber ?? "").toLowerCase().includes(q) ||
@@ -101,7 +103,7 @@ export default function MetersPage({
(x.concentratorName ?? "").toLowerCase().includes(q)
);
});
}, [isMockMode, search, m.filteredMeters]);
}, [takeType, search, m.filteredMeters]);
// Validation
const validateForm = (): boolean => {
@@ -117,16 +119,18 @@ export default function MetersPage({
// CRUD handlers
const handleSave = async () => {
if (isMockMode) return;
if (!validateForm()) return;
try {
if (editingId) {
const updatedMeter = await updateMeter(editingId, form);
m.setMeters((prev) => prev.map((x) => (x.id === editingId ? updatedMeter : x)));
await updateMeter(editingId, form);
// Reload meters to ensure data is synced with backend
// This is important because the backend may update project_id based on concentrator
await m.loadMeters();
} else {
const newMeter = await createMeter(form);
m.setMeters((prev) => [...prev, newMeter]);
await createMeter(form);
// Reload meters to get the complete data with project info
await m.loadMeters();
}
setShowModal(false);
@@ -143,7 +147,6 @@ export default function MetersPage({
};
const handleDelete = async () => {
if (isMockMode) return;
if (!activeMeter) return;
try {
@@ -169,7 +172,7 @@ export default function MetersPage({
};
const openEditModal = () => {
if (isMockMode || !activeMeter) return;
if (!activeMeter) return;
setEditingId(activeMeter.id);
setForm({
@@ -181,14 +184,22 @@ export default function MetersPage({
type: activeMeter.type,
status: activeMeter.status,
installationDate: activeMeter.installationDate ?? "",
protocol: activeMeter.protocol ?? undefined,
voltage: activeMeter.voltage ?? undefined,
signal: activeMeter.signal ?? undefined,
leakageStatus: activeMeter.leakageStatus ?? undefined,
burstStatus: activeMeter.burstStatus ?? undefined,
currentFlow: activeMeter.currentFlow ?? undefined,
totalFlowReverse: activeMeter.totalFlowReverse ?? undefined,
manufacturer: activeMeter.manufacturer ?? undefined,
latitude: activeMeter.latitude ?? undefined,
longitude: activeMeter.longitude ?? undefined,
});
setErrors({});
setShowModal(true);
};
const openCreateModal = () => {
if (isMockMode) return;
setForm(emptyForm);
setErrors({});
setEditingId(null);
@@ -196,7 +207,7 @@ export default function MetersPage({
};
return (
<div className="flex gap-6 p-6 w-full bg-gray-100">
<div className="flex gap-6 p-6 w-full bg-gray-100 dark:bg-zinc-950">
{/* SIDEBAR */}
<MetersSidebar
loadingProjects={m.loadingProjects}
@@ -204,12 +215,21 @@ export default function MetersPage({
setTakeType={setTakeType}
selectedProject={m.selectedProject}
setSelectedProject={m.setSelectedProject}
isMockMode={isMockMode}
isMockMode={false}
projects={sidebarProjects}
onRefresh={handleRefresh}
refreshDisabled={false}
allProjects={m.allProjects}
allProjects={m.allProjects.map(p => p.name)}
onResetSelection={resetSelection}
meterTypes={m.meterTypes}
selectedMeterTypeId={m.selectedMeterTypeId}
onSelectMeterTypeId={(id: string) => {
m.setSelectedMeterTypeId(id);
m.setSelectedProject("");
setActiveMeter(null);
setSearch("");
}}
loadingMeterTypes={m.loadingMeterTypes}
/>
{/* MAIN */}
@@ -221,10 +241,8 @@ export default function MetersPage({
<div>
<h1 className="text-2xl font-bold">Meter Management</h1>
<p className="text-sm text-blue-100">
{isMockMode
? `Modo demo (${takeType}) - backend pendiente`
: m.selectedProject
? `Proyecto: ${m.selectedProject}`
{m.selectedProject
? `Proyecto: ${m.selectedProject}${takeType !== "GENERAL" ? ` • Tipo: ${takeType}` : ""}`
: "Selecciona un proyecto desde el panel izquierdo"}
</p>
</div>
@@ -232,15 +250,14 @@ export default function MetersPage({
<div className="flex gap-3">
<button
onClick={openCreateModal}
disabled={isMockMode || m.allProjects.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
disabled={m.allProjects.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
<Plus size={16} /> Agregar
</button>
<button
onClick={() => setShowBulkUpload(true)}
disabled={isMockMode}
className="flex items-center gap-2 px-4 py-2 bg-green-500 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-green-600"
>
<Upload size={16} /> Carga Masiva
@@ -248,19 +265,16 @@ export default function MetersPage({
<button
onClick={openEditModal}
disabled={isMockMode || !activeMeter}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
disabled={!activeMeter}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60 hover:bg-white/10"
>
<Pencil size={16} /> Editar
</button>
<button
onClick={() => {
if (isMockMode) return;
setConfirmOpen(true);
}}
disabled={isMockMode || !activeMeter}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
onClick={() => setConfirmOpen(true)}
disabled={!activeMeter}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60 hover:bg-red-500/20"
>
<Trash2 size={16} /> Eliminar
</button>
@@ -275,20 +289,21 @@ export default function MetersPage({
</div>
<input
className="bg-white rounded-lg shadow px-4 py-2 text-sm"
className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 dark:text-zinc-100 rounded-lg shadow px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:placeholder-zinc-500"
placeholder="Buscar por nombre, serial, ubicación o concentrador..."
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={isMockMode || !m.selectedProject}
disabled={!m.selectedProject}
/>
<MetersTable
data={searchFiltered}
isLoading={m.loadingMeters}
isMockMode={isMockMode}
isMockMode={false}
selectedProject={m.selectedProject}
activeMeter={activeMeter}
onRowClick={(row) => setActiveMeter(row)}
takeType={takeType}
/>
<ConfirmModal
@@ -317,6 +332,7 @@ export default function MetersPage({
{showModal && (
<MetersModal
editingId={editingId}
selectedProject={m.selectedProject}
form={form}
setForm={setForm}
errors={errors}

View File

@@ -5,6 +5,7 @@ import { fetchConcentrators, type Concentrator } from "../../api/concentrators";
type Props = {
editingId: string | null;
selectedProject?: string;
form: MeterInput;
setForm: React.Dispatch<React.SetStateAction<MeterInput>>;
@@ -18,6 +19,7 @@ type Props = {
export default function MetersModal({
editingId,
selectedProject,
form,
setForm,
errors,
@@ -28,6 +30,7 @@ export default function MetersModal({
const title = editingId ? "Editar Medidor" : "Agregar Medidor";
const [concentrators, setConcentrators] = useState<Concentrator[]>([]);
const [loadingConcentrators, setLoadingConcentrators] = useState(true);
const isPruebaProject = selectedProject === "PRUEBA";
// Load concentrators for the dropdown
useEffect(() => {
@@ -46,18 +49,18 @@ export default function MetersModal({
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-[500px] max-h-[90vh] overflow-y-auto space-y-4">
<h2 className="text-lg font-semibold">{title}</h2>
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-700 rounded-xl p-6 w-[500px] max-h-[90vh] overflow-y-auto space-y-4">
<h2 className="text-lg font-semibold dark:text-white">{title}</h2>
{/* FORM */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
<h3 className="text-sm font-semibold text-gray-700 dark:text-zinc-200 border-b dark:border-zinc-700 pb-2">
Información del Medidor
</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-gray-600 mb-1">Serial *</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Serial *</label>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["serialNumber"] ? "border-red-500" : ""
@@ -76,7 +79,7 @@ export default function MetersModal({
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Meter ID</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Meter ID</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="ID del medidor (opcional)"
@@ -87,7 +90,7 @@ export default function MetersModal({
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Nombre *</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Nombre *</label>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["name"] ? "border-red-500" : ""
@@ -104,7 +107,7 @@ export default function MetersModal({
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Concentrador *</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Concentrador *</label>
<select
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["concentratorId"] ? "border-red-500" : ""
@@ -132,7 +135,7 @@ export default function MetersModal({
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Ubicación</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Ubicación</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Ubicación del medidor (opcional)"
@@ -141,9 +144,41 @@ export default function MetersModal({
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Domicilio</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Domicilio de la toma (opcional)"
value={form.address ?? ""}
onChange={(e) => setForm({ ...form, address: e.target.value || undefined })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-gray-600 mb-1">Tipo</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Cuenta CESPT</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Cuenta CESPT (opcional)"
value={form.cesptAccount ?? ""}
onChange={(e) => setForm({ ...form, cesptAccount: e.target.value || undefined })}
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Clave Catastral</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Clave catastral (opcional)"
value={form.cadastralKey ?? ""}
onChange={(e) => setForm({ ...form, cadastralKey: e.target.value || undefined })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Tipo</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.type ?? "LORA"}
@@ -156,7 +191,7 @@ export default function MetersModal({
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Estado</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Estado</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.status ?? "ACTIVE"}
@@ -172,7 +207,7 @@ export default function MetersModal({
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Fecha de Instalación</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Fecha de Instalación</label>
<input
type="date"
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
@@ -187,9 +222,148 @@ export default function MetersModal({
</div>
</div>
{isPruebaProject && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-700 dark:text-zinc-200 border-b dark:border-zinc-700 pb-2">
Información Técnica (Proyecto PRUEBA)
</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Protocol</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="ej: LoRaWAN"
value={form.protocol ?? ""}
onChange={(e) => setForm({ ...form, protocol: e.target.value || undefined })}
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Voltage (V)</label>
<input
type="number"
step="0.01"
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="ej: 3.6"
value={form.voltage ?? ""}
onChange={(e) => setForm({ ...form, voltage: e.target.value ? parseFloat(e.target.value) : undefined })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Signal (dBm)</label>
<input
type="number"
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="ej: -85"
value={form.signal ?? ""}
onChange={(e) => setForm({ ...form, signal: e.target.value ? parseInt(e.target.value) : undefined })}
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Current Flow</label>
<input
type="number"
step="0.0001"
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="ej: 12.5"
value={form.currentFlow ?? ""}
onChange={(e) => setForm({ ...form, currentFlow: e.target.value ? parseFloat(e.target.value) : undefined })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Total Flow Reverse</label>
<input
type="number"
step="0.0001"
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="ej: 0.0"
value={form.totalFlowReverse ?? ""}
onChange={(e) => setForm({ ...form, totalFlowReverse: e.target.value ? parseFloat(e.target.value) : undefined })}
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Leakage Status</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.leakageStatus ?? ""}
onChange={(e) => setForm({ ...form, leakageStatus: e.target.value || undefined })}
>
<option value="">Selecciona...</option>
<option value="OK">OK</option>
<option value="WARNING">Warning</option>
<option value="ALERT">Alert</option>
<option value="CRITICAL">Critical</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Burst Status</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.burstStatus ?? ""}
onChange={(e) => setForm({ ...form, burstStatus: e.target.value || undefined })}
>
<option value="">Selecciona...</option>
<option value="OK">OK</option>
<option value="WARNING">Warning</option>
<option value="ALERT">Alert</option>
<option value="CRITICAL">Critical</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Manufacturer</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="ej: Kamstrup"
value={form.manufacturer ?? ""}
onChange={(e) => setForm({ ...form, manufacturer: e.target.value || undefined })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Latitude</label>
<input
type="number"
step="0.00000001"
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="ej: 20.659698"
value={form.latitude ?? ""}
onChange={(e) => setForm({ ...form, latitude: e.target.value ? parseFloat(e.target.value) : undefined })}
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Longitude</label>
<input
type="number"
step="0.00000001"
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="ej: -103.349609"
value={form.longitude ?? ""}
onChange={(e) => setForm({ ...form, longitude: e.target.value ? parseFloat(e.target.value) : undefined })}
/>
</div>
</div>
</div>
)}
{/* ACTIONS */}
<div className="flex justify-end gap-2 pt-3 border-t">
<button onClick={onClose} className="px-4 py-2 rounded hover:bg-gray-100">
<div className="flex justify-end gap-2 pt-3 border-t dark:border-zinc-700">
<button onClick={onClose} className="px-4 py-2 rounded hover:bg-gray-100 dark:hover:bg-zinc-800 dark:text-zinc-300">
Cancelar
</button>
<button

View File

@@ -1,8 +1,9 @@
// src/pages/meters/MetersSidebar.tsx
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, RefreshCcw, Check } from "lucide-react";
import { RefreshCcw, Check } from "lucide-react";
import type React from "react";
import type { ProjectCard, TakeType } from "./MeterPage";
import type { MeterType } from "../../api/meterTypes";
type Props = {
loadingProjects: boolean;
@@ -21,6 +22,11 @@ type Props = {
allProjects: string[];
onResetSelection?: () => void;
meterTypes: MeterType[];
selectedMeterTypeId: string;
onSelectMeterTypeId: (id: string) => void;
loadingMeterTypes: boolean;
};
type TakeTypeOption = { key: TakeType; label: string };
@@ -44,6 +50,10 @@ export default function MetersSidebar({
refreshDisabled,
allProjects,
onResetSelection,
meterTypes,
selectedMeterTypeId,
onSelectMeterTypeId,
loadingMeterTypes,
}: Props) {
const [typesMenuOpen, setTypesMenuOpen] = useState(false);
@@ -68,17 +78,15 @@ export default function MetersSidebar({
return (
<aside className="w-[420px] shrink-0">
<div className="bg-white rounded-xl shadow p-4 flex flex-col h-[calc(100vh-48px)]">
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-4 flex flex-col h-[calc(100vh-48px)]">
{/* Header */}
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm text-gray-500">Proyectos</p>
<p className="text-xs text-gray-400">
Tipo: <span className="font-semibold">{takeTypeLabel}</span>
{" • "}
<p className="text-sm text-gray-500 dark:text-zinc-400">Proyectos</p>
<p className="text-xs text-gray-400 dark:text-zinc-500">
Seleccionado:{" "}
<span className="font-semibold">
{selectedProject || (isMockMode ? "— (modo demo)" : "—")}
{selectedProject || "—"}
</span>
</p>
</div>
@@ -96,27 +104,9 @@ export default function MetersSidebar({
{/* ✅ Tipos de tomas (dropdown) — mismo UI que Concentrators */}
<div className="mt-4 relative" ref={menuRef}>
<button
type="button"
onClick={() => setTypesMenuOpen((v) => !v)}
disabled={loadingProjects}
className="w-full inline-flex items-center justify-between rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-700 shadow-sm hover:bg-gray-50 disabled:opacity-60"
>
<span className="flex items-center gap-2">
Tipos de tomas
<span className="text-xs font-semibold text-gray-500">
({takeTypeLabel})
</span>
</span>
<ChevronDown
size={16}
className={`${typesMenuOpen ? "rotate-180" : ""} transition`}
/>
</button>
{typesMenuOpen && (
<div className="absolute z-50 mt-2 w-full rounded-xl border border-gray-200 bg-white shadow-lg overflow-hidden">
<div className="absolute z-50 mt-2 w-full rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 shadow-lg overflow-hidden">
{TAKE_TYPE_OPTIONS.map((opt) => {
const active = takeType === opt.key;
@@ -128,25 +118,20 @@ export default function MetersSidebar({
setTakeType(opt.key);
setTypesMenuOpen(false);
// Reset selection/search desde el parent
onResetSelection?.();
if (opt.key !== "GENERAL") {
// mock mode -> limpia selección real
setSelectedProject("");
} else {
// vuelve a GENERAL -> autoselecciona real si no hay
setSelectedProject((prev) => prev || allProjects[0] || "");
if (!selectedProject && allProjects.length > 0) {
setSelectedProject(allProjects[0]);
}
}}
className={[
"w-full px-3 py-2 text-left text-sm flex items-center justify-between hover:bg-gray-50",
"w-full px-3 py-2 text-left text-sm flex items-center justify-between hover:bg-gray-50 dark:hover:bg-zinc-700",
active ? "bg-blue-50/60" : "bg-white",
].join(" ")}
>
<span
className={`font-semibold ${
active ? "text-blue-700" : "text-gray-700"
active ? "text-blue-700" : "text-gray-700 dark:text-zinc-200"
}`}
>
{opt.label}
@@ -160,15 +145,35 @@ export default function MetersSidebar({
)}
</div>
<div className="mt-3">
<label className="block text-xs font-semibold text-gray-700 mb-1.5">
Filtrar por Tipo de Toma del Proyecto
</label>
<select
value={selectedMeterTypeId}
onChange={(e) => onSelectMeterTypeId(e.target.value)}
disabled={loadingMeterTypes}
className="w-full rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
>
<option value="">Todos los tipos de toma</option>
{meterTypes.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
</div>
{/* List */}
<div className="mt-4 overflow-y-auto flex-1 space-y-3 pr-1">
{loadingProjects ? (
<div className="text-sm text-gray-500">Loading projects...</div>
<div className="text-sm text-gray-500 dark:text-zinc-400">Cargando proyectos...</div>
) : projects.length === 0 ? (
<div className="text-sm text-gray-500 text-center py-10">
{isMockMode
? "No hay datos demo para este tipo."
: "No se encontraron proyectos."}
{selectedMeterTypeId
? "No hay proyectos con el tipo de toma seleccionado."
: "No se encontraron proyectos."
}
</div>
) : (
projects.map((p) => {
@@ -185,8 +190,8 @@ export default function MetersSidebar({
className={[
"rounded-xl border p-4 transition cursor-pointer",
active
? "border-blue-600 bg-blue-50/40"
: "border-gray-200 bg-white hover:bg-gray-50",
? "border-blue-600 bg-blue-50/40 dark:bg-blue-900/30"
: "border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-700",
isMockMode ? "opacity-90" : "",
].join(" ")}
title={
@@ -197,10 +202,10 @@ export default function MetersSidebar({
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-gray-800">
<p className="text-sm font-semibold text-gray-800 dark:text-zinc-100">
{p.name}
</p>
<p className="text-xs text-gray-500">{p.region}</p>
<p className="text-xs text-gray-500 dark:text-zinc-400">{p.region}</p>
</div>
<span
@@ -208,7 +213,7 @@ export default function MetersSidebar({
"text-xs font-semibold px-2 py-1 rounded-full",
p.status === "ACTIVO"
? "bg-green-100 text-green-700"
: "bg-gray-200 text-gray-700",
: "bg-gray-200 text-gray-700 dark:text-zinc-200",
].join(" ")}
>
{p.status}
@@ -217,36 +222,36 @@ export default function MetersSidebar({
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div className="flex justify-between gap-2">
<span className="text-gray-500">Subproyectos</span>
<span className="font-medium text-gray-800">
<span className="text-gray-500 dark:text-zinc-400">Subproyectos</span>
<span className="font-medium text-gray-800 dark:text-zinc-100">
{p.projects}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500">Medidores</span>
<span className="font-medium text-gray-800">
<span className="text-gray-500 dark:text-zinc-400">Medidores</span>
<span className="font-medium text-gray-800 dark:text-zinc-100">
{p.meters}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500">Alertas activas</span>
<span className="font-medium text-gray-800">
<span className="text-gray-500 dark:text-zinc-400">Alertas activas</span>
<span className="font-medium text-gray-800 dark:text-zinc-100">
{p.activeAlerts}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500">Última sync</span>
<span className="font-medium text-gray-800">
<span className="text-gray-500 dark:text-zinc-400">Última sync</span>
<span className="font-medium text-gray-800 dark:text-zinc-100">
{p.lastSync}
</span>
</div>
<div className="col-span-2 flex justify-between gap-2">
<span className="text-gray-500">Responsable</span>
<span className="font-medium text-gray-800">
<span className="text-gray-500 dark:text-zinc-400">Responsable</span>
<span className="font-medium text-gray-800 dark:text-zinc-100">
{p.contact}
</span>
</div>
@@ -283,7 +288,7 @@ export default function MetersSidebar({
)}
</div>
<div className="pt-3 border-t text-xs text-gray-500">
<div className="pt-3 border-t text-xs text-gray-500 dark:text-zinc-400">
Nota: region/alertas/última sync están en modo demostración hasta
integrar backend.
</div>

View File

@@ -1,13 +1,13 @@
import MaterialTable from "@material-table/core";
import type { Meter } from "../../api/meters";
import type { TakeType } from "./MeterPage";
type Props = {
export type MetersTableProps = {
data: Meter[];
isLoading: boolean;
isMockMode: boolean;
selectedProject: string;
takeType: TakeType;
activeMeter: Meter | null;
onRowClick: (row: Meter) => void;
};
@@ -17,70 +17,133 @@ export default function MetersTable({
isLoading,
isMockMode,
selectedProject,
takeType,
activeMeter,
onRowClick,
}: Props) {
}: MetersTableProps) {
const disabled = isMockMode || !selectedProject;
const typeLabels: Record<TakeType, string> = {
GENERAL: "todos los tipos",
LORA: "LoRa",
LORAWAN: "LoRaWAN",
GRANDES: "Grandes consumidores",
};
const isPruebaProject = selectedProject === "PRUEBA";
const defaultColumns = [
{ title: "Serial", field: "serialNumber", render: (r: Meter) => r.serialNumber || "-" },
{ title: "Meter ID", field: "meterId", render: (r: Meter) => r.meterId || "-" },
{ title: "Nombre", field: "name", render: (r: Meter) => r.name || "-" },
{ title: "Ubicación", field: "location", render: (r: Meter) => r.location || "-" },
{
title: "Tipo",
field: "type",
render: (r: Meter) => {
const typeLabels: Record<string, string> = {
LORA: "LoRa",
LORAWAN: "LoRaWAN",
GRANDES: "Grandes Consumidores",
};
const typeColors: Record<string, string> = {
LORA: "text-green-600 border-green-600",
LORAWAN: "text-purple-600 border-purple-600",
GRANDES: "text-orange-600 border-orange-600",
};
const type = r.type || "LORA";
return (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${typeColors[type] || "text-gray-600 border-gray-600"}`}
>
{typeLabels[type] || type}
</span>
);
},
},
{
title: "Estado",
field: "status",
render: (r: Meter) => (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${
r.status === "ACTIVE"
? "text-blue-600 border-blue-600"
: "text-red-600 border-red-600"
}`}
>
{r.status || "-"}
</span>
),
},
{ title: "Concentrador", field: "concentratorName", render: (r: Meter) => r.concentratorName || "-" },
{ title: "Última Lectura", field: "lastReadingValue", render: (r: Meter) => r.lastReadingValue != null ? Number(r.lastReadingValue).toFixed(2) : "-" },
];
const pruebaColumns = [
{ title: "Meters No.", field: "meterId", render: (r: Meter) => r.meterId || r.serialNumber || "-" },
{ title: "Name", field: "name", render: (r: Meter) => r.name || "-" },
{ title: "Protocol", field: "protocol", render: (r: Meter) => r.protocol || "-" },
{
title: "Status",
field: "status",
render: (r: Meter) => (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${
r.status === "ACTIVE"
? "text-blue-600 border-blue-600"
: "text-red-600 border-red-600"
}`}
>
{r.status || "-"}
</span>
),
},
{ title: "Total Flow", field: "lastReadingValue", render: (r: Meter) => r.lastReadingValue != null ? Number(r.lastReadingValue).toFixed(2) : "-" },
{
title: "Last Contact",
field: "lastReadingAt",
render: (r: Meter) => r.lastReadingAt ? new Date(r.lastReadingAt).toLocaleString('es-MX', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}) : "-"
},
{ title: "Voltage", field: "voltage", render: (r: Meter) => r.voltage != null ? `${Number(r.voltage).toFixed(2)} V` : "-" },
{ title: "Signal", field: "signal", render: (r: Meter) => r.signal != null ? `${r.signal} dBm` : "-" },
{ title: "Leakage Status", field: "leakageStatus", render: (r: Meter) => r.leakageStatus || "-" },
{ title: "Burst Status", field: "burstStatus", render: (r: Meter) => r.burstStatus || "-" },
{ title: "Current Flow", field: "currentFlow", render: (r: Meter) => r.currentFlow != null ? Number(r.currentFlow).toFixed(4) : "-" },
{ title: "Total Flow Reverse", field: "totalFlowReverse", render: (r: Meter) => r.totalFlowReverse != null ? Number(r.totalFlowReverse).toFixed(4) : "-" },
];
const columns = isPruebaProject ? pruebaColumns : defaultColumns;
return (
<div className={disabled ? "opacity-60 pointer-events-none" : ""}>
<MaterialTable
title="Meters"
isLoading={isLoading}
columns={[
{ title: "Serial", field: "serialNumber", render: (r: Meter) => r.serialNumber || "-" },
{ title: "Meter ID", field: "meterId", render: (r: Meter) => r.meterId || "-" },
{ title: "Nombre", field: "name", render: (r: Meter) => r.name || "-" },
{ title: "Ubicación", field: "location", render: (r: Meter) => r.location || "-" },
{
title: "Tipo",
field: "type",
render: (r: Meter) => {
const typeLabels: Record<string, string> = {
LORA: "LoRa",
LORAWAN: "LoRaWAN",
GRANDES: "Grandes Consumidores",
};
const typeColors: Record<string, string> = {
LORA: "text-green-600 border-green-600",
LORAWAN: "text-purple-600 border-purple-600",
GRANDES: "text-orange-600 border-orange-600",
};
const type = r.type || "LORA";
return (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${typeColors[type] || "text-gray-600 border-gray-600"}`}
>
{typeLabels[type] || type}
</span>
);
},
},
{
title: "Estado",
field: "status",
render: (r: Meter) => (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${
r.status === "ACTIVE"
? "text-blue-600 border-blue-600"
: "text-red-600 border-red-600"
}`}
>
{r.status || "-"}
</span>
),
},
{ title: "Concentrador", field: "concentratorName", render: (r: Meter) => r.concentratorName || "-" },
{ title: "Última Lectura", field: "lastReadingValue", render: (r: Meter) => r.lastReadingValue != null ? Number(r.lastReadingValue).toFixed(2) : "-" },
]}
columns={columns}
data={disabled ? [] : data}
onRowClick={(_, rowData) => onRowClick(rowData as Meter)}
options={{
actionsColumnIndex: -1,
search: false,
paging: true,
pageSize: 10,
pageSizeOptions: [10, 20, 50],
sorting: true,
maxBodyHeight: "calc(100vh - 400px)",
headerStyle: {
position: "sticky",
top: 0,
backgroundColor: "#fff",
zIndex: 10,
fontWeight: 600,
},
rowStyle: (rowData) => ({
backgroundColor:
activeMeter?.id === (rowData as Meter).id ? "#EEF2FF" : "#FFFFFF",
@@ -91,10 +154,12 @@ export default function MetersTable({
emptyDataSourceMessage: isMockMode
? "Modo demo: selecciona 'General' para ver datos reales."
: !selectedProject
? "Select a project to view meters."
? "Selecciona un proyecto para ver medidores."
: isLoading
? "Loading meters..."
: "No meters found. Click 'Add' to create your first meter.",
? "Cargando medidores..."
: takeType !== "GENERAL"
? `No se encontraron medidores de tipo ${typeLabels[takeType]} en este proyecto.`
: "No se encontraron medidores. Haz clic en 'Agregar' para crear tu primer medidor.",
},
}}
/>

View File

@@ -1,13 +1,19 @@
import { useEffect, useMemo, useState } from "react";
import { fetchMeters, type Meter } from "../../api/meters";
import { fetchProjects } from "../../api/projects";
import { fetchProjects, type Project } from "../../api/projects";
import { getCurrentUserRole, getCurrentUserProjectId } from "../../api/auth";
import { fetchMeterTypes, type MeterType } from "../../api/meterTypes";
type UseMetersArgs = {
initialProject?: string;
};
export function useMeters({ initialProject }: UseMetersArgs) {
const [allProjects, setAllProjects] = useState<string[]>([]);
const userRole = getCurrentUserRole();
const userProjectId = getCurrentUserProjectId();
const isAdmin = userRole?.toUpperCase() === 'ADMIN';
const [allProjects, setAllProjects] = useState<Project[]>([]);
const [loadingProjects, setLoadingProjects] = useState(true);
const [selectedProject, setSelectedProject] = useState(initialProject || "");
@@ -16,16 +22,32 @@ export function useMeters({ initialProject }: UseMetersArgs) {
const [filteredMeters, setFilteredMeters] = useState<Meter[]>([]);
const [loadingMeters, setLoadingMeters] = useState(true);
const [meterTypes, setMeterTypes] = useState<MeterType[]>([]);
const [selectedMeterTypeId, setSelectedMeterTypeId] = useState<string>("");
const [loadingMeterTypes, setLoadingMeterTypes] = useState(true);
const loadProjects = async () => {
setLoadingProjects(true);
try {
const projects = await fetchProjects();
const projectNames = projects.map((p) => p.name);
setAllProjects(projectNames);
let visibleProjects = projects;
if (!isAdmin && userProjectId) {
visibleProjects = projects.filter(p => p.id === userProjectId);
}
setAllProjects(visibleProjects);
setSelectedProject((prev) => {
if (prev) return prev;
if (initialProject) return initialProject;
if (!isAdmin && userProjectId) {
const userProject = projects.find(p => p.id === userProjectId);
if (userProject) return userProject.name;
}
const projectNames = visibleProjects.map((p) => p.name);
return projectNames[0] ?? "";
});
} catch (error) {
@@ -36,6 +58,19 @@ export function useMeters({ initialProject }: UseMetersArgs) {
}
};
const loadMeterTypes = async () => {
setLoadingMeterTypes(true);
try {
const types = await fetchMeterTypes();
setMeterTypes(types);
} catch (error) {
console.error("Error loading meter types:", error);
setMeterTypes([]);
} finally {
setLoadingMeterTypes(false);
}
};
const loadMeters = async () => {
setLoadingMeters(true);
@@ -50,10 +85,10 @@ export function useMeters({ initialProject }: UseMetersArgs) {
}
};
// init - load projects and meters
useEffect(() => {
loadProjects();
loadMeters();
loadMeterTypes();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -62,7 +97,6 @@ export function useMeters({ initialProject }: UseMetersArgs) {
if (initialProject) setSelectedProject(initialProject);
}, [initialProject]);
// filter by project
useEffect(() => {
if (!selectedProject) {
setFilteredMeters([]);
@@ -71,6 +105,13 @@ export function useMeters({ initialProject }: UseMetersArgs) {
setFilteredMeters(meters.filter((m) => m.projectName === selectedProject));
}, [selectedProject, meters]);
const filteredProjects = useMemo(() => {
if (!selectedMeterTypeId) {
return allProjects;
}
return allProjects.filter(p => p.meterTypeId === selectedMeterTypeId);
}, [allProjects, selectedMeterTypeId]);
const projectsCounts = useMemo(() => {
return meters.reduce<Record<string, number>>((acc, m) => {
const project = m.projectName ?? "SIN PROYECTO";
@@ -83,13 +124,19 @@ export function useMeters({ initialProject }: UseMetersArgs) {
// loading
loadingProjects,
loadingMeters,
loadingMeterTypes,
// projects
allProjects,
filteredProjects,
projectsCounts,
selectedProject,
setSelectedProject,
meterTypes,
selectedMeterTypeId,
setSelectedMeterTypeId,
// data
meters,
setMeters,

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useState, useMemo } from "react";
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
import MaterialTable from "@material-table/core";
import {
@@ -7,10 +7,20 @@ import {
fetchProjects,
createProject as apiCreateProject,
updateProject as apiUpdateProject,
deleteProject as apiDeleteProject,
deactivateProject as apiDeactivateProject,
} from "../../api/projects";
import { fetchMeterTypes, type MeterType } from "../../api/meterTypes";
import { getCurrentUserRole, getCurrentUserProjectId, getCurrentUserOrganismoId } from "../../api/auth";
import { getAllOrganismos, type OrganismoOperador } from "../../api/organismos";
export default function ProjectsPage() {
const userRole = useMemo(() => getCurrentUserRole(), []);
const userProjectId = useMemo(() => getCurrentUserProjectId(), []);
const userOrganismoId = useMemo(() => getCurrentUserOrganismoId(), []);
const isAdmin = userRole?.toUpperCase() === 'ADMIN';
const isOrganismo = userRole?.toUpperCase() === 'ORGANISMO_OPERADOR';
const isOperator = userRole?.toUpperCase() === 'OPERATOR';
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [activeProject, setActiveProject] = useState<Project | null>(null);
@@ -19,12 +29,17 @@ export default function ProjectsPage() {
const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [meterTypes, setMeterTypes] = useState<MeterType[]>([]);
const [organismos, setOrganismos] = useState<OrganismoOperador[]>([]);
const emptyForm: ProjectInput = {
name: "",
description: "",
areaName: "",
location: "",
status: "ACTIVE",
meterTypeId: null,
organismoOperadorId: isOrganismo ? userOrganismoId : null,
};
const [form, setForm] = useState<ProjectInput>(emptyForm);
@@ -42,8 +57,50 @@ export default function ProjectsPage() {
}
};
const visibleProjects = useMemo(() => {
// ADMIN sees all
if (isAdmin) return projects;
// ORGANISMO_OPERADOR sees only their organismo's projects
if (isOrganismo && userOrganismoId) {
return projects.filter(p => p.organismoOperadorId === userOrganismoId);
}
// OPERATOR sees only their single project
if (isOperator && userProjectId) {
return projects.filter(p => p.id === userProjectId);
}
return [];
}, [projects, isAdmin, isOrganismo, isOperator, userProjectId, userOrganismoId]);
const loadMeterTypesData = async () => {
try {
const types = await fetchMeterTypes();
setMeterTypes(types);
} catch (error) {
console.error("Error loading meter types:", error);
setMeterTypes([]);
}
};
const loadOrganismos = async () => {
try {
const response = await getAllOrganismos({ pageSize: 100 });
setOrganismos(response.data);
} catch (err) {
console.error("Error loading organismos:", err);
setOrganismos([]);
}
};
useEffect(() => {
loadProjects();
loadMeterTypesData();
if (isAdmin) {
loadOrganismos();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSave = async () => {
@@ -76,20 +133,25 @@ export default function ProjectsPage() {
if (!activeProject) return;
const confirmDelete = window.confirm(
`¿Estás seguro que quieres eliminar el proyecto "${activeProject.name}"?`
`¿Estás seguro que quieres desactivar el proyecto "${activeProject.name}"?\n\nEl proyecto será desactivado (no eliminado) y cualquier usuario asignado será desvinculado.`
);
if (!confirmDelete) return;
try {
await apiDeleteProject(activeProject.id);
setProjects((prev) => prev.filter((p) => p.id !== activeProject.id));
const deactivatedProject = await apiDeactivateProject(activeProject.id);
setProjects((prev) =>
prev.map((p) => (p.id === deactivatedProject.id ? deactivatedProject : p))
);
setActiveProject(null);
alert(`Proyecto "${activeProject.name}" ha sido desactivado exitosamente.`);
} catch (error) {
console.error("Error deleting project:", error);
console.error("Error deactivating project:", error);
alert(
`Error deleting project: ${
error instanceof Error ? error.message : "Please try again."
`Error al desactivar el proyecto: ${
error instanceof Error ? error.message : "Por favor intenta de nuevo."
}`
);
}
@@ -104,6 +166,8 @@ export default function ProjectsPage() {
areaName: activeProject.areaName,
location: activeProject.location ?? "",
status: activeProject.status,
meterTypeId: activeProject.meterTypeId ?? null,
organismoOperadorId: activeProject.organismoOperadorId ?? null,
});
setShowModal(true);
};
@@ -114,14 +178,14 @@ export default function ProjectsPage() {
setShowModal(true);
};
const filtered = projects.filter((p) =>
const filtered = visibleProjects.filter((p) =>
`${p.name} ${p.areaName} ${p.description ?? ""}`
.toLowerCase()
.includes(search.toLowerCase())
);
return (
<div className="flex gap-6 p-6 w-full bg-gray-100">
<div className="flex gap-6 p-6 w-full bg-gray-100 dark:bg-zinc-950">
<div className="flex-1 flex flex-col gap-6">
{/* HEADER */}
<div
@@ -136,28 +200,34 @@ export default function ProjectsPage() {
</div>
<div className="flex gap-3">
<button
onClick={openCreateModal}
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg"
>
<Plus size={16} /> Agregar
</button>
{(isAdmin || isOrganismo) && (
<button
onClick={openCreateModal}
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg"
>
<Plus size={16} /> Agregar
</button>
)}
<button
onClick={openEditModal}
disabled={!activeProject}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Pencil size={16} /> Editar
</button>
{(isAdmin || isOrganismo) && (
<button
onClick={openEditModal}
disabled={!activeProject}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Pencil size={16} /> Editar
</button>
)}
<button
onClick={handleDelete}
disabled={!activeProject}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Trash2 size={16} /> Eliminar
</button>
{(isAdmin || isOrganismo) && (
<button
onClick={handleDelete}
disabled={!activeProject}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Trash2 size={16} /> Eliminar
</button>
)}
<button
onClick={loadProjects}
@@ -170,7 +240,7 @@ export default function ProjectsPage() {
{/* SEARCH */}
<input
className="bg-white rounded-lg shadow px-4 py-2 text-sm"
className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 dark:text-zinc-100 rounded-lg shadow px-4 py-2 text-sm dark:placeholder-zinc-500"
placeholder="Buscar proyecto..."
value={search}
onChange={(e) => setSearch(e.target.value)}
@@ -183,6 +253,32 @@ export default function ProjectsPage() {
columns={[
{ title: "Nombre", field: "name" },
{ title: "Area", field: "areaName" },
...(isAdmin ? [{
title: "Organismo Operador",
field: "organismoOperadorId",
render: (rowData: Project) => {
if (!rowData.organismoOperadorId) return <span className="text-gray-400">-</span>;
const org = organismos.find(o => o.id === rowData.organismoOperadorId);
return org ? (
<span className="px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-700">
{org.name}
</span>
) : <span className="text-gray-400">-</span>;
},
}] : []),
{
title: "Tipo de Toma",
field: "meterTypeId",
render: (rowData: Project) => {
if (!rowData.meterTypeId) return "-";
const meterType = meterTypes.find(mt => mt.id === rowData.meterTypeId);
return meterType ? (
<span className="px-2 py-1 rounded text-xs font-medium bg-indigo-100 text-indigo-700">
{meterType.name}
</span>
) : "-";
}
},
{ title: "Descripción", field: "description", render: (rowData: Project) => rowData.description || "-" },
{ title: "Ubicación", field: "location", render: (rowData: Project) => rowData.location || "-" },
{
@@ -211,6 +307,8 @@ export default function ProjectsPage() {
options={{
search: false,
paging: true,
pageSize: 10,
pageSizeOptions: [10, 20, 50],
sorting: true,
rowStyle: (rowData) => ({
backgroundColor:
@@ -232,13 +330,13 @@ export default function ProjectsPage() {
{/* MODAL */}
{showModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-[450px] space-y-4">
<h2 className="text-lg font-semibold">
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-700 rounded-xl p-6 w-[450px] space-y-4">
<h2 className="text-lg font-semibold dark:text-white">
{editingId ? "Editar Proyecto" : "Agregar Proyecto"}
</h2>
<div>
<label className="block text-sm text-gray-600 mb-1">Nombre *</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Nombre *</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Nombre del proyecto"
@@ -248,7 +346,7 @@ export default function ProjectsPage() {
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Area *</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Area *</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Nombre del area"
@@ -258,7 +356,7 @@ export default function ProjectsPage() {
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Descripción</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Descripción</label>
<textarea
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Descripción del proyecto (opcional)"
@@ -269,7 +367,7 @@ export default function ProjectsPage() {
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Ubicación</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Ubicación</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Ubicación (opcional)"
@@ -279,7 +377,47 @@ export default function ProjectsPage() {
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Estado</label>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Tipo de Toma</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.meterTypeId ?? ""}
onChange={(e) => setForm({ ...form, meterTypeId: e.target.value || null })}
>
<option value="">Selecciona un tipo (opcional)</option>
{meterTypes.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
{meterTypes.length === 0 && (
<p className="text-xs text-amber-600 mt-1">
No hay tipos de toma disponibles. Asegúrate de aplicar la migración SQL.
</p>
)}
</div>
{/* Organismo Operador selector - ADMIN only */}
{isAdmin && (
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Organismo Operador</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.organismoOperadorId ?? ""}
onChange={(e) => setForm({ ...form, organismoOperadorId: e.target.value || null })}
>
<option value="">Sin organismo (opcional)</option>
{organismos.filter(o => o.is_active).map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
</div>
)}
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Estado</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.status ?? "ACTIVE"}
@@ -291,10 +429,10 @@ export default function ProjectsPage() {
</select>
</div>
<div className="flex justify-end gap-2 pt-3 border-t">
<div className="flex justify-end gap-2 pt-3 border-t dark:border-zinc-700">
<button
onClick={() => setShowModal(false)}
className="px-4 py-2 rounded hover:bg-gray-100"
className="px-4 py-2 rounded hover:bg-gray-100 dark:hover:bg-zinc-800 dark:text-zinc-300"
>
Cancelar
</button>

13
upload-panel/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Panel de Carga de Datos - GRH</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2348
upload-panel/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
upload-panel/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "upload-panel",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.559.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.2.1",
"tailwindcss": "^4.1.18",
"typescript": "^5.2.2",
"vite": "^5.2.0"
}
}

58
upload-panel/src/App.tsx Normal file
View File

@@ -0,0 +1,58 @@
import { Upload } from 'lucide-react';
import { MetersUpload } from './components/MetersUpload';
import { ReadingsUpload } from './components/ReadingsUpload';
function App() {
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b border-gray-200 shadow-sm">
<div className="max-w-6xl mx-auto px-4 py-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Upload className="w-6 h-6 text-blue-600" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">
Panel de Carga de Datos
</h1>
<p className="text-sm text-gray-500">
GRH - Sistema de Gestión de Recursos Hídricos
</p>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-6xl mx-auto px-4 py-8">
{/* Instructions */}
<div className="mb-8 bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-2">Instrucciones</h2>
<ul className="text-sm text-gray-600 space-y-1 list-disc list-inside">
<li>Descarga la plantilla CSV correspondiente para ver el formato requerido</li>
<li>Completa el archivo con los datos a cargar</li>
<li>Arrastra el archivo o haz clic para seleccionarlo</li>
<li>Revisa los resultados y corrige los errores si los hay</li>
</ul>
</div>
{/* Upload Cards */}
<div className="grid md:grid-cols-2 gap-6">
<MetersUpload />
<ReadingsUpload />
</div>
{/* Footer Info */}
<div className="mt-8 text-center text-sm text-gray-500">
<p>Los archivos deben estar en formato CSV (valores separados por comas).</p>
<p className="mt-1">
API: <code className="bg-gray-100 px-2 py-0.5 rounded">http://localhost:3000/api</code>
</p>
</div>
</main>
</div>
);
}
export default App;

View File

@@ -0,0 +1,65 @@
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
export interface UploadError {
row: number;
field?: string;
message: string;
data?: Record<string, unknown>;
}
export interface UploadResult {
total: number;
inserted: number;
updated: number;
errors: UploadError[];
}
export interface ApiResponse {
success: boolean;
message: string;
data?: UploadResult;
}
/**
* Upload meters CSV file
*/
export async function uploadMetersCSV(file: File): Promise<ApiResponse> {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE_URL}/csv-upload/meters`, {
method: 'POST',
body: formData,
});
return response.json();
}
/**
* Upload readings CSV file
*/
export async function uploadReadingsCSV(file: File): Promise<ApiResponse> {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE_URL}/csv-upload/readings`, {
method: 'POST',
body: formData,
});
return response.json();
}
/**
* Download meters CSV template
*/
export function downloadMetersTemplate(): void {
window.open(`${API_BASE_URL}/csv-upload/meters/template`, '_blank');
}
/**
* Download readings CSV template
*/
export function downloadReadingsTemplate(): void {
window.open(`${API_BASE_URL}/csv-upload/readings/template`, '_blank');
}

View File

@@ -0,0 +1,108 @@
import { useCallback, useState } from 'react';
import { Upload, FileText, X } from 'lucide-react';
interface FileDropzoneProps {
onFileSelect: (file: File) => void;
accept?: string;
disabled?: boolean;
}
export function FileDropzone({ onFileSelect, accept = '.csv', disabled = false }: FileDropzoneProps) {
const [isDragging, setIsDragging] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!disabled) {
setIsDragging(true);
}
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (disabled) return;
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (file.name.endsWith('.csv')) {
setSelectedFile(file);
onFileSelect(file);
}
}
}, [onFileSelect, disabled]);
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
const file = files[0];
setSelectedFile(file);
onFileSelect(file);
}
e.target.value = '';
}, [onFileSelect]);
const clearFile = useCallback(() => {
setSelectedFile(null);
}, []);
return (
<div
className={`
relative border-2 border-dashed rounded-lg p-6 text-center transition-colors
${isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:border-blue-400'}
`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
type="file"
accept={accept}
onChange={handleFileInput}
disabled={disabled}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
/>
{selectedFile ? (
<div className="flex items-center justify-center gap-3">
<FileText className="w-8 h-8 text-green-600" />
<div className="text-left">
<p className="font-medium text-gray-900">{selectedFile.name}</p>
<p className="text-sm text-gray-500">
{(selectedFile.size / 1024).toFixed(1)} KB
</p>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
clearFile();
}}
className="p-1 hover:bg-gray-100 rounded"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
) : (
<div className="space-y-2">
<Upload className="w-10 h-10 mx-auto text-gray-400" />
<p className="text-gray-600">
Arrastra un archivo CSV aquí o haz clic para seleccionar
</p>
<p className="text-sm text-gray-400">
Solo archivos .csv
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { useState, useCallback } from 'react';
import { Droplets, Download, Upload, Loader2 } from 'lucide-react';
import { FileDropzone } from './FileDropzone';
import { ResultsDisplay } from './ResultsDisplay';
import { uploadMetersCSV, downloadMetersTemplate, type UploadResult } from '../api/upload';
export function MetersUpload() {
const [file, setFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [result, setResult] = useState<UploadResult | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFileSelect = useCallback((selectedFile: File) => {
setFile(selectedFile);
setResult(null);
setError(null);
}, []);
const handleUpload = useCallback(async () => {
if (!file) return;
setIsUploading(true);
setError(null);
try {
const response = await uploadMetersCSV(file);
if (response.success && response.data) {
setResult(response.data);
} else {
setError(response.message || 'Error al procesar el archivo');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Error de conexión con el servidor');
} finally {
setIsUploading(false);
}
}, [file]);
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{/* Header */}
<div className="px-6 py-4 bg-gradient-to-r from-blue-500 to-blue-600">
<div className="flex items-center gap-3">
<div className="p-2 bg-white/20 rounded-lg">
<Droplets className="w-6 h-6 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Tomas de Agua (Medidores)</h2>
<p className="text-blue-100 text-sm">Crear nuevos o actualizar existentes</p>
</div>
</div>
</div>
{/* Content */}
<div className="p-6 space-y-4">
{/* Info */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800">
<strong>Campos requeridos:</strong> serial_number, name, concentrator_serial (para nuevos)
</p>
<p className="text-sm text-blue-700 mt-1">
Si el serial_number ya existe, se actualizarán los campos proporcionados.
</p>
</div>
{/* Template Download */}
<button
type="button"
onClick={downloadMetersTemplate}
className="inline-flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800 transition-colors"
>
<Download className="w-4 h-4" />
Descargar plantilla CSV
</button>
{/* File Dropzone */}
<FileDropzone
onFileSelect={handleFileSelect}
disabled={isUploading}
/>
{/* Upload Button */}
<button
type="button"
onClick={handleUpload}
disabled={!file || isUploading}
className={`
w-full py-3 px-4 rounded-lg font-medium flex items-center justify-center gap-2 transition-colors
${!file || isUploading
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}
`}
>
{isUploading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Procesando...
</>
) : (
<>
<Upload className="w-5 h-5" />
Subir Archivo
</>
)}
</button>
{/* Error Message */}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700">{error}</p>
</div>
)}
{/* Results */}
<ResultsDisplay result={result} type="meters" />
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { useState, useCallback } from 'react';
import { BarChart3, Download, Upload, Loader2 } from 'lucide-react';
import { FileDropzone } from './FileDropzone';
import { ResultsDisplay } from './ResultsDisplay';
import { uploadReadingsCSV, downloadReadingsTemplate, type UploadResult } from '../api/upload';
export function ReadingsUpload() {
const [file, setFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [result, setResult] = useState<UploadResult | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFileSelect = useCallback((selectedFile: File) => {
setFile(selectedFile);
setResult(null);
setError(null);
}, []);
const handleUpload = useCallback(async () => {
if (!file) return;
setIsUploading(true);
setError(null);
try {
const response = await uploadReadingsCSV(file);
if (response.success && response.data) {
setResult(response.data);
} else {
setError(response.message || 'Error al procesar el archivo');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Error de conexión con el servidor');
} finally {
setIsUploading(false);
}
}, [file]);
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{/* Header */}
<div className="px-6 py-4 bg-gradient-to-r from-green-500 to-green-600">
<div className="flex items-center gap-3">
<div className="p-2 bg-white/20 rounded-lg">
<BarChart3 className="w-6 h-6 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Lecturas</h2>
<p className="text-green-100 text-sm">Registrar lecturas de medidores</p>
</div>
</div>
</div>
{/* Content */}
<div className="p-6 space-y-4">
{/* Info */}
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-sm text-green-800">
<strong>Campos requeridos:</strong> meter_serial, reading_value
</p>
<p className="text-sm text-green-700 mt-1">
El medidor debe existir previamente. La fecha es opcional (por defecto: ahora).
</p>
</div>
{/* Template Download */}
<button
type="button"
onClick={downloadReadingsTemplate}
className="inline-flex items-center gap-2 text-sm text-green-600 hover:text-green-800 transition-colors"
>
<Download className="w-4 h-4" />
Descargar plantilla CSV
</button>
{/* File Dropzone */}
<FileDropzone
onFileSelect={handleFileSelect}
disabled={isUploading}
/>
{/* Upload Button */}
<button
type="button"
onClick={handleUpload}
disabled={!file || isUploading}
className={`
w-full py-3 px-4 rounded-lg font-medium flex items-center justify-center gap-2 transition-colors
${!file || isUploading
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-green-600 text-white hover:bg-green-700'
}
`}
>
{isUploading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Procesando...
</>
) : (
<>
<Upload className="w-5 h-5" />
Subir Archivo
</>
)}
</button>
{/* Error Message */}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700">{error}</p>
</div>
)}
{/* Results */}
<ResultsDisplay result={result} type="readings" />
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
import type { UploadResult } from '../api/upload';
interface ResultsDisplayProps {
result: UploadResult | null;
type: 'meters' | 'readings';
}
export function ResultsDisplay({ result, type }: ResultsDisplayProps) {
if (!result) return null;
const hasErrors = result.errors.length > 0;
const processedCount = type === 'meters'
? result.inserted + result.updated
: result.inserted;
return (
<div className="mt-4 border rounded-lg overflow-hidden">
{/* Summary Header */}
<div className={`p-4 ${hasErrors ? 'bg-yellow-50' : 'bg-green-50'}`}>
<div className="flex items-center gap-2">
{hasErrors ? (
<AlertTriangle className="w-5 h-5 text-yellow-600" />
) : (
<CheckCircle className="w-5 h-5 text-green-600" />
)}
<span className={`font-medium ${hasErrors ? 'text-yellow-800' : 'text-green-800'}`}>
Resultado de la carga
</span>
</div>
</div>
{/* Stats */}
<div className="p-4 space-y-2 border-t">
<div className="flex items-center gap-2 text-gray-700">
<CheckCircle className="w-4 h-4 text-green-500" />
<span>{result.total} registros procesados</span>
</div>
{type === 'meters' ? (
<>
<div className="flex items-center gap-2 text-gray-700">
<CheckCircle className="w-4 h-4 text-blue-500" />
<span>{result.inserted} insertados</span>
</div>
<div className="flex items-center gap-2 text-gray-700">
<CheckCircle className="w-4 h-4 text-purple-500" />
<span>{result.updated} actualizados</span>
</div>
</>
) : (
<div className="flex items-center gap-2 text-gray-700">
<CheckCircle className="w-4 h-4 text-blue-500" />
<span>{result.inserted} lecturas insertadas</span>
</div>
)}
{hasErrors && (
<div className="flex items-center gap-2 text-red-600">
<XCircle className="w-4 h-4" />
<span>{result.errors.length} errores</span>
</div>
)}
</div>
{/* Success message if no errors */}
{!hasErrors && processedCount > 0 && (
<div className="p-4 bg-green-50 border-t">
<p className="text-green-700 text-sm">
Todos los registros fueron procesados correctamente.
</p>
</div>
)}
{/* Error List */}
{hasErrors && (
<div className="border-t">
<div className="p-3 bg-red-50">
<span className="font-medium text-red-800">Errores encontrados:</span>
</div>
<div className="max-h-64 overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-600">Fila</th>
<th className="px-4 py-2 text-left font-medium text-gray-600">Campo</th>
<th className="px-4 py-2 text-left font-medium text-gray-600">Error</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{result.errors.map((error, index) => (
<tr key={index} className="hover:bg-gray-50">
<td className="px-4 py-2 text-gray-900">{error.row}</td>
<td className="px-4 py-2 text-gray-600">{error.field || '-'}</td>
<td className="px-4 py-2 text-red-600">{error.message}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

10
upload-panel/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

1
upload-panel/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [
react({
jsxRuntime: 'automatic',
}),
tailwindcss(),
],
server: {
host: true,
port: 5174,
allowedHosts: [
"localhost",
"127.0.0.1",
"panel.gestionrecursoshidricos.com"
],
},
});

View File

@@ -26,6 +26,7 @@
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"node-cron": "^3.0.3",
"pg": "^8.11.3",
"winston": "^3.11.0",
"xlsx": "^0.18.5",
@@ -37,6 +38,7 @@
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20.11.5",
"@types/node-cron": "^3.0.11",
"@types/pg": "^8.10.9",
"nodemon": "^3.0.3",
"ts-node-dev": "^2.0.0",

View File

@@ -0,0 +1,62 @@
#!/bin/bash
# ============================================================================
# Database Migration Script
# Run a specific SQL migration file against the database
# ============================================================================
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Load environment variables
if [ -f ../.env ]; then
export $(cat ../.env | grep -v '^#' | xargs)
else
echo -e "${RED}Error: .env file not found${NC}"
exit 1
fi
# Check if migration file is provided
if [ -z "$1" ]; then
echo -e "${YELLOW}Usage: ./run-migration.sh <migration-file.sql>${NC}"
echo ""
echo "Available migrations:"
ls -1 ../sql/*.sql | grep -v schema.sql
exit 1
fi
MIGRATION_FILE=$1
# Check if file exists
if [ ! -f "../sql/$MIGRATION_FILE" ]; then
echo -e "${RED}Error: Migration file not found: ../sql/$MIGRATION_FILE${NC}"
exit 1
fi
# Construct database URL
DB_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
echo -e "${YELLOW}========================================${NC}"
echo -e "${YELLOW}Running migration: $MIGRATION_FILE${NC}"
echo -e "${YELLOW}Database: $DB_NAME${NC}"
echo -e "${YELLOW}========================================${NC}"
echo ""
# Run the migration
psql "$DB_URL" -f "../sql/$MIGRATION_FILE"
if [ $? -eq 0 ]; then
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}✓ Migration completed successfully!${NC}"
echo -e "${GREEN}========================================${NC}"
else
echo ""
echo -e "${RED}========================================${NC}"
echo -e "${RED}✗ Migration failed!${NC}"
echo -e "${RED}========================================${NC}"
exit 1
fi

View File

@@ -0,0 +1,207 @@
-- ============================================================================
-- Audit Logs Migration
-- Add audit logging table to track user actions and system changes
-- ============================================================================
-- ============================================================================
-- ENUM TYPE: audit_action
-- ============================================================================
CREATE TYPE audit_action AS ENUM (
'CREATE',
'UPDATE',
'DELETE',
'LOGIN',
'LOGOUT',
'READ',
'EXPORT',
'BULK_UPLOAD',
'STATUS_CHANGE',
'PERMISSION_CHANGE'
);
-- ============================================================================
-- TABLE: audit_logs
-- ============================================================================
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- User information
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
user_email VARCHAR(255) NOT NULL,
user_name VARCHAR(255) NOT NULL,
-- Action details
action audit_action NOT NULL,
table_name VARCHAR(100) NOT NULL,
record_id UUID,
-- Change tracking
old_values JSONB,
new_values JSONB,
description TEXT,
-- Request metadata
ip_address INET,
user_agent TEXT,
-- Status
success BOOLEAN NOT NULL DEFAULT TRUE,
error_message TEXT,
-- Timestamp
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================================
-- INDEXES
-- ============================================================================
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
CREATE INDEX idx_audit_logs_table_name ON audit_logs(table_name);
CREATE INDEX idx_audit_logs_record_id ON audit_logs(record_id);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC);
CREATE INDEX idx_audit_logs_user_id_created_at ON audit_logs(user_id, created_at DESC);
CREATE INDEX idx_audit_logs_table_name_record_id ON audit_logs(table_name, record_id);
-- Index for JSON queries on old_values and new_values
CREATE INDEX idx_audit_logs_old_values ON audit_logs USING GIN (old_values);
CREATE INDEX idx_audit_logs_new_values ON audit_logs USING GIN (new_values);
-- ============================================================================
-- COMMENTS
-- ============================================================================
COMMENT ON TABLE audit_logs IS 'System audit log tracking all user actions and data changes';
COMMENT ON COLUMN audit_logs.user_id IS 'Reference to user who performed the action (nullable if user deleted)';
COMMENT ON COLUMN audit_logs.user_email IS 'Email snapshot at time of action';
COMMENT ON COLUMN audit_logs.user_name IS 'Name snapshot at time of action';
COMMENT ON COLUMN audit_logs.action IS 'Type of action performed';
COMMENT ON COLUMN audit_logs.table_name IS 'Database table affected by the action';
COMMENT ON COLUMN audit_logs.record_id IS 'ID of the specific record affected';
COMMENT ON COLUMN audit_logs.old_values IS 'JSON snapshot of values before change';
COMMENT ON COLUMN audit_logs.new_values IS 'JSON snapshot of values after change';
COMMENT ON COLUMN audit_logs.description IS 'Human-readable description of the action';
COMMENT ON COLUMN audit_logs.ip_address IS 'IP address of the user';
COMMENT ON COLUMN audit_logs.user_agent IS 'Browser/client user agent string';
COMMENT ON COLUMN audit_logs.success IS 'Whether the action completed successfully';
COMMENT ON COLUMN audit_logs.error_message IS 'Error message if action failed';
-- ============================================================================
-- HELPER FUNCTION: Get current user info from request context
-- ============================================================================
CREATE OR REPLACE FUNCTION get_current_user_info()
RETURNS TABLE (
user_id UUID,
user_email VARCHAR(255),
user_name VARCHAR(255)
) AS $$
BEGIN
-- This will be called from application code with current_setting
RETURN QUERY
SELECT
NULLIF(current_setting('app.current_user_id', true), '')::UUID,
NULLIF(current_setting('app.current_user_email', true), ''),
NULLIF(current_setting('app.current_user_name', true), '');
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================================
-- HELPER FUNCTION: Log audit entry
-- ============================================================================
CREATE OR REPLACE FUNCTION log_audit(
p_user_id UUID,
p_user_email VARCHAR(255),
p_user_name VARCHAR(255),
p_action audit_action,
p_table_name VARCHAR(100),
p_record_id UUID DEFAULT NULL,
p_old_values JSONB DEFAULT NULL,
p_new_values JSONB DEFAULT NULL,
p_description TEXT DEFAULT NULL,
p_ip_address INET DEFAULT NULL,
p_user_agent TEXT DEFAULT NULL,
p_success BOOLEAN DEFAULT TRUE,
p_error_message TEXT DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_log_id UUID;
BEGIN
INSERT INTO audit_logs (
user_id,
user_email,
user_name,
action,
table_name,
record_id,
old_values,
new_values,
description,
ip_address,
user_agent,
success,
error_message
) VALUES (
p_user_id,
p_user_email,
p_user_name,
p_action,
p_table_name,
p_record_id,
p_old_values,
p_new_values,
p_description,
p_ip_address,
p_user_agent,
p_success,
p_error_message
) RETURNING id INTO v_log_id;
RETURN v_log_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================================
-- VIEW: audit_logs_summary
-- ============================================================================
CREATE OR REPLACE VIEW audit_logs_summary AS
SELECT
al.id,
al.user_email,
al.user_name,
al.action,
al.table_name,
al.record_id,
al.description,
al.success,
al.created_at,
al.ip_address,
-- User reference (may be null if user deleted)
u.id AS current_user_id,
u.is_active AS user_is_active
FROM audit_logs al
LEFT JOIN users u ON al.user_id = u.id
ORDER BY al.created_at DESC;
COMMENT ON VIEW audit_logs_summary IS 'Audit logs with user status information';
-- ============================================================================
-- VIEW: audit_statistics
-- ============================================================================
CREATE OR REPLACE VIEW audit_statistics AS
SELECT
DATE(created_at) AS date,
action,
table_name,
COUNT(*) AS action_count,
COUNT(DISTINCT user_id) AS unique_users,
SUM(CASE WHEN success THEN 1 ELSE 0 END) AS successful_actions,
SUM(CASE WHEN NOT success THEN 1 ELSE 0 END) AS failed_actions
FROM audit_logs
GROUP BY DATE(created_at), action, table_name
ORDER BY date DESC, action_count DESC;
COMMENT ON VIEW audit_statistics IS 'Daily statistics of audit log actions';
-- ============================================================================
-- END OF MIGRATION
-- ============================================================================

View File

@@ -0,0 +1,124 @@
-- ============================================================================
-- Add extended fields to meters table
-- These fields store additional technical and operational data for meters
-- All fields are nullable (not required)
-- ============================================================================
-- Communication & Network Fields
ALTER TABLE meters ADD COLUMN IF NOT EXISTS protocol VARCHAR(50);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS mac VARCHAR(50);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS gateway VARCHAR(100);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS network_mode VARCHAR(50);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS phone_id VARCHAR(50);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS phone_model VARCHAR(100);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS phone_name VARCHAR(100);
-- Voltage & Power Fields
ALTER TABLE meters ADD COLUMN IF NOT EXISTS voltage DECIMAL(10, 2);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS voltage_rtu DECIMAL(10, 2);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS voltage_status VARCHAR(50);
-- Signal & Communication Quality
ALTER TABLE meters ADD COLUMN IF NOT EXISTS signal INTEGER;
-- Status Fields
ALTER TABLE meters ADD COLUMN IF NOT EXISTS storage_status VARCHAR(50);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS flow_status VARCHAR(50);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS open_status VARCHAR(50);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS actuator_status VARCHAR(50);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS counter_status VARCHAR(50);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS leakage_status VARCHAR(50);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS burst_status VARCHAR(50);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS valid_status VARCHAR(50);
-- Security & Alerts
ALTER TABLE meters ADD COLUMN IF NOT EXISTS magnetic_attack BOOLEAN;
ALTER TABLE meters ADD COLUMN IF NOT EXISTS realtime_information_flag BOOLEAN;
-- Flow Measurements
ALTER TABLE meters ADD COLUMN IF NOT EXISTS current_flow DECIMAL(15, 4);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS total_flow_reverse DECIMAL(15, 4);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS current_flow_reverse DECIMAL(15, 4);
-- Protocol Fields (M-Bus/LoRaWAN specific)
ALTER TABLE meters ADD COLUMN IF NOT EXISTS l_field VARCHAR(10);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS c_field VARCHAR(10);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS ver VARCHAR(10);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS dev VARCHAR(20);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS ci_field VARCHAR(10);
-- Company & Manufacturer Info
ALTER TABLE meters ADD COLUMN IF NOT EXISTS company_abbreviation VARCHAR(50);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS manufacturer VARCHAR(100);
-- Geolocation
ALTER TABLE meters ADD COLUMN IF NOT EXISTS latitude DECIMAL(10, 8);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS longitude DECIMAL(11, 8);
-- Additional Data (JSON for flexible data storage)
ALTER TABLE meters ADD COLUMN IF NOT EXISTS data JSONB;
-- ============================================================================
-- Add indexes for commonly queried fields
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_meters_protocol ON meters(protocol);
CREATE INDEX IF NOT EXISTS idx_meters_mac ON meters(mac);
CREATE INDEX IF NOT EXISTS idx_meters_gateway ON meters(gateway);
CREATE INDEX IF NOT EXISTS idx_meters_manufacturer ON meters(manufacturer);
CREATE INDEX IF NOT EXISTS idx_meters_flow_status ON meters(flow_status);
CREATE INDEX IF NOT EXISTS idx_meters_leakage_status ON meters(leakage_status);
CREATE INDEX IF NOT EXISTS idx_meters_geolocation ON meters(latitude, longitude) WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
-- ============================================================================
-- Add comments for documentation
-- ============================================================================
COMMENT ON COLUMN meters.protocol IS 'Communication protocol (LoRa, LoRaWAN, NB-IoT, etc.)';
COMMENT ON COLUMN meters.mac IS 'MAC address of the device';
COMMENT ON COLUMN meters.gateway IS 'Gateway identifier or name';
COMMENT ON COLUMN meters.network_mode IS 'Network operation mode';
COMMENT ON COLUMN meters.voltage IS 'Battery voltage (V)';
COMMENT ON COLUMN meters.voltage_rtu IS 'RTU voltage (V)';
COMMENT ON COLUMN meters.voltage_status IS 'Battery status (OK, LOW, CRITICAL)';
COMMENT ON COLUMN meters.signal IS 'Signal strength (RSSI or similar)';
COMMENT ON COLUMN meters.storage_status IS 'Internal storage status';
COMMENT ON COLUMN meters.flow_status IS 'Flow measurement status';
COMMENT ON COLUMN meters.leakage_status IS 'Leak detection status';
COMMENT ON COLUMN meters.burst_status IS 'Burst pipe detection status';
COMMENT ON COLUMN meters.magnetic_attack IS 'Magnetic tampering detected';
COMMENT ON COLUMN meters.realtime_information_flag IS 'Real-time data available';
COMMENT ON COLUMN meters.current_flow IS 'Current flow rate (m³/h or L/h)';
COMMENT ON COLUMN meters.total_flow_reverse IS 'Total reverse flow accumulated';
COMMENT ON COLUMN meters.current_flow_reverse IS 'Current reverse flow rate';
COMMENT ON COLUMN meters.l_field IS 'M-Bus L-Field (length)';
COMMENT ON COLUMN meters.c_field IS 'M-Bus C-Field (control)';
COMMENT ON COLUMN meters.ver IS 'Protocol version';
COMMENT ON COLUMN meters.dev IS 'Device type identifier';
COMMENT ON COLUMN meters.ci_field IS 'M-Bus CI-Field (control information)';
COMMENT ON COLUMN meters.latitude IS 'Latitude coordinate (WGS84)';
COMMENT ON COLUMN meters.longitude IS 'Longitude coordinate (WGS84)';
COMMENT ON COLUMN meters.data IS 'Additional flexible data storage (JSON)';
-- ============================================================================
-- Verify the changes
-- ============================================================================
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = 'meters'
AND column_name IN (
'protocol', 'mac', 'gateway', 'network_mode', 'phone_id', 'phone_model',
'phone_name', 'voltage', 'voltage_rtu', 'voltage_status', 'signal',
'storage_status', 'flow_status', 'open_status', 'actuator_status',
'counter_status', 'leakage_status', 'burst_status', 'valid_status',
'magnetic_attack', 'realtime_information_flag', 'current_flow',
'total_flow_reverse', 'current_flow_reverse', 'l_field', 'c_field',
'ver', 'dev', 'ci_field', 'company_abbreviation', 'manufacturer',
'latitude', 'longitude', 'data'
)
ORDER BY ordinal_position;

View File

@@ -0,0 +1,68 @@
-- ============================================================================
-- Add project_id column to meters table
-- This establishes the relationship between meters and projects
-- ============================================================================
-- Step 1: Add the column as nullable first (to allow existing data)
ALTER TABLE meters
ADD COLUMN IF NOT EXISTS project_id UUID;
-- Step 2: For existing meters without project_id, try to get it from their concentrator
UPDATE meters m
SET project_id = c.project_id
FROM concentrators c
WHERE m.concentrator_id = c.id
AND m.project_id IS NULL
AND m.concentrator_id IS NOT NULL;
-- Step 3: Add foreign key constraint
ALTER TABLE meters
DROP CONSTRAINT IF EXISTS meters_project_id_fkey;
ALTER TABLE meters
ADD CONSTRAINT meters_project_id_fkey
FOREIGN KEY (project_id)
REFERENCES projects(id)
ON DELETE CASCADE;
-- Step 4: Create index for better query performance
CREATE INDEX IF NOT EXISTS idx_meters_project_id ON meters(project_id);
-- Step 5: Add comment for documentation
COMMENT ON COLUMN meters.project_id IS 'Project to which this meter belongs';
-- ============================================================================
-- Verify the changes
-- ============================================================================
-- Check if the column was added
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = 'meters'
AND column_name = 'project_id';
-- Check the constraint
SELECT
conname AS constraint_name,
contype AS constraint_type,
pg_get_constraintdef(oid) AS constraint_definition
FROM pg_constraint
WHERE conrelid = 'meters'::regclass
AND conname LIKE '%project_id%';
-- Show meters with their project info
SELECT
m.id,
m.name,
m.serial_number,
m.project_id,
p.name AS project_name,
c.name AS concentrator_name
FROM meters m
LEFT JOIN projects p ON m.project_id = p.id
LEFT JOIN concentrators c ON m.concentrator_id = c.id
LIMIT 10;

View File

@@ -0,0 +1,39 @@
-- ============================================================================
-- Add Notifications Table
-- Migration for notification system supporting negative flow alerts
-- ============================================================================
-- Create notification type enum
CREATE TYPE notification_type AS ENUM ('NEGATIVE_FLOW', 'SYSTEM_ALERT', 'MAINTENANCE');
-- ============================================================================
-- TABLE: notifications
-- ============================================================================
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
meter_id UUID REFERENCES meters(id) ON DELETE SET NULL,
notification_type notification_type NOT NULL DEFAULT 'NEGATIVE_FLOW',
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
meter_serial_number VARCHAR(255),
flow_value DECIMAL(12, 4),
is_read BOOLEAN NOT NULL DEFAULT FALSE,
read_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Indexes for performance
CREATE INDEX idx_notifications_user_id ON notifications(user_id);
CREATE INDEX idx_notifications_meter_id ON notifications(meter_id);
CREATE INDEX idx_notifications_is_read ON notifications(is_read);
CREATE INDEX idx_notifications_created_at ON notifications(created_at DESC);
CREATE INDEX idx_notifications_user_unread ON notifications(user_id, is_read) WHERE is_read = FALSE;
COMMENT ON TABLE notifications IS 'User notifications for meter alerts and system events';
COMMENT ON COLUMN notifications.user_id IS 'User who receives this notification';
COMMENT ON COLUMN notifications.meter_id IS 'Related meter (nullable if meter is deleted)';
COMMENT ON COLUMN notifications.notification_type IS 'Type of notification';
COMMENT ON COLUMN notifications.flow_value IS 'Flow value if negative flow alert';
COMMENT ON COLUMN notifications.is_read IS 'Whether notification has been read by user';
COMMENT ON COLUMN notifications.read_at IS 'Timestamp when notification was marked as read';

View File

@@ -0,0 +1,66 @@
-- ============================================
-- Migration: Add Organismos Operadores (3-level hierarchy)
-- Admin → Organismo Operador → Operador
-- ============================================
-- 1. Add ORGANISMO_OPERADOR to role_name ENUM
-- NOTE: ALTER TYPE ADD VALUE cannot run inside a transaction block
ALTER TYPE role_name ADD VALUE IF NOT EXISTS 'ORGANISMO_OPERADOR';
-- 2. Create organismos_operadores table
CREATE TABLE IF NOT EXISTS organismos_operadores (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
region VARCHAR(255),
contact_name VARCHAR(255),
contact_email VARCHAR(255),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Add updated_at trigger
CREATE TRIGGER set_organismos_operadores_updated_at
BEFORE UPDATE ON organismos_operadores
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Index for active organismos
CREATE INDEX IF NOT EXISTS idx_organismos_operadores_active ON organismos_operadores (is_active);
-- 3. Add organismo_operador_id FK to projects table
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS organismo_operador_id UUID REFERENCES organismos_operadores(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_projects_organismo_operador_id ON projects (organismo_operador_id);
-- 4. Add organismo_operador_id FK to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS organismo_operador_id UUID REFERENCES organismos_operadores(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_users_organismo_operador_id ON users (organismo_operador_id);
-- 5. Insert ORGANISMO_OPERADOR role with permissions
INSERT INTO roles (name, description, permissions)
SELECT
'ORGANISMO_OPERADOR',
'Organismo operador que gestiona proyectos y operadores dentro de su jurisdicción',
'["projects:read", "projects:list", "concentrators:read", "concentrators:list", "meters:read", "meters:write", "meters:list", "readings:read", "readings:list", "users:read", "users:write", "users:list"]'::jsonb
WHERE NOT EXISTS (
SELECT 1 FROM roles WHERE name = 'ORGANISMO_OPERADOR'
);
-- 6. Migrate VIEWER users to OPERATOR role
UPDATE users
SET role_id = (SELECT id FROM roles WHERE name = 'OPERATOR' LIMIT 1)
WHERE role_id = (SELECT id FROM roles WHERE name = 'VIEWER' LIMIT 1);
-- 7. Seed example organismos operadores
INSERT INTO organismos_operadores (name, description, region, contact_name, contact_email)
SELECT 'CESPT', 'Comisión Estatal de Servicios Públicos de Tijuana', 'Tijuana, BC', 'Admin CESPT', 'admin@cespt.gob.mx'
WHERE NOT EXISTS (SELECT 1 FROM organismos_operadores WHERE name = 'CESPT');
INSERT INTO organismos_operadores (name, description, region, contact_name, contact_email)
SELECT 'XICALI', 'Organismo Operador de Mexicali', 'Mexicali, BC', 'Admin XICALI', 'admin@xicali.gob.mx'
WHERE NOT EXISTS (SELECT 1 FROM organismos_operadores WHERE name = 'XICALI');

View File

@@ -0,0 +1,11 @@
-- Add new fields to users table
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(20);
ALTER TABLE users ADD COLUMN IF NOT EXISTS street VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS city VARCHAR(100);
ALTER TABLE users ADD COLUMN IF NOT EXISTS state VARCHAR(100);
ALTER TABLE users ADD COLUMN IF NOT EXISTS zip_code VARCHAR(10);
-- Add new fields to meters table
ALTER TABLE meters ADD COLUMN IF NOT EXISTS address TEXT;
ALTER TABLE meters ADD COLUMN IF NOT EXISTS cespt_account VARCHAR(50);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS cadastral_key VARCHAR(50);

View File

@@ -0,0 +1,27 @@
-- ============================================================================
-- Add project_id to users table
-- This allows assigning a specific project to OPERATOR users
-- ADMIN users don't need a project assignment (can see all projects)
-- ============================================================================
-- Add project_id column to users table
ALTER TABLE users
ADD COLUMN project_id UUID REFERENCES projects(id) ON DELETE SET NULL;
-- Add index for better query performance
CREATE INDEX idx_users_project_id ON users(project_id);
-- Add comment
COMMENT ON COLUMN users.project_id IS 'Assigned project for OPERATOR users. NULL for ADMIN users who can access all projects.';
-- ============================================================================
-- Verify the change
-- ============================================================================
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = 'users'
AND column_name = 'project_id';

View File

@@ -0,0 +1,81 @@
-- ============================================================================
-- Create meter_types table and add relationship to projects
-- Meter types: LoRa, LoRaWAN, Grandes Consumidores
-- ============================================================================
-- Create meter_types table
CREATE TABLE IF NOT EXISTS meter_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) NOT NULL UNIQUE,
code VARCHAR(20) NOT NULL UNIQUE,
description TEXT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insert default meter types
INSERT INTO meter_types (name, code, description) VALUES
('LoRa', 'LORA', 'Medidores con tecnología LoRa'),
('LoRaWAN', 'LORAWAN', 'Medidores con tecnología LoRaWAN'),
('Grandes Consumidores', 'GRANDES', 'Medidores para grandes consumidores')
ON CONFLICT (code) DO NOTHING;
-- Add meter_type_id column to projects table
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS meter_type_id UUID REFERENCES meter_types(id) ON DELETE SET NULL;
-- Add index for better query performance
CREATE INDEX IF NOT EXISTS idx_projects_meter_type_id ON projects(meter_type_id);
-- Add comment
COMMENT ON TABLE meter_types IS 'Catalog of meter types (LoRa, LoRaWAN, Grandes Consumidores)';
COMMENT ON COLUMN projects.meter_type_id IS 'Default meter type for this project';
-- ============================================================================
-- Helper function to get meter type by code
-- ============================================================================
CREATE OR REPLACE FUNCTION get_meter_type_id(type_code VARCHAR)
RETURNS UUID AS $$
DECLARE
type_id UUID;
BEGIN
SELECT id INTO type_id FROM meter_types WHERE code = type_code AND is_active = true;
RETURN type_id;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- Update trigger for updated_at
-- ============================================================================
CREATE OR REPLACE FUNCTION update_meter_types_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_meter_types_updated_at
BEFORE UPDATE ON meter_types
FOR EACH ROW
EXECUTE FUNCTION update_meter_types_updated_at();
-- ============================================================================
-- Verify the changes
-- ============================================================================
-- Show meter types
SELECT id, name, code, description, is_active FROM meter_types ORDER BY code;
-- Show projects table structure
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = 'projects'
AND column_name = 'meter_type_id';

View File

@@ -0,0 +1,188 @@
import { Response } from 'express';
import { AuthenticatedRequest } from '../types';
import * as auditService from '../services/audit.service';
export async function getAuditLogs(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const {
userId,
action,
tableName,
recordId,
startDate,
endDate,
success,
page = '1',
limit = '50',
} = req.query;
const filters: auditService.AuditLogFilters = {
userId: userId as string,
action: action as auditService.AuditAction,
tableName: tableName as string,
recordId: recordId as string,
startDate: startDate ? new Date(startDate as string) : undefined,
endDate: endDate ? new Date(endDate as string) : undefined,
success: success === 'true' ? true : success === 'false' ? false : undefined,
page: parseInt(page as string, 10),
limit: parseInt(limit as string, 10),
};
const result = await auditService.getAuditLogs(filters);
const totalPages = Math.ceil(result.total / filters.limit!);
const hasNextPage = filters.page! < totalPages;
const hasPreviousPage = filters.page! > 1;
res.status(200).json({
success: true,
message: 'Audit logs retrieved successfully',
data: result.logs,
pagination: {
page: filters.page,
limit: filters.limit,
total: result.total,
totalPages,
hasNextPage,
hasPreviousPage,
},
});
} catch (error: any) {
console.error('Error fetching audit logs:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to fetch audit logs',
});
}
}
export async function getAuditLogById(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { id } = req.params;
const log = await auditService.getAuditLogById(id);
if (!log) {
res.status(404).json({
success: false,
error: 'Audit log not found',
});
return;
}
res.status(200).json({
success: true,
message: 'Audit log retrieved successfully',
data: log,
});
} catch (error: any) {
console.error('Error fetching audit log:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to fetch audit log',
});
}
}
export async function getAuditLogsForRecord(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, recordId } = req.params;
const logs = await auditService.getAuditLogsForRecord(tableName, recordId);
res.status(200).json({
success: true,
message: 'Audit logs retrieved successfully',
data: logs,
});
} catch (error: any) {
console.error('Error fetching audit logs for record:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to fetch audit logs for record',
});
}
}
export async function getAuditStatistics(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { days = '30' } = req.query;
const daysNum = parseInt(days as string, 10);
const stats = await auditService.getAuditStatistics(daysNum);
res.status(200).json({
success: true,
message: 'Audit statistics retrieved successfully',
data: stats,
});
} catch (error: any) {
console.error('Error fetching audit statistics:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to fetch audit statistics',
});
}
}
export async function getMyActivity(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const user = req.user;
if (!user) {
res.status(401).json({
success: false,
error: 'User not authenticated',
});
return;
}
const { page = '1', limit = '50' } = req.query;
const filters: auditService.AuditLogFilters = {
userId: user.userId,
page: parseInt(page as string, 10),
limit: parseInt(limit as string, 10),
};
const result = await auditService.getAuditLogs(filters);
const totalPages = Math.ceil(result.total / filters.limit!);
const hasNextPage = filters.page! < totalPages;
const hasPreviousPage = filters.page! > 1;
res.status(200).json({
success: true,
message: 'Your activity logs retrieved successfully',
data: result.logs,
pagination: {
page: filters.page,
limit: filters.limit,
total: result.total,
totalPages,
hasNextPage,
hasPreviousPage,
},
});
} catch (error: any) {
console.error('Error fetching user activity:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to fetch activity logs',
});
}
}

View File

@@ -1,5 +1,5 @@
import { Request, Response } from 'express';
import { AuthenticatedRequest } from '../middleware/auth.middleware';
import type { AuthenticatedRequest } from '../types';
import * as authService from '../services/auth.service';
import { LoginInput, RefreshInput } from '../validators/auth.validator';
@@ -76,7 +76,7 @@ export async function refresh(req: Request, res: Response): Promise<void> {
*/
export async function logout(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.id;
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({ success: false, error: 'Authentication required' });
@@ -105,7 +105,7 @@ export async function logout(req: AuthenticatedRequest, res: Response): Promise<
*/
export async function getMe(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.id;
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({ success: false, error: 'Authentication required' });
@@ -114,7 +114,6 @@ export async function getMe(req: AuthenticatedRequest, res: Response): Promise<v
const profile = await authService.getMe(userId);
// Transform avatarUrl to avatar_url for frontend compatibility
res.status(200).json({
success: true,
data: {
@@ -123,6 +122,7 @@ export async function getMe(req: AuthenticatedRequest, res: Response): Promise<v
name: profile.name,
role: profile.role,
avatar_url: profile.avatarUrl,
project_id: profile.projectId,
},
});
} catch (error) {

View File

@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import { AuthenticatedRequest } from '../types';
import * as concentratorService from '../services/concentrator.service';
import { CreateConcentratorInput, UpdateConcentratorInput } from '../validators/concentrator.validator';
@@ -7,13 +8,14 @@ import { CreateConcentratorInput, UpdateConcentratorInput } from '../validators/
* Get all concentrators with optional filters and pagination
* Query params: project_id, status, page, limit, sortBy, sortOrder
*/
export async function getAll(req: Request, res: Response): Promise<void> {
export async function getAll(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { project_id, status, page, limit, sortBy, sortOrder } = req.query;
const { project_id, status, type, page, limit, sortBy, sortOrder } = req.query;
const filters: concentratorService.ConcentratorFilters = {};
if (project_id) filters.project_id = project_id as string;
if (status) filters.status = status as string;
if (type) filters.type = type as concentratorService.ConcentratorType;
const pagination: concentratorService.PaginationOptions = {
page: page ? parseInt(page as string, 10) : 1,
@@ -22,7 +24,14 @@ export async function getAll(req: Request, res: Response): Promise<void> {
sortOrder: sortOrder as 'asc' | 'desc',
};
const result = await concentratorService.getAll(filters, pagination);
// Pass user info for role-based filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
projectId: req.user.projectId,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const result = await concentratorService.getAll(filters, pagination, requestingUser);
res.status(200).json({
success: true,

View File

@@ -8,7 +8,7 @@ import * as readingService from '../services/reading.service';
* List all meters with pagination and optional filtering
* Query params: page, pageSize, concentrator_id, project_id, status, type, search
*/
export async function getAll(req: Request, res: Response): Promise<void> {
export async function getAll(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const page = parseInt(req.query.page as string, 10) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string, 10) || 50, 1000);
@@ -35,7 +35,14 @@ export async function getAll(req: Request, res: Response): Promise<void> {
filters.search = req.query.search as string;
}
const result = await meterService.getAll(filters, { page, pageSize });
// Pass user info for role-based filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
projectId: req.user.projectId,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const result = await meterService.getAll(filters, { page, pageSize }, requestingUser);
res.status(200).json({
success: true,
@@ -89,7 +96,7 @@ export async function getById(req: Request, res: Response): Promise<void> {
*/
export async function create(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.id;
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
@@ -237,7 +244,7 @@ export async function deleteMeter(req: AuthenticatedRequest, res: Response): Pro
* Get meter readings history with optional date range filter
* Query params: start_date, end_date, page, pageSize
*/
export async function getReadings(req: Request, res: Response): Promise<void> {
export async function getReadings(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
@@ -267,7 +274,14 @@ export async function getReadings(req: Request, res: Response): Promise<void> {
filters.end_date = req.query.end_date as string;
}
const result = await readingService.getAll(filters, { page, pageSize });
// Pass user info for role-based filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
projectId: req.user.projectId,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const result = await readingService.getAll(filters, { page, pageSize }, requestingUser);
res.status(200).json({
success: true,

View File

@@ -0,0 +1,185 @@
import { Request, Response } from 'express';
import * as meterTypeService from '../services/meterType.service';
/**
* GET /meter-types
* Get all active meter types
*/
export async function getAll(req: Request, res: Response): Promise<void> {
try {
const meterTypes = await meterTypeService.getAll();
res.status(200).json({
success: true,
data: meterTypes,
});
} catch (error) {
console.error('Error fetching meter types:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch meter types',
});
}
}
/**
* GET /meter-types/:id
* Get a single meter type by ID
*/
export async function getById(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const meterType = await meterTypeService.getById(id);
if (!meterType) {
res.status(404).json({
success: false,
error: 'Meter type not found',
});
return;
}
res.status(200).json({
success: true,
data: meterType,
});
} catch (error) {
console.error('Error fetching meter type:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch meter type',
});
}
}
/**
* GET /meter-types/code/:code
* Get a meter type by code
*/
export async function getByCode(req: Request, res: Response): Promise<void> {
try {
const { code } = req.params;
const meterType = await meterTypeService.getByCode(code);
if (!meterType) {
res.status(404).json({
success: false,
error: 'Meter type not found',
});
return;
}
res.status(200).json({
success: true,
data: meterType,
});
} catch (error) {
console.error('Error fetching meter type:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch meter type',
});
}
}
/**
* POST /meter-types
* Create a new meter type (ADMIN only)
*/
export async function create(req: Request, res: Response): Promise<void> {
try {
const { name, code, description } = req.body;
if (!name || !code) {
res.status(400).json({
success: false,
error: 'Name and code are required',
});
return;
}
const meterType = await meterTypeService.create({
name,
code,
description,
});
res.status(201).json({
success: true,
data: meterType,
});
} catch (error) {
console.error('Error creating meter type:', error);
res.status(500).json({
success: false,
error: 'Failed to create meter type',
});
}
}
/**
* PUT /meter-types/:id
* Update a meter type (ADMIN only)
*/
export async function update(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const { name, code, description, is_active } = req.body;
const meterType = await meterTypeService.update(id, {
name,
code,
description,
is_active,
});
if (!meterType) {
res.status(404).json({
success: false,
error: 'Meter type not found',
});
return;
}
res.status(200).json({
success: true,
data: meterType,
});
} catch (error) {
console.error('Error updating meter type:', error);
res.status(500).json({
success: false,
error: 'Failed to update meter type',
});
}
}
/**
* DELETE /meter-types/:id
* Soft delete a meter type (ADMIN only)
*/
export async function remove(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const success = await meterTypeService.remove(id);
if (!success) {
res.status(404).json({
success: false,
error: 'Meter type not found',
});
return;
}
res.status(200).json({
success: true,
message: 'Meter type deleted successfully',
});
} catch (error) {
console.error('Error deleting meter type:', error);
res.status(500).json({
success: false,
error: 'Failed to delete meter type',
});
}
}

View File

@@ -0,0 +1,233 @@
import { Response } from 'express';
import { AuthenticatedRequest } from '../middleware/auth.middleware';
import * as notificationService from '../services/notification.service';
import { NotificationFilter } from '../types';
/**
* GET /api/notifications
* List all notifications for the authenticated user with pagination
* Query params: page, limit, is_read, notification_type, start_date, end_date
*/
export async function getAll(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
if (!req.user?.userId) {
res.status(401).json({
success: false,
error: 'Unauthorized',
});
return;
}
const page = parseInt(req.query.page as string, 10) || 1;
const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100);
const filters: NotificationFilter = {};
if (req.query.is_read !== undefined) {
filters.is_read = req.query.is_read === 'true';
}
if (req.query.notification_type) {
filters.notification_type = req.query.notification_type as 'NEGATIVE_FLOW' | 'SYSTEM_ALERT' | 'MAINTENANCE';
}
if (req.query.start_date) {
filters.start_date = new Date(req.query.start_date as string);
}
if (req.query.end_date) {
filters.end_date = new Date(req.query.end_date as string);
}
const result = await notificationService.getAllForUser(
req.user.userId,
filters,
{ page, limit }
);
res.status(200).json({
success: true,
data: result.notifications,
pagination: result.pagination,
});
} catch (error) {
console.error('Error fetching notifications:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch notifications',
});
}
}
/**
* GET /api/notifications/unread-count
* Get count of unread notifications for the authenticated user
*/
export async function getUnreadCount(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
if (!req.user?.userId) {
res.status(401).json({
success: false,
error: 'Unauthorized',
});
return;
}
const count = await notificationService.getUnreadCount(req.user.userId);
res.status(200).json({
success: true,
data: { count },
});
} catch (error) {
console.error('Error fetching unread count:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch unread count',
});
}
}
/**
* GET /api/notifications/:id
* Get a single notification by ID
*/
export async function getById(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
if (!req.user?.userId) {
res.status(401).json({
success: false,
error: 'Unauthorized',
});
return;
}
const { id } = req.params;
const notification = await notificationService.getById(id, req.user.userId);
if (!notification) {
res.status(404).json({
success: false,
error: 'Notification not found',
});
return;
}
res.status(200).json({
success: true,
data: notification,
});
} catch (error) {
console.error('Error fetching notification:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch notification',
});
}
}
/**
* PATCH /api/notifications/:id/read
* Mark a notification as read
*/
export async function markAsRead(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
if (!req.user?.userId) {
res.status(401).json({
success: false,
error: 'Unauthorized',
});
return;
}
const { id } = req.params;
const notification = await notificationService.markAsRead(id, req.user.userId);
if (!notification) {
res.status(404).json({
success: false,
error: 'Notification not found',
});
return;
}
res.status(200).json({
success: true,
data: notification,
});
} catch (error) {
console.error('Error marking notification as read:', error);
res.status(500).json({
success: false,
error: 'Failed to mark notification as read',
});
}
}
/**
* PATCH /api/notifications/read-all
* Mark all notifications as read for the authenticated user
*/
export async function markAllAsRead(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
if (!req.user?.userId) {
res.status(401).json({
success: false,
error: 'Unauthorized',
});
return;
}
const count = await notificationService.markAllAsRead(req.user.userId);
res.status(200).json({
success: true,
data: { count },
message: `Marked ${count} notification(s) as read`,
});
} catch (error) {
console.error('Error marking all notifications as read:', error);
res.status(500).json({
success: false,
error: 'Failed to mark all notifications as read',
});
}
}
/**
* DELETE /api/notifications/:id
* Delete a notification
*/
export async function deleteNotification(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
if (!req.user?.userId) {
res.status(401).json({
success: false,
error: 'Unauthorized',
});
return;
}
const { id } = req.params;
const deleted = await notificationService.deleteNotification(id, req.user.userId);
if (!deleted) {
res.status(404).json({
success: false,
error: 'Notification not found',
});
return;
}
res.status(200).json({
success: true,
message: 'Notification deleted successfully',
});
} catch (error) {
console.error('Error deleting notification:', error);
res.status(500).json({
success: false,
error: 'Failed to delete notification',
});
}
}

View File

@@ -0,0 +1,186 @@
import { Request, Response } from 'express';
import * as organismoService from '../services/organismo-operador.service';
/**
* GET /organismos-operadores
* List all organismos operadores
*/
export async function getAll(req: Request, res: Response): Promise<void> {
try {
const page = parseInt(req.query.page as string, 10) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string, 10) || 50, 100);
const result = await organismoService.getAll({ page, pageSize });
res.status(200).json({
success: true,
data: result.data,
pagination: result.pagination,
});
} catch (error) {
console.error('Error fetching organismos:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch organismos operadores',
});
}
}
/**
* GET /organismos-operadores/:id
* Get a single organismo by ID
*/
export async function getById(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const organismo = await organismoService.getById(id);
if (!organismo) {
res.status(404).json({
success: false,
error: 'Organismo operador not found',
});
return;
}
res.status(200).json({
success: true,
data: organismo,
});
} catch (error) {
console.error('Error fetching organismo:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch organismo operador',
});
}
}
/**
* POST /organismos-operadores
* Create a new organismo operador (ADMIN only)
*/
export async function create(req: Request, res: Response): Promise<void> {
try {
const data = req.body as organismoService.CreateOrganismoInput;
const organismo = await organismoService.create(data);
res.status(201).json({
success: true,
data: organismo,
});
} catch (error) {
console.error('Error creating organismo:', error);
res.status(500).json({
success: false,
error: 'Failed to create organismo operador',
});
}
}
/**
* PUT /organismos-operadores/:id
* Update an organismo operador (ADMIN only)
*/
export async function update(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const data = req.body as organismoService.UpdateOrganismoInput;
const organismo = await organismoService.update(id, data);
if (!organismo) {
res.status(404).json({
success: false,
error: 'Organismo operador not found',
});
return;
}
res.status(200).json({
success: true,
data: organismo,
});
} catch (error) {
console.error('Error updating organismo:', error);
res.status(500).json({
success: false,
error: 'Failed to update organismo operador',
});
}
}
/**
* DELETE /organismos-operadores/:id
* Delete an organismo operador (ADMIN only)
*/
export async function remove(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const deleted = await organismoService.remove(id);
if (!deleted) {
res.status(404).json({
success: false,
error: 'Organismo operador not found',
});
return;
}
res.status(200).json({
success: true,
data: { message: 'Organismo operador deleted successfully' },
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete organismo operador';
if (message.includes('Cannot delete')) {
res.status(409).json({
success: false,
error: message,
});
return;
}
console.error('Error deleting organismo:', error);
res.status(500).json({
success: false,
error: 'Failed to delete organismo operador',
});
}
}
/**
* GET /organismos-operadores/:id/projects
* Get projects belonging to an organismo
*/
export async function getProjects(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const organismo = await organismoService.getById(id);
if (!organismo) {
res.status(404).json({
success: false,
error: 'Organismo operador not found',
});
return;
}
const projects = await organismoService.getProjectsByOrganismo(id);
res.status(200).json({
success: true,
data: projects,
});
} catch (error) {
console.error('Error fetching organismo projects:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch organismo projects',
});
}
}

View File

@@ -8,7 +8,7 @@ import { CreateProjectInput, UpdateProjectInput, ProjectStatusType } from '../va
* List all projects with pagination and optional filtering
* Query params: page, pageSize, status, area_name, search
*/
export async function getAll(req: Request, res: Response): Promise<void> {
export async function getAll(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const page = parseInt(req.query.page as string, 10) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string, 10) || 10, 100);
@@ -27,7 +27,14 @@ export async function getAll(req: Request, res: Response): Promise<void> {
filters.search = req.query.search as string;
}
const result = await projectService.getAll(filters, { page, pageSize });
// Pass user info for role-based filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
projectId: req.user.projectId,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const result = await projectService.getAll(filters, { page, pageSize }, requestingUser);
res.status(200).json({
success: true,
@@ -81,7 +88,7 @@ export async function getById(req: Request, res: Response): Promise<void> {
*/
export async function create(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.id;
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
@@ -225,3 +232,37 @@ export async function getStats(req: Request, res: Response): Promise<void> {
});
}
}
/**
* POST /projects/:id/deactivate
* Deactivate a project and unassign users
* Requires authentication
*/
export async function deactivateProject(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const project = await projectService.getById(id);
if (!project) {
res.status(404).json({
success: false,
error: 'Project not found',
});
return;
}
const deactivatedProject = await projectService.deactivateProjectAndUnassignUsers(id);
res.status(200).json({
success: true,
data: deactivatedProject,
});
} catch (error) {
console.error('Error deactivating project:', error);
res.status(500).json({
success: false,
error: 'Failed to deactivate project',
});
}
}

View File

@@ -1,11 +1,12 @@
import { Request, Response } from 'express';
import { AuthenticatedRequest } from '../middleware/auth.middleware';
import * as readingService from '../services/reading.service';
/**
* GET /readings
* List all readings with pagination and filtering
*/
export async function getAll(req: Request, res: Response): Promise<void> {
export async function getAll(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const {
page = '1',
@@ -31,7 +32,14 @@ export async function getAll(req: Request, res: Response): Promise<void> {
pageSize: Math.min(parseInt(pageSize as string, 10), 100), // Max 100 per page
};
const result = await readingService.getAll(filters, pagination);
// Pass user info for role-based filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
projectId: req.user.projectId,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const result = await readingService.getAll(filters, pagination, requestingUser);
res.status(200).json({
success: true,
@@ -136,12 +144,20 @@ export async function deleteReading(req: Request, res: Response): Promise<void>
* GET /readings/summary
* Get consumption summary statistics
*/
export async function getSummary(req: Request, res: Response): Promise<void> {
export async function getSummary(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { project_id } = req.query;
// Pass user info for role-based filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
projectId: req.user.projectId,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const summary = await readingService.getConsumptionSummary(
project_id as string | undefined
project_id as string | undefined,
requestingUser
);
res.status(200).json({

View File

@@ -1,5 +1,5 @@
import { Response } from 'express';
import { AuthenticatedRequest } from '../middleware/auth.middleware';
import { AuthenticatedRequest } from '../types';
import * as roleService from '../services/role.service';
import { CreateRoleInput, UpdateRoleInput } from '../validators/role.validator';
@@ -37,9 +37,10 @@ export async function getRoleById(
res: Response
): Promise<void> {
try {
const roleId = parseInt(req.params.id, 10);
const roleId = req.params.id;
if (isNaN(roleId)) {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(roleId)) {
res.status(400).json({
success: false,
error: 'Invalid role ID',
@@ -120,9 +121,10 @@ export async function updateRole(
res: Response
): Promise<void> {
try {
const roleId = parseInt(req.params.id, 10);
const roleId = req.params.id;
if (isNaN(roleId)) {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(roleId)) {
res.status(400).json({
success: false,
error: 'Invalid role ID',
@@ -178,9 +180,10 @@ export async function deleteRole(
res: Response
): Promise<void> {
try {
const roleId = parseInt(req.params.id, 10);
const roleId = req.params.id;
if (isNaN(roleId)) {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(roleId)) {
res.status(400).json({
success: false,
error: 'Invalid role ID',

View File

@@ -0,0 +1,352 @@
import { Request, Response } from 'express';
import { triggerNegativeFlowDetection } from '../jobs/negativeFlowDetection';
import { AuthenticatedRequest } from '../middleware/auth.middleware';
import { query } from '../config/database';
/**
* POST /api/test/create-negative-flow-meter
* Create a test meter with negative flow for testing notifications
* Body (optional): { flowValue: number } - Defaults to -25.75
*/
export async function createTestMeterWithNegativeFlow(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
// Get custom flow value from request body, default to -25.75
const { flowValue } = req.body as { flowValue?: number };
const negativeFlowValue = flowValue !== undefined ? flowValue : -25.75;
// Ensure the value is negative
if (negativeFlowValue >= 0) {
res.status(400).json({
success: false,
error: 'Flow value must be negative',
message: 'Please provide a negative flow value (e.g., -25.75)',
});
return;
}
console.log(`🧪 [Test] Creating test meter with negative flow: ${negativeFlowValue}`);
// Get first active concentrator
const concentratorResult = await query(`
SELECT id, project_id
FROM concentrators
WHERE status = 'ACTIVE'
LIMIT 1
`);
if (concentratorResult.rows.length === 0) {
res.status(400).json({
success: false,
error: 'No active concentrators found',
message: 'Please create a concentrator first before creating test meter',
});
return;
}
const concentrator = concentratorResult.rows[0];
// Check if test meter already exists
const existingMeterResult = await query(`
SELECT id FROM meters WHERE serial_number = 'TEST-NEGATIVE-001'
`);
let meterId: string;
let action: string;
if (existingMeterResult.rows.length > 0) {
// Update existing test meter
meterId = existingMeterResult.rows[0].id;
action = 'updated';
await query(`
UPDATE meters
SET
last_reading_value = $1,
last_reading_at = CURRENT_TIMESTAMP,
status = 'ACTIVE',
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
`, [negativeFlowValue, meterId]);
console.log(`✅ [Test] Test meter updated: ${meterId} with flow: ${negativeFlowValue}`);
} else {
// Create new test meter
action = 'created';
const createResult = await query(`
INSERT INTO meters (
serial_number,
meter_id,
name,
concentrator_id,
location,
type,
status,
last_reading_value,
last_reading_at,
installation_date,
created_at,
updated_at
) VALUES (
'TEST-NEGATIVE-001',
'TEST-NEG-001',
'Test Meter - Negative Flow',
$1,
'Test Location - Building A',
'LORA',
'ACTIVE',
$2,
CURRENT_TIMESTAMP,
'2024-01-01',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
) RETURNING id
`, [concentrator.id, negativeFlowValue]);
meterId = createResult.rows[0].id;
console.log(`✅ [Test] Test meter created: ${meterId} with flow: ${negativeFlowValue}`);
}
// Get the created/updated meter details
const meterDetails = await query(`
SELECT
m.id,
m.serial_number,
m.name,
m.last_reading_value,
m.status,
m.concentrator_id,
c.project_id
FROM meters m
JOIN concentrators c ON c.id = m.concentrator_id
WHERE m.id = $1
`, [meterId]);
res.status(200).json({
success: true,
message: `Test meter ${action} successfully with flow value: ${negativeFlowValue}`,
data: {
action,
meter: meterDetails.rows[0],
instructions: {
next_step: 'Trigger the notification job',
endpoint: 'POST /api/test/trigger-negative-flow',
},
},
});
} catch (error) {
console.error('❌ [Test] Error creating test meter:', error);
res.status(500).json({
success: false,
error: 'Failed to create test meter',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
/**
* PUT /api/test/update-meter-flow/:meterId
* Update a meter's last_reading_value for testing
* Body: { flowValue: number }
*/
export async function updateMeterFlow(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { meterId } = req.params;
const { flowValue } = req.body as { flowValue: number };
if (flowValue === undefined || flowValue === null) {
res.status(400).json({
success: false,
error: 'Flow value is required',
message: 'Please provide a flowValue in the request body',
});
return;
}
console.log(`🧪 [Test] Updating meter ${meterId} with flow value: ${flowValue}`);
// Update meter flow value
const updateResult = await query(`
UPDATE meters
SET
last_reading_value = $1,
last_reading_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
RETURNING id, serial_number, name, last_reading_value, status
`, [flowValue, meterId]);
if (updateResult.rows.length === 0) {
res.status(404).json({
success: false,
error: 'Meter not found',
message: `No meter found with ID: ${meterId}`,
});
return;
}
const meter = updateResult.rows[0];
console.log(`✅ [Test] Meter ${meterId} updated with flow: ${flowValue}`);
res.status(200).json({
success: true,
message: `Meter flow updated successfully to ${flowValue}`,
data: {
meter,
isNegative: flowValue < 0,
instructions: flowValue < 0 ? {
next_step: 'This meter now has negative flow. Trigger the notification job.',
endpoint: 'POST /api/test/trigger-negative-flow',
} : {
next_step: 'This meter has positive flow and will not trigger notifications.',
},
},
});
} catch (error) {
console.error('❌ [Test] Error updating meter flow:', error);
res.status(500).json({
success: false,
error: 'Failed to update meter flow',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
/**
* POST /api/test/trigger-negative-flow
* Manually trigger the negative flow detection job for testing
* This endpoint simulates what the cron job does at 1:00 AM
*/
export async function triggerNegativeFlowJob(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
console.log('🧪 [Test] Manually triggering negative flow detection...');
await triggerNegativeFlowDetection();
res.status(200).json({
success: true,
message: 'Negative flow detection job triggered successfully',
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error('❌ [Test] Error triggering negative flow detection:', error);
res.status(500).json({
success: false,
error: 'Failed to trigger negative flow detection',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
/**
* GET /api/test/negative-flow-meters
* Get list of meters with negative flow for testing
*/
export async function getNegativeFlowMeters(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { getMetersWithNegativeFlow } = await import('../services/notification.service');
const meters = await getMetersWithNegativeFlow();
res.status(200).json({
success: true,
data: {
count: meters.length,
meters: meters,
},
});
} catch (error) {
console.error('❌ [Test] Error fetching negative flow meters:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch negative flow meters',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
/**
* GET /api/test/notifications-info
* Get information about the notification system
*/
export async function getNotificationsInfo(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
// Count notifications by type
const typeCountResult = await query(`
SELECT notification_type, COUNT(*) as count
FROM notifications
GROUP BY notification_type
`);
// Count unread notifications
const unreadResult = await query(`
SELECT COUNT(*) as count
FROM notifications
WHERE is_read = false
`);
// Recent notifications
const recentResult = await query(`
SELECT *
FROM notifications
ORDER BY created_at DESC
LIMIT 10
`);
res.status(200).json({
success: true,
data: {
totalUnread: parseInt(unreadResult.rows[0].count, 10),
byType: typeCountResult.rows,
recentNotifications: recentResult.rows,
},
});
} catch (error) {
console.error('❌ [Test] Error fetching notifications info:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch notifications info',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
/**
* DELETE /api/test/cleanup-test-data
* Clean up test meter and notifications
*/
export async function cleanupTestData(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
console.log('🧹 [Test] Cleaning up test data...');
// Delete notifications for test meter
const notificationsResult = await query(`
DELETE FROM notifications
WHERE meter_serial_number = 'TEST-NEGATIVE-001'
RETURNING id
`);
// Delete test meter
const meterResult = await query(`
DELETE FROM meters
WHERE serial_number = 'TEST-NEGATIVE-001'
RETURNING id
`);
res.status(200).json({
success: true,
message: 'Test data cleaned up successfully',
data: {
notifications_deleted: notificationsResult.rows.length,
meters_deleted: meterResult.rows.length,
},
});
} catch (error) {
console.error('❌ [Test] Error cleaning up test data:', error);
res.status(500).json({
success: false,
error: 'Failed to clean up test data',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}

View File

@@ -41,7 +41,13 @@ export async function getAllUsers(
sortOrder: (req.query.sortOrder as 'asc' | 'desc') || 'desc',
};
const result = await userService.getAll(filters, pagination);
// Pass requesting user for scope filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const result = await userService.getAll(filters, pagination, requestingUser);
res.status(200).json({
success: true,
@@ -79,8 +85,8 @@ export async function getUserById(
// Check if user is admin or requesting their own data
const requestingUser = req.user;
const isAdmin = requestingUser?.role === 'ADMIN';
const isSelf = requestingUser?.id === userId;
const isAdmin = requestingUser?.roleName === 'ADMIN';
const isSelf = requestingUser?.userId === userId;
if (!isAdmin && !isSelf) {
res.status(403).json({
@@ -125,12 +131,20 @@ export async function createUser(
try {
const data = req.body as CreateUserInput;
// If ORGANISMO_OPERADOR is creating a user, force their own organismo_operador_id
let organismoOperadorId = data.organismo_operador_id;
if (req.user?.roleName === 'ORGANISMO_OPERADOR' && req.user?.organismoOperadorId) {
organismoOperadorId = req.user.organismoOperadorId;
}
const user = await userService.create({
email: data.email,
password: data.password,
name: data.name,
avatar_url: data.avatar_url,
role_id: data.role_id,
project_id: data.project_id,
organismo_operador_id: organismoOperadorId,
is_active: data.is_active,
});
@@ -177,8 +191,8 @@ export async function updateUser(
}
const requestingUser = req.user;
const isAdmin = requestingUser?.role === 'ADMIN';
const isSelf = requestingUser?.id === userId;
const isAdmin = requestingUser?.roleName === 'ADMIN';
const isSelf = requestingUser?.userId === userId;
if (!isAdmin && !isSelf) {
res.status(403).json({
@@ -254,7 +268,7 @@ export async function deleteUser(
}
// Prevent admin from deleting themselves
if (req.user?.id === userId) {
if (req.user?.userId === userId) {
res.status(400).json({
success: false,
error: 'Cannot deactivate your own account',
@@ -305,7 +319,7 @@ export async function changePassword(
}
// Only allow users to change their own password
if (req.user?.id !== userId) {
if (req.user?.userId !== userId) {
res.status(403).json({
success: false,
error: 'You can only change your own password',

View File

@@ -7,6 +7,8 @@ import helmet from 'helmet';
import routes from './routes';
import logger from './utils/logger';
import { testConnection } from './config/database';
import { auditMiddleware } from './middleware/audit.middleware';
import { scheduleNegativeFlowDetection } from './jobs/negativeFlowDetection';
const app: Application = express();
@@ -42,6 +44,9 @@ app.use(cors({
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Audit logging middleware (before routes)
app.use(auditMiddleware);
// Health check endpoint
app.get('/health', (_req: Request, res: Response) => {
res.status(200).json({
@@ -81,6 +86,9 @@ const startServer = async () => {
await testConnection();
logger.info('Database connection established');
scheduleNegativeFlowDetection();
logger.info('Cron jobs initialized');
app.listen(PORT, () => {
logger.info(`Server running on port ${PORT} in ${NODE_ENV} mode`);
logger.info(`Health check available at http://localhost:${PORT}/health`);

View File

@@ -0,0 +1,181 @@
import cron from 'node-cron';
import * as notificationService from '../services/notification.service';
/**
* Cron job that runs three times daily at 1:00 AM, 1:15 AM, and 1:30 AM PST
* to detect meters with negative flow and create notifications for responsible users
*
* Timezone: America/Los_Angeles (Pacific Standard Time)
* 1:00 AM PST = 3:00 AM CST (Jalisco, Mexico)
* 1:15 AM PST = 3:15 AM CST (Jalisco, Mexico)
* 1:30 AM PST = 3:30 AM CST (Jalisco, Mexico)
*/
export function scheduleNegativeFlowDetection(): void {
// Schedule: Every day at 1:00 AM, 1:15 AM, and 1:30 AM Pacific Standard Time
// Cron format: minute hour day-of-month month day-of-week
// '0,15,30 1 * * *' = At 01:00, 01:15, and 01:30 (1:00 AM, 1:15 AM, 1:30 AM) every day
cron.schedule('0,15,30 1 * * *', async () => {
console.log('🔍 [Cron] Starting negative flow detection job...');
const startTime = Date.now();
try {
// Get all meters with negative flow values
const negativeFlowMeters = await notificationService.getMetersWithNegativeFlow();
if (negativeFlowMeters.length === 0) {
console.log('✅ [Cron] No meters with negative flow found');
return;
}
console.log(`⚠️ [Cron] Found ${negativeFlowMeters.length} meter(s) with negative flow`);
let notificationsCreated = 0;
let errors = 0;
// Group meters by project to avoid duplicate notifications
const metersByProject = new Map<string, typeof negativeFlowMeters>();
for (const meter of negativeFlowMeters) {
const projectId = meter.project_id;
if (!metersByProject.has(projectId)) {
metersByProject.set(projectId, []);
}
metersByProject.get(projectId)!.push(meter);
}
// Create notifications for each project's users
for (const [projectId, meters] of metersByProject.entries()) {
try {
// Get users responsible for this project
const userIds = await notificationService.getUsersForProject(projectId);
if (userIds.length === 0) {
console.log(`⚠️ [Cron] No users found for project ${projectId}`);
continue;
}
// Create notification for each meter for each user
for (const meter of meters) {
const title = 'Negative Flow Alert';
const message = `${meter.name} (${meter.serial_number}) has negative flow of ${meter.last_reading_value} units`;
for (const userId of userIds) {
try {
await notificationService.create({
user_id: userId,
meter_id: meter.id,
notification_type: 'NEGATIVE_FLOW',
title,
message,
meter_serial_number: meter.serial_number,
flow_value: meter.last_reading_value,
});
notificationsCreated++;
} catch (error) {
console.error(`❌ [Cron] Error creating notification for user ${userId}, meter ${meter.id}:`, error);
errors++;
}
}
}
} catch (error) {
console.error(`❌ [Cron] Error processing project ${projectId}:`, error);
errors++;
}
}
const duration = Date.now() - startTime;
console.log(
`✅ [Cron] Negative flow detection completed in ${duration}ms:`,
`${notificationsCreated} notification(s) created,`,
`${errors} error(s)`
);
// Clean up old read notifications (optional maintenance task)
try {
const deletedCount = await notificationService.deleteOldReadNotifications();
if (deletedCount > 0) {
console.log(`🗑️ [Cron] Cleaned up ${deletedCount} old read notification(s)`);
}
} catch (error) {
console.error('❌ [Cron] Error cleaning up old notifications:', error);
}
} catch (error) {
console.error('❌ [Cron] Fatal error in negative flow detection job:', error);
}
}, {
timezone: 'America/Los_Angeles' // Pacific Standard Time (PST/PDT)
});
console.log('⏰ [Cron] Negative flow detection job scheduled (3 times daily at PST):');
console.log(' • 1:00 AM PST (3:00 AM CST)');
console.log(' • 1:15 AM PST (3:15 AM CST)');
console.log(' • 1:30 AM PST (3:30 AM CST)');
console.log(' Timezone: America/Los_Angeles (Pacific Time)');
}
/**
* Manual trigger for testing the negative flow detection
* Can be called directly for testing purposes
*/
export async function triggerNegativeFlowDetection(): Promise<void> {
console.log('🔍 [Manual] Starting negative flow detection...');
const startTime = Date.now();
try {
const negativeFlowMeters = await notificationService.getMetersWithNegativeFlow();
if (negativeFlowMeters.length === 0) {
console.log('✅ [Manual] No meters with negative flow found');
return;
}
console.log(`⚠️ [Manual] Found ${negativeFlowMeters.length} meter(s) with negative flow`);
let notificationsCreated = 0;
// Group meters by project
const metersByProject = new Map<string, typeof negativeFlowMeters>();
for (const meter of negativeFlowMeters) {
const projectId = meter.project_id;
if (!metersByProject.has(projectId)) {
metersByProject.set(projectId, []);
}
metersByProject.get(projectId)!.push(meter);
}
// Create notifications
for (const [projectId, meters] of metersByProject.entries()) {
const userIds = await notificationService.getUsersForProject(projectId);
for (const meter of meters) {
const title = 'Negative Flow Alert';
const message = `${meter.name} (${meter.serial_number}) has negative flow of ${meter.last_reading_value} units`;
for (const userId of userIds) {
await notificationService.create({
user_id: userId,
meter_id: meter.id,
notification_type: 'NEGATIVE_FLOW',
title,
message,
meter_serial_number: meter.serial_number,
flow_value: meter.last_reading_value,
});
notificationsCreated++;
}
}
}
const duration = Date.now() - startTime;
console.log(`✅ [Manual] Created ${notificationsCreated} notification(s) in ${duration}ms`);
} catch (error) {
console.error('❌ [Manual] Error in negative flow detection:', error);
throw error;
}
}

Some files were not shown because too many files have changed in this diff Show More