Compare commits

..

61 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
196f7a53b3 Fix: Comment out unused ReadingRow interface in bulk-upload service 2026-01-26 19:49:29 -06:00
2d977b13b4 Convert user and role IDs from number to UUID string
Updated backend and frontend to use UUID strings instead of integers for user and role IDs
to match PostgreSQL database schema (UUID type).

Backend changes:
- Updated User and UserPublic interfaces: id and role_id now string (UUID)
- Updated JwtPayload: userId and roleId now string
- Updated user validators: role_id validation changed from number to UUID string
- Removed parseInt() calls in user controller (getUserById, updateUser, deleteUser, changePassword)
- Updated all user service function signatures to accept string IDs
- Updated create() and update() functions to accept role_id as string

Frontend changes:
- Updated User interface in users API: role_id is string
- Updated CreateUserInput and UpdateUserInput: role_id is string
- Added role filter in UsersPage sidebar
- Removed number conversion logic (parseInt)

This fixes the "invalid input syntax for type uuid" error when creating/updating users.
2026-01-26 19:49:15 -06:00
c910ce8996 Fix user schema to match database structure
Updated backend to use single 'name' field instead of 'first_name' and 'last_name'
to match the actual database schema where users table has a 'name' column.

Changes:
- Updated User and UserPublic interfaces to use 'name' and 'avatar_url'
- Updated user validators to use 'name' instead of first/last names
- Updated all SQL queries in user.service.ts to select u.name
- Updated search filters and sort columns
- Fixed user creation and update operations

This resolves the "column u.first_name does not exist" error.
2026-01-26 11:45:30 -06:00
6d25f5103b Add users and roles API integration to UsersPage
Created API modules for users and roles management:
- Added src/api/roles.ts with getAllRoles, getRoleById, createRole, etc.
- Added src/api/users.ts with getAllUsers, createUser, updateUser, etc.

Updated UsersPage to fetch data from backend:
- Fetch roles from /api/roles endpoint on mount
- Fetch users from /api/users endpoint on mount
- Integrated createUser API call with form submission
- Added proper validation and error handling
- Split name field into firstName and lastName for API compatibility
- Added loading states and refresh functionality
2026-01-26 11:45:01 -06:00
137 changed files with 24558 additions and 2331 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)
│ 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) │
│ 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 │
├─────────────────────────────────────────────────────────────┤
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

605
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
│ ├── 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
│ │ │ ├── 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

68
src/api/roles.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* Roles API
* Handles all role-related API requests
*/
import { apiClient } from './client';
export interface Role {
id: string;
name: string;
description: string;
permissions: Record<string, Record<string, boolean>>;
created_at: string;
updated_at: string;
}
export interface RoleListResponse {
success: boolean;
message: string;
data: Role[];
}
/**
* Get all roles
*/
export async function getAllRoles(): Promise<Role[]> {
const response = await apiClient.get<Role[]>('/api/roles');
return response;
}
/**
* Get a single role by ID
*/
export async function getRoleById(id: string): Promise<Role> {
return apiClient.get<Role>(`/api/roles/${id}`);
}
/**
* Create a new role
*/
export async function createRole(data: {
name: string;
description: string;
permissions?: Record<string, Record<string, boolean>>;
}): Promise<Role> {
return apiClient.post<Role>('/api/roles', data);
}
/**
* Update an existing role
*/
export async function updateRole(
id: string,
data: {
name?: string;
description?: string;
permissions?: Record<string, Record<string, boolean>>;
}
): Promise<Role> {
return apiClient.put<Role>(`/api/roles/${id}`, data);
}
/**
* Delete a role
*/
export async function deleteRole(id: string): Promise<void> {
return apiClient.delete<void>(`/api/roles/${id}`);
}

128
src/api/users.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* Users API
* Handles all user-related API requests
*/
import { apiClient } from './client';
export interface User {
id: string;
email: string;
name: string;
avatar_url: string | null;
role_id: string;
role?: {
id: string;
name: string;
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;
}
export interface CreateUserInput {
email: string;
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 {
current_password: string;
new_password: string;
}
export interface UserListResponse {
data: User[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
/**
* Get all users with optional filters and pagination
*/
export async function getAllUsers(params?: {
role_id?: number;
is_active?: boolean;
search?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}): Promise<UserListResponse> {
return apiClient.get<UserListResponse>('/api/users', { params });
}
/**
* Get a single user by ID
*/
export async function getUserById(id: string): Promise<User> {
return apiClient.get<User>(`/api/users/${id}`);
}
/**
* Create a new user
*/
export async function createUser(data: CreateUserInput): Promise<User> {
return apiClient.post<User>('/api/users', data);
}
/**
* Update an existing user
*/
export async function updateUser(id: string, data: UpdateUserInput): Promise<User> {
return apiClient.put<User>(`/api/users/${id}`, data);
}
/**
* Delete (deactivate) a user
*/
export async function deleteUser(id: string): Promise<void> {
return apiClient.delete<void>(`/api/users/${id}`);
}
/**
* Change user password
*/
export async function changePassword(id: string, data: ChangePasswordInput): Promise<void> {
return apiClient.put<void>(`/api/users/${id}/password`, data);
}

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,11 +125,33 @@ 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 */}
{/* USERS MANAGEMENT - ADMIN and ORGANISMO_OPERADOR */}
{(isAdmin || isOrganismo) && (
<li>
<button
onClick={() => isExpanded && setUsersOpen(!usersOpen)}
@@ -145,6 +178,8 @@ export default function Sidebar({ setPage }: SidebarProps) {
Users
</button>
</li>
{/* Roles - ADMIN only */}
{isAdmin && (
<li>
<button
onClick={() => setPage("roles")}
@@ -153,9 +188,120 @@ export default function Sidebar({ setPage }: SidebarProps) {
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"}
>
{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) => ({
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]
);
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) ================= */
@@ -206,10 +341,10 @@ export default function Home({
{/* ✅ 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">
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">
Sistema de Tomas de Agua
</h1>
<p className="text-gray-600 mt-2">
<p className="text-gray-600 dark:text-zinc-300 mt-2">
Monitorea, administra y controla tus operaciones en un solo lugar.
</p>
</div>
@@ -218,7 +353,7 @@ export default function Home({
<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"
className="relative z-0 h-20 w-auto opacity-80 select-none pointer-events-none shrink-0"
draggable={false}
/>
</div>
@@ -226,40 +361,55 @@ export default function Home({
{/* 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">
{/* 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">Organismos Operadores</p>
<p className="text-xs text-gray-400">
<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">{selectedOrganism}</span>
<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"
@@ -267,10 +417,11 @@ export default function Home({
>
Organismos Operadores
</button>
)}
</div>
{showOrganisms && (
<div className="fixed inset-0 z-30">
{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"
@@ -281,22 +432,21 @@ export default function Home({
/>
{/* 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,36 +606,55 @@ 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>
{/* 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>
{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
@@ -454,17 +670,43 @@ export default function Home({
</BarChart>
</ResponsiveContainer>
</div>
{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>
)}
</>
)}
</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">
{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">
<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>
@@ -473,22 +715,39 @@ export default function Home({
</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">
{(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">
<span>
{a.company} - {a.type}
<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>
<span className="text-red-500 font-medium">{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,131 +41,135 @@ 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="fixed inset-0 w-screen h-screen font-sans">
{/* Imagen de fondo - agua */}
<div
className="absolute left-0 top-0 h-[3px] w-full opacity-90"
className="absolute inset-0 w-full h-full bg-cover bg-center"
style={{
background:
"linear-gradient(90deg, transparent, rgba(86,107,184,0.9), rgba(76,95,158,0.9), transparent)",
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>
</div>
</section>
{/* 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">
{/* 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-20 w-20 object-contain rounded-lg"
className="h-18 w-18 object-contain"
/>
<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>
<span className="text-white text-4xl font-bold tracking-tight drop-shadow-lg">
GRH
</span>
</div>
<form onSubmit={onSubmit} className="mt-8 space-y-6">
<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-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
{serverError}
</div>
)}
{/* Email */}
<div>
<label className="block text-sm font-medium text-slate-700">
<label className="block text-sm font-medium text-slate-700 mb-1.5">
Correo electrónico
</label>
<div className="relative mt-2">
<div className="relative">
<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"
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-1 top-1/2 -translate-y-1/2 text-slate-500"
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400"
size={18}
/>
</div>
{errors.email && (
<p className="mt-1 text-xs text-red-600">
{errors.email}
</p>
<p className="mt-1.5 text-xs text-red-600">{errors.email}</p>
)}
</div>
{/* Contraseña */}
<div>
<label className="block text-sm font-medium text-slate-700">
<label className="block text-sm font-medium text-slate-700 mb-1.5">
Contraseña
</label>
<div className="relative mt-2">
<div className="relative">
<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"
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-8 top-1/2 -translate-y-1/2 text-slate-500"
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-1 top-1/2 -translate-y-1/2 text-slate-500"
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400"
size={18}
/>
</div>
{errors.password && (
<p className="mt-1 text-xs text-red-600">
{errors.password}
</p>
<p className="mt-1.5 text-xs text-red-600">{errors.password}</p>
)}
</div>
@@ -174,22 +177,20 @@ export default function LoginPage({ onSuccess }: LoginPageProps) {
<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"
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={18} />
Entrando...
<Loader2 className="animate-spin" size={20} />
Ingresando...
</>
) : (
"Iniciar sesión"
"Ingresar"
)}
</button>
</form>
</div>
</div>
</section>
</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 = () => {
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);
}
};
const handleSave = async () => {
if (!form.name.trim()) {
setError("Role name is required");
return;
}
setLoading(true);
setError(null);
try {
if (editingId) {
setRoles(prev => prev.map(r => r.id === editingId ? { id: editingId, ...form } : r));
const updated = await updateRole(editingId, form);
setRoles(prev => prev.map(r => r.id === editingId ? updated : r));
} else {
const newId = Date.now().toString();
setRoles(prev => [...prev, { id: newId, ...form }]);
const created = await createRole(form);
setRoles(prev => [...prev, created]);
}
setShowModal(false);
setEditingId(null);
setForm(emptyRole);
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 = () => {
const handleDelete = async () => {
if (!activeRole) return;
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,52 +152,112 @@ 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..."
<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)} />
onChange={e => setSearch(e.target.value)}
/>
{/* TABLE */}
<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" },
{ title: "Description", field: "description" },
{
title: "Status",
field: "status",
title: "Name",
field: "name",
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>
<span className="font-medium text-gray-900">{rowData.name}</span>
)
},
{ title: "Created", field: "createdAt", type: "date" }
{
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)}
@@ -120,30 +265,100 @@ export default function RolesPage() {
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" })
rowStyle: (rowData) => ({
backgroundColor: activeRole?.id === (rowData as Role).id ? "#EEF2FF" : "#FFFFFF",
cursor: "pointer"
})
}}
/>
)}
</div>
</div>
{/* ADD/EDIT 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-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>
{/* 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}
<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>
<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>
</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,7 +1,16 @@
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 { Role } from "./RolesPage"; // Importa los tipos de roles
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;
name: string;
}
interface User {
id: string;
@@ -9,62 +18,383 @@ interface User {
email: string;
roleId: string;
roleName: string;
projectId: string | null;
organismoOperadorId: string | null;
organismoName: string | null;
status: "ACTIVE" | "INACTIVE";
createdAt: string;
}
interface UserForm {
name: string;
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 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 userRole = useMemo(() => getCurrentUserRole(), []);
const userOrganismoId = useMemo(() => getCurrentUserOrganismoId(), []);
const isAdmin = userRole?.toUpperCase() === 'ADMIN';
const isOrganismo = userRole?.toUpperCase() === 'ORGANISMO_OPERADOR';
const initialUsers: User[] = [
{ id: "1", name: "Admin GRH", email: "grh@domain.com", roleId: "1", roleName: "SUPER_ADMIN", status: "ACTIVE", createdAt: "2025-12-17" },
{ id: "2", name: "User CESPT", email: "cespt@domain.com", roleId: "2", roleName: "USER", status: "ACTIVE", createdAt: "2025-12-16" },
];
const [users, setUsers] = useState<User[]>(initialUsers);
const [users, setUsers] = useState<User[]>([]);
const [activeUser, setActiveUser] = useState<User | null>(null);
const [search, setSearch] = useState("");
const [selectedRoleFilter, setSelectedRoleFilter] = useState<string>("");
const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [roles, setRoles] = useState<Role[]>(initialRoles);
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: Omit<User, "id" | "roleName"> = { name: "", email: "", roleId: "", status: "ACTIVE", createdAt: new Date().toISOString().slice(0,10) };
const [form, setForm] = useState<Omit<User, "id" | "roleName">>(emptyUser);
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 handleSave = () => {
const roleName = roles.find(r => r.id === form.roleId)?.name || "";
if (editingId) {
setUsers(prev => prev.map(u => u.id === editingId ? { id: editingId, roleName, ...form } : u));
} else {
const newId = Date.now().toString();
setUsers(prev => [...prev, { id: newId, roleName, ...form }]);
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();
}, []);
const fetchUsers = async () => {
try {
setLoadingUsers(true);
const usersResponse = await getAllUsers();
const mappedUsers: User[] = usersResponse.data.map((apiUser: ApiUser) => ({
id: apiUser.id,
name: apiUser.name,
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)
}));
setUsers(mappedUsers);
const uniqueRolesMap = new Map<string, RoleOption>();
usersResponse.data.forEach((apiUser: ApiUser) => {
if (apiUser.role) {
uniqueRolesMap.set(apiUser.role.id, {
id: apiUser.role.id,
name: apiUser.role.name
});
}
});
const uniqueRoles = Array.from(uniqueRolesMap.values());
setRoles(uniqueRoles);
} catch (error) {
console.error('Failed to fetch users:', error);
setUsers([]);
setRoles([]);
} finally {
setLoadingUsers(false);
}
};
const handleSave = async () => {
setError(null);
if (!form.name || !form.email || !form.roleId) {
setError("Please fill in all required fields");
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;
}
if (form.password && form.password.length < 8) {
setError("Password must be at least 8 characters");
return;
}
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);
} else {
const createData: CreateUserInput = {
email: form.email,
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);
}
await handleRefresh();
setShowModal(false);
setEditingId(null);
setForm(emptyUser);
} catch (err) {
console.error('Failed to save user:', err);
setError(err instanceof Error ? err.message : 'Failed to save user');
} finally {
setSaving(false);
}
};
const handleDelete = () => {
const handleRefresh = async () => {
await fetchUsers();
};
const handleDelete = async () => {
if (!activeUser) return;
setUsers(prev => prev.filter(u => u.id !== activeUser.id));
if (!window.confirm(`Are you sure you want to delete user "${activeUser.name}"?`)) {
return;
}
try {
setSaving(true);
await deleteUser(activeUser.id);
await handleRefresh();
setActiveUser(null);
} catch (error) {
console.error('Failed to delete user:', error);
alert('Failed to delete user. Please try again.');
} finally {
setSaving(false);
}
};
const filtered = users.filter(u => u.name.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase()));
const fetchModalRoles = async () => {
try {
setLoadingModalRoles(true);
const rolesData = await getAllRoles();
// 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 {
setLoadingModalRoles(false);
}
};
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 = 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: "",
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
const filtered = users.filter(u => {
const matchesSearch = u.name.toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase());
const matchesRole = !selectedRoleFilter || u.roleId === selectedRoleFilter;
return matchesSearch && matchesRole;
});
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">Project Information</h3>
<p className="text-sm text-gray-700">Usuarios disponibles y sus roles.</p>
<select value={form.roleId} onChange={e => setForm({...form, roleId: e.target.value})} className="w-full border px-3 py-2 rounded mt-2">
<option value="">Select Role</option>
<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 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
disabled={loadingUsers}
>
<option value="">All Roles</option>
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
{selectedRoleFilter && (
<button
onClick={() => setSelectedRoleFilter("")}
className="mt-2 text-xs text-blue-600 hover:text-blue-800"
>
Clear filter
</button>
)}
</div>
{/* MAIN */}
@@ -77,48 +407,201 @@ export default function UsersPage() {
<p className="text-sm text-blue-100">Usuarios registrados</p>
</div>
<div className="flex gap-3">
<button onClick={() => { setForm(emptyUser); setEditingId(null); setShowModal(true); }} className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg"><Plus size={16} /> Add</button>
<button onClick={() => { if(!activeUser) return; setEditingId(activeUser.id); setForm({...activeUser}); setShowModal(true); }} disabled={!activeUser} className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"><Pencil size={16}/> Edit</button>
<button onClick={handleDelete} disabled={!activeUser} className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"><Trash2 size={16}/> Delete</button>
<button onClick={() => setUsers([...users])} className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg"><RefreshCcw size={16}/> Refresh</button>
<button onClick={handleOpenAddModal} className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg"><Plus size={16} /> Add</button>
<button onClick={() => {
if(!activeUser) return;
handleOpenEditModal(activeUser);
}} disabled={!activeUser} className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"><Pencil size={16}/> Edit</button>
<button onClick={handleDelete} disabled={!activeUser || saving} className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"><Trash2 size={16}/> Delete</button>
<button onClick={handleRefresh} className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg" disabled={loadingUsers}><RefreshCcw size={16}/> Refresh</button>
</div>
</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 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
title="Users"
columns={[
{ 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>
<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="Email" value={form.email} onChange={e => setForm({...form, email: e.target.value})} />
<select value={form.roleId} onChange={e => setForm({...form, roleId: e.target.value})} className="w-full border px-3 py-2 rounded mt-2">
<option value="">Select Role</option>
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
<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">
{error}
</div>
)}
<input
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})}
disabled={saving}
/>
<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 *"
value={form.email}
onChange={e => setForm({...form, email: 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="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 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 || ""}
onChange={e => setForm({...form, password: e.target.value})}
disabled={saving}
/>
)}
{/* Role selector */}
<select
value={form.roleId}
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>
<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})} />
{/* 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 dark:border-zinc-700 dark:text-zinc-100"
disabled={saving}
>
Status: {form.status}
</button>
<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>
<button
onClick={() => { setShowModal(false); setError(null); }}
className="px-4 py-2 dark:text-zinc-300"
disabled={saving}
>
Cancel
</button>
<button
onClick={handleSave}
className="bg-[#4c5f9e] text-white px-4 py-2 rounded disabled:opacity-50"
disabled={saving || loadingModalRoles}
>
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
</div>

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("");
}
// 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 = () => {
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,17 +17,22 @@ export default function MetersTable({
isLoading,
isMockMode,
selectedProject,
takeType,
activeMeter,
onRowClick,
}: Props) {
}: MetersTableProps) {
const disabled = isMockMode || !selectedProject;
return (
<div className={disabled ? "opacity-60 pointer-events-none" : ""}>
<MaterialTable
title="Meters"
isLoading={isLoading}
columns={[
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 || "-" },
@@ -73,14 +78,72 @@ export default function MetersTable({
},
{ 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={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,13 +200,16 @@ export default function ProjectsPage() {
</div>
<div className="flex gap-3">
{(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>
)}
{(isAdmin || isOrganismo) && (
<button
onClick={openEditModal}
disabled={!activeProject}
@@ -150,7 +217,9 @@ export default function ProjectsPage() {
>
<Pencil size={16} /> Editar
</button>
)}
{(isAdmin || isOrganismo) && (
<button
onClick={handleDelete}
disabled={!activeProject}
@@ -158,6 +227,7 @@ export default function ProjectsPage() {
>
<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,
@@ -67,9 +73,9 @@ export async function getUserById(
res: Response
): Promise<void> {
try {
const userId = parseInt(req.params.id, 10);
const userId = req.params.id;
if (isNaN(userId)) {
if (!userId) {
res.status(400).json({
success: false,
error: 'Invalid user ID',
@@ -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.toString();
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,
first_name: data.first_name,
last_name: data.last_name,
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,
});
@@ -166,9 +180,9 @@ export async function updateUser(
res: Response
): Promise<void> {
try {
const userId = parseInt(req.params.id, 10);
const userId = req.params.id;
if (isNaN(userId)) {
if (!userId) {
res.status(400).json({
success: false,
error: 'Invalid user ID',
@@ -177,8 +191,8 @@ export async function updateUser(
}
const requestingUser = req.user;
const isAdmin = requestingUser?.role === 'ADMIN';
const isSelf = requestingUser?.id === userId.toString();
const isAdmin = requestingUser?.roleName === 'ADMIN';
const isSelf = requestingUser?.userId === userId;
if (!isAdmin && !isSelf) {
res.status(403).json({
@@ -243,9 +257,9 @@ export async function deleteUser(
res: Response
): Promise<void> {
try {
const userId = parseInt(req.params.id, 10);
const userId = req.params.id;
if (isNaN(userId)) {
if (!userId) {
res.status(400).json({
success: false,
error: 'Invalid user ID',
@@ -254,7 +268,7 @@ export async function deleteUser(
}
// Prevent admin from deleting themselves
if (req.user?.id === userId.toString()) {
if (req.user?.userId === userId) {
res.status(400).json({
success: false,
error: 'Cannot deactivate your own account',
@@ -294,9 +308,9 @@ export async function changePassword(
res: Response
): Promise<void> {
try {
const userId = parseInt(req.params.id, 10);
const userId = req.params.id;
if (isNaN(userId)) {
if (!userId) {
res.status(400).json({
success: false,
error: 'Invalid user ID',
@@ -305,7 +319,7 @@ export async function changePassword(
}
// Only allow users to change their own password
if (req.user?.id !== userId.toString()) {
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`);

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