Compare commits

...

64 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
Exteban08
6c7d448b2f Fix: Corregir pantalla blanca y mejorar carga masiva
- Fix error .toFixed() con valores DECIMAL de PostgreSQL (string vs number)
- Fix modal de carga masiva que se cerraba sin mostrar resultados
- Validar fechas antes de insertar en BD (evita error con "Installed")
- Agregar mapeos de columnas comunes (device_status, device_name, etc.)
- Normalizar valores de status (Installed -> ACTIVE, New_LoRa -> ACTIVE)
- Actualizar documentación del proyecto

Archivos modificados:
- src/pages/meters/MetersTable.tsx
- src/pages/consumption/ConsumptionPage.tsx
- src/pages/meters/MeterPage.tsx
- water-api/src/services/bulk-upload.service.ts
- ESTADO_ACTUAL.md
- CAMBIOS_SESION.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 23:13:48 +00:00
Exteban08
ab97987c6a Agregar carga masiva de lecturas y corregir manejo de respuestas paginadas
- Implementar carga masiva de lecturas via Excel (backend y frontend)
- Corregir cliente API para manejar respuestas con paginación
- Eliminar referencias a device_id (columna inexistente)
- Cambiar areaName por meterLocation en lecturas
- Actualizar fetchProjects y fetchConcentrators para paginación
- Agregar documentación del estado actual y cambios

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:23:41 +00:00
Exteban08
c81a18987f Migrar backend a PostgreSQL + Node.js/Express con nuevas funcionalidades
Backend (water-api/):
- Crear API REST completa con Express + TypeScript
- Implementar autenticación JWT con refresh tokens
- CRUD completo para: projects, concentrators, meters, gateways, devices, users, roles
- Agregar validación con Zod para todas las entidades
- Implementar webhooks para The Things Stack (LoRaWAN)
- Agregar endpoint de lecturas con filtros y resumen de consumo
- Implementar carga masiva de medidores via Excel (.xlsx)

Frontend:
- Crear cliente HTTP con manejo automático de JWT y refresh
- Actualizar todas las APIs para usar nuevo backend
- Agregar sistema de autenticación real (login, logout, me)
- Agregar selector de tipo (LORA, LoRaWAN, Grandes) en concentradores y medidores
- Agregar campo Meter ID en medidores
- Crear modal de carga masiva para medidores
- Agregar página de consumo con gráficas y filtros
- Corregir carga de proyectos independiente de datos existentes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:13:26 +00:00
181 changed files with 39154 additions and 3497 deletions

202
CAMBIOS_SESION.md Normal file
View File

@@ -0,0 +1,202 @@
# Historial de Cambios - Proyecto GRH
Registro cronologico de cambios significativos realizados al proyecto.
---
## 2026-02-09: Organismos Operadores + Historico de Tomas + Documentacion
### 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.
### Nuevas Funcionalidades
**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
Aproximadamente 50+ archivos en 12 commits.
---
## 2026-01-23: Fix pantalla blanca y carga masiva
### Resumen
Correccion de errores criticos que causaban pantalla blanca y mejoras en el sistema de carga masiva.
### Problema 1: Pantalla Blanca en Water Meters y Consumo
**Sintoma:** Al navegar a "Water Meters" o "Consumo", la pagina se quedaba en blanco.
**Causa:** PostgreSQL devuelve valores DECIMAL como strings (ej: `"300.0000"`). El codigo llamaba `.toFixed()` directamente sobre estos strings.
**Solucion:** Convertir a numero con `Number()` antes de `.toFixed()`.
**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
**Sintoma:** El modal se cerraba automaticamente despues de la carga sin mostrar resultados.
**Causa:** El callback `onSuccess` cerraba el modal automaticamente.
**Solucion:** Separar recarga de datos (`onSuccess`) del cierre del modal (`onClose`).
**Archivo:** `src/pages/meters/MeterPage.tsx:332-340`
### Problema 3: Error de Fecha Invalida en Carga Masiva
**Sintoma:** Error `invalid input syntax for type date: "Installed"` al subir medidores.
**Causa:** Columnas con valores como "Installed" o "New_LoRa" se interpretaban como fechas.
**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.)
**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` | Validacion de fechas, mapeos, normalizacion |
### 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

329
ESTADO_ACTUAL.md Normal file
View File

@@ -0,0 +1,329 @@
# Estado Actual del Proyecto GRH
**Fecha:** 2026-02-09
**Ultima actualizacion:** Documentacion actualizada para reflejar el estado completo del proyecto
---
## Resumen del Proyecto
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
### Jerarquia de datos:
```
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 SPA) │
│ http://localhost:5173 │
├─────────────────────────────────────────────────────────────┤
│ 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 (Express) │
│ http://localhost:3000 │
├─────────────────────────────────────────────────────────────┤
│ 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) │
└──────────────────────────┬──────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ PostgreSQL │
├─────────────────────────────────────────────────────────────┤
│ 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 │
└─────────────────────────────────────────────────────────────┘
```
---
## Funcionalidades Implementadas
### 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. 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, COMPLETED
- Estadisticas por proyecto (medidores, lecturas, areas)
### 4. Gestion de Concentradores
- CRUD completo
- Vinculados a proyectos
- Estado: ACTIVE, INACTIVE, OFFLINE, MAINTENANCE, ERROR
- IP, firmware, ultima comunicacion
### 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
### 6. Consumo y Lecturas
- CRUD de lecturas
- Tipos: AUTOMATIC, MANUAL, SCHEDULED
- 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
### 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 / CSV)
Columnas requeridas:
- `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` - Ubicacion
- `type` - LORA, LORAWAN, GRANDES (default: LORA)
- `status` - ACTIVE, INACTIVE, etc. (default: ACTIVE)
- `installation_date` - Fecha de instalacion (YYYY-MM-DD)
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
Columnas opcionales:
- `reading_type` - AUTOMATIC, MANUAL, SCHEDULED (default: MANUAL)
- `received_at` - Fecha/hora (default: ahora)
- `battery_level` - Nivel de bateria (%)
- `signal_strength` - Intensidad de senal (dBm)
---
## Datos en Base de Datos
### Proyectos
- ADAMANT
- OLE
- LUZIA
- ATELIER
### Concentradores
| Serial | Nombre | Proyecto |
|--------|--------|----------|
| 2024072612 | Adamant | ADAMANT |
| 2024030601 | OLE | OLE |
| 2024030402 | LUZIA | LUZIA |
| 2024072602 | ATELIER | ATELIER |
### Medidores
- ADAMANT: 201 medidores
- OLE: 5 medidores
---
## Historial de Correcciones
### 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.
### 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
### 2026-02-04: Favicon y conectores
- Actualizacion de favicon
- Mejoras en tiempo de ultima conexion de conectores
- Plan de implementacion para rol ORGANISMOS_OPERADORES
### 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
### 2026-02-05: Sincronizacion de conectores
- Cambio de hora de sincronizacion de 2:00 AM a 9:00 AM
---
## Comandos Utiles
```bash
# Iniciar backend (desarrollo)
cd /home/GRH/water-project/water-api
npm run dev
# Iniciar frontend (desarrollo)
cd /home/GRH/water-project
npm run dev
# 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
# Ejecutar schema de base de datos
psql -d water_project -f water-api/sql/schema.sql
```
---
## Proximos Pasos Sugeridos
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

@@ -8,6 +8,16 @@ import ConcentratorsPage from "./pages/concentrators/ConcentratorsPage";
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";
@@ -18,7 +28,15 @@ import SettingsModal, {
import LoginPage from "./pages/LoginPage";
// ✅ NUEVO
// Auth imports
import {
isAuthenticated,
getMe,
logout as authLogout,
clearAuth,
type AuthUser,
} from "./api/auth";
import ConfirmModal from "./components/layout/common/ConfirmModal";
import Watermark from "./components/layout/common/Watermark";
@@ -27,28 +45,68 @@ export type Page =
| "projects"
| "meters"
| "concentrators"
| "consumption"
| "auditoria"
| "users"
| "roles";
const AUTH_KEY = "grh_auth";
| "roles"
| "sh-meters"
| "xmeters"
| "tts"
| "analytics-map"
| "analytics-reports"
| "analytics-server"
| "organismos"
| "historico";
export default function App() {
const [isAuth, setIsAuth] = useState<boolean>(() => {
return Boolean(localStorage.getItem(AUTH_KEY));
});
const [isAuth, setIsAuth] = useState<boolean>(false);
const [user, setUser] = useState<AuthUser | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const handleLogin = (payload?: { token?: string }) => {
localStorage.setItem(
AUTH_KEY,
JSON.stringify({ token: payload?.token ?? "demo", ts: Date.now() })
);
// Check authentication on mount
useEffect(() => {
const checkAuth = async () => {
if (isAuthenticated()) {
try {
const userData = await getMe();
setUser(userData);
setIsAuth(true);
} catch {
clearAuth();
setIsAuth(false);
setUser(null);
}
}
setAuthLoading(false);
};
checkAuth();
}, []);
const handleLogin = () => {
// After successful login, fetch user data
const fetchUser = async () => {
try {
const userData = await getMe();
setUser(userData);
setIsAuth(true);
} catch {
clearAuth();
setIsAuth(false);
}
};
fetchUser();
};
const handleLogout = () => {
localStorage.removeItem(AUTH_KEY);
const handleLogout = async () => {
try {
await authLogout();
} catch {
// Ignore logout errors
}
clearAuth();
setUser(null);
setIsAuth(false);
// opcional: reset de navegación
// Reset navigation
setPage("home");
setSubPage("default");
setSelectedProject("");
@@ -65,13 +123,6 @@ export default function App() {
const [profileOpen, setProfileOpen] = useState(false);
const [savingProfile, setSavingProfile] = useState(false);
const [user, setUser] = useState({
name: "CESPT Admin",
email: "admin@cespt.gob.mx",
avatarUrl: null as string | null,
organismName: "CESPT",
});
const [settingsOpen, setSettingsOpen] = useState(false);
const [settings, setSettings] = useState<AppSettings>(() => loadSettings());
@@ -84,7 +135,7 @@ export default function App() {
const handleUploadAvatar = async (file: File) => {
const base64 = await fileToBase64(file);
localStorage.setItem("mock_avatar", base64);
setUser((prev) => ({ ...prev, avatarUrl: base64 }));
setUser((prev) => prev ? { ...prev, avatar_url: base64 } : null);
};
function fileToBase64(file: File) {
@@ -101,18 +152,17 @@ export default function App() {
email: string;
organismName?: string;
}) => {
if (!user) return;
setSavingProfile(true);
try {
const updated = await updateMyProfile(next);
setUser((prev) => ({
setUser((prev) => prev ? ({
...prev,
name: updated.name ?? next.name ?? prev.name,
email: updated.email ?? next.email ?? prev.email,
avatarUrl: updated.avatarUrl ?? prev.avatarUrl,
organismName:
updated.organismName ?? next.organismName ?? prev.organismName,
}));
avatar_url: updated.avatarUrl ?? prev.avatar_url,
}) : null);
setProfileOpen(false);
} finally {
@@ -141,10 +191,30 @@ export default function App() {
return <MetersPage selectedProject={selectedProject} />;
case "concentrators":
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 (
@@ -159,6 +229,15 @@ export default function App() {
}
};
// Show loading while checking authentication
if (authLoading) {
return (
<div className="flex h-screen w-full items-center justify-center bg-slate-50">
<div className="text-slate-500">Cargando...</div>
</div>
);
}
if (!isAuth) {
return <LoginPage onSuccess={handleLogin} />;
}
@@ -186,17 +265,16 @@ export default function App() {
page={page}
subPage={subPage}
setSubPage={setSubPage}
userName={user.name}
userEmail={user.email}
avatarUrl={user.avatarUrl}
userName={user?.name ?? "Usuario"}
userEmail={user?.email ?? ""}
avatarUrl={user?.avatar_url ?? null}
onOpenProfile={() => setProfileOpen(true)}
// ✅ en vez de cerrar, abrimos confirm modal
onRequestLogout={() => setLogoutOpen(true)}
/>
</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>
@@ -212,11 +290,11 @@ export default function App() {
<ProfileModal
open={profileOpen}
loading={savingProfile}
avatarUrl={user.avatarUrl}
avatarUrl={user?.avatar_url ?? null}
initial={{
name: user.name,
email: user.email,
organismName: user.organismName,
name: user?.name ?? "",
email: user?.email ?? "",
organismName: "",
}}
onClose={() => setProfileOpen(false)}
onSave={handleSaveProfile}
@@ -236,7 +314,7 @@ export default function App() {
onConfirm={async () => {
setLoggingOut(true);
try {
handleLogout();
await handleLogout();
setLogoutOpen(false);
} finally {
setLoggingOut(false);

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}`
);
}

435
src/api/auth.ts Normal file
View File

@@ -0,0 +1,435 @@
/**
* Authentication API Module
* Handles login, logout, token refresh, and user session management
*/
import { apiClient } from './client';
import { ApiError } from './types';
// Storage keys for authentication tokens
const ACCESS_TOKEN_KEY = 'grh_access_token';
const REFRESH_TOKEN_KEY = 'grh_refresh_token';
/**
* Login credentials interface
*/
export interface LoginCredentials {
email: string;
password: string;
}
/**
* Authentication tokens interface
*/
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
/**
* Authenticated user interface
*/
export interface AuthUser {
id: string;
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
*/
export interface LoginResponse extends AuthTokens {
user: AuthUser;
}
/**
* Refresh token response
*/
export interface RefreshResponse {
accessToken: string;
}
/**
* Store authentication tokens in localStorage
* @param tokens - The tokens to store
*/
function storeTokens(tokens: AuthTokens): void {
try {
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken);
} catch (error) {
console.error('Failed to store authentication tokens:', error);
}
}
/**
* Authenticate user with email and password
* @param credentials - The login credentials
* @returns Promise resolving to tokens and user data
*/
export async function login(credentials: LoginCredentials): Promise<LoginResponse> {
// Validate credentials
if (!credentials.email || !credentials.password) {
throw new ApiError('Email and password are required', 400);
}
const response = await apiClient.post<LoginResponse>(
'/api/auth/login',
credentials,
{ skipAuth: true }
);
// Store tokens on successful login
if (response.accessToken && response.refreshToken) {
storeTokens({
accessToken: response.accessToken,
refreshToken: response.refreshToken,
});
}
return response;
}
/**
* Refresh the access token using the stored refresh token
* @returns Promise resolving to the new access token
*/
export async function refresh(): Promise<RefreshResponse> {
const refreshToken = getStoredTokens()?.refreshToken;
if (!refreshToken) {
throw new ApiError('No refresh token available', 401);
}
const response = await apiClient.post<RefreshResponse>(
'/api/auth/refresh',
{ refreshToken },
{ skipAuth: true }
);
// Update stored access token
if (response.accessToken) {
try {
localStorage.setItem(ACCESS_TOKEN_KEY, response.accessToken);
} catch (error) {
console.error('Failed to update access token:', error);
}
}
return response;
}
/**
* Log out the current user
* Clears tokens and optionally notifies the server
* @returns Promise resolving when logout is complete
*/
export async function logout(): Promise<void> {
try {
const refreshToken = getRefreshToken();
// Attempt to notify server about logout
// This allows the server to invalidate the refresh token
await apiClient.post('/api/auth/logout', { refreshToken }, {
skipAuth: false, // Include token so server knows which session to invalidate
});
} catch (error) {
// Continue with local logout even if server request fails
console.warn('Server logout request failed:', error);
} finally {
// Always clear local tokens
clearAuth();
}
}
/**
* Get the currently authenticated user's profile
* @returns Promise resolving to the user data
*/
export async function getMe(): Promise<AuthUser> {
return apiClient.get<AuthUser>('/api/auth/me');
}
/**
* Check if the user is currently authenticated
* Validates that an access token exists and is not obviously expired
* @returns boolean indicating authentication status
*/
export function isAuthenticated(): boolean {
const tokens = getStoredTokens();
if (!tokens?.accessToken) {
return false;
}
// Check if the token is a valid JWT and not expired
try {
const payload = parseJwtPayload(tokens.accessToken);
if (!payload) {
return false;
}
// Check if token has expired
if (payload.exp) {
const expirationTime = (payload.exp as number) * 1000; // Convert to milliseconds
const currentTime = Date.now();
// Consider token expired if less than 30 seconds remaining
if (currentTime >= expirationTime - 30000) {
return false;
}
}
return true;
} catch {
// If we can't parse the token, assume it's invalid
return false;
}
}
/**
* Get stored authentication tokens
* @returns The stored tokens or null if not found
*/
export function getStoredTokens(): AuthTokens | null {
try {
const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY);
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
if (!accessToken || !refreshToken) {
return null;
}
return {
accessToken,
refreshToken,
};
} catch {
return null;
}
}
/**
* Clear all authentication data from storage
*/
export function clearAuth(): void {
try {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
} catch (error) {
console.error('Failed to clear authentication data:', error);
}
}
/**
* Parse JWT payload without verification
* Used for client-side token inspection (expiration check, etc.)
* @param token - The JWT token to parse
* @returns The parsed payload or null if invalid
*/
function parseJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
const payload = parts[1];
// Handle base64url encoding
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
// Pad with '=' if necessary
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
const decoded = atob(padded);
return JSON.parse(decoded);
} catch {
return null;
}
}
/**
* Get the access token for external use
* Useful when other parts of the app need the raw token
* @returns The access token or null
*/
export function getAccessToken(): string | null {
try {
return localStorage.getItem(ACCESS_TOKEN_KEY);
} catch {
return null;
}
}
/**
* Get the refresh token for external use
* @returns The refresh token or null
*/
export function getRefreshToken(): string | null {
try {
return localStorage.getItem(REFRESH_TOKEN_KEY);
} catch {
return null;
}
}
/**
* Check if the access token is about to expire (within threshold)
* @param thresholdMs - Time threshold in milliseconds (default: 5 minutes)
* @returns boolean indicating if token is expiring soon
*/
export function isTokenExpiringSoon(thresholdMs: number = 5 * 60 * 1000): boolean {
const tokens = getStoredTokens();
if (!tokens?.accessToken) {
return true;
}
try {
const payload = parseJwtPayload(tokens.accessToken);
if (!payload?.exp) {
return false; // Can't determine expiration, assume it's fine
}
const expirationTime = (payload.exp as number) * 1000;
const currentTime = Date.now();
return currentTime >= expirationTime - thresholdMs;
} catch {
return true;
}
}
/**
* Update user profile
* @param updates - The profile updates
* @returns Promise resolving to the updated user data
*/
export async function updateProfile(updates: Partial<Pick<AuthUser, 'name' | 'email'>>): Promise<AuthUser> {
return apiClient.patch<AuthUser>('/api/auth/me', updates);
}
/**
* Change user password
* @param currentPassword - The current password
* @param newPassword - The new password
* @returns Promise resolving when password is changed
*/
export async function changePassword(
currentPassword: string,
newPassword: string
): Promise<void> {
await apiClient.post('/api/auth/change-password', {
currentPassword,
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';
}

404
src/api/client.ts Normal file
View File

@@ -0,0 +1,404 @@
/**
* API Client with JWT Authentication
* Handles all HTTP requests with automatic token management
*/
import { ApiError } from './types';
// Storage keys for authentication tokens
const ACCESS_TOKEN_KEY = 'grh_access_token';
const REFRESH_TOKEN_KEY = 'grh_refresh_token';
// Base URL from environment variable
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
/**
* Request configuration options
*/
interface RequestOptions {
headers?: Record<string, string>;
params?: Record<string, string | number | boolean | undefined | null>;
skipAuth?: boolean;
}
/**
* Internal request configuration
*/
interface InternalRequestConfig {
method: string;
url: string;
data?: unknown;
options?: RequestOptions;
}
/**
* Flag to prevent multiple simultaneous refresh attempts
*/
let isRefreshing = false;
/**
* Queue of requests waiting for token refresh
*/
let refreshQueue: Array<{
resolve: (token: string) => void;
reject: (error: Error) => void;
}> = [];
/**
* Get stored access token from localStorage
*/
function getAccessToken(): string | null {
try {
return localStorage.getItem(ACCESS_TOKEN_KEY);
} catch {
return null;
}
}
/**
* Get stored refresh token from localStorage
*/
function getRefreshToken(): string | null {
try {
return localStorage.getItem(REFRESH_TOKEN_KEY);
} catch {
return null;
}
}
/**
* Store access token in localStorage
*/
function setAccessToken(token: string): void {
try {
localStorage.setItem(ACCESS_TOKEN_KEY, token);
} catch {
console.error('Failed to store access token');
}
}
/**
* Clear all auth tokens from localStorage
*/
function clearTokens(): void {
try {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
} catch {
console.error('Failed to clear tokens');
}
}
/**
* Redirect to login page
*/
function redirectToLogin(): void {
clearTokens();
// Use window.location for a hard redirect to clear any state
window.location.href = '/login';
}
/**
* Build URL with query parameters
*/
function buildUrl(endpoint: string, params?: RequestOptions['params']): string {
const url = new URL(endpoint.startsWith('http') ? endpoint : `${BASE_URL}${endpoint}`);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, String(value));
}
});
}
return url.toString();
}
/**
* Build headers with optional authentication
*/
function buildHeaders(options?: RequestOptions): Headers {
const headers = new Headers({
'Content-Type': 'application/json',
...options?.headers,
});
if (!options?.skipAuth) {
const token = getAccessToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
}
return headers;
}
/**
* Attempt to refresh the access token
*/
async function refreshAccessToken(): Promise<string> {
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new ApiError('No refresh token available', 401);
}
const response = await fetch(`${BASE_URL}/api/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new ApiError(
errorData.error?.message || 'Token refresh failed',
response.status,
errorData.error?.errors
);
}
const data = await response.json();
const newAccessToken = data.accessToken || data.data?.accessToken;
if (!newAccessToken) {
throw new ApiError('Invalid refresh response', 401);
}
setAccessToken(newAccessToken);
return newAccessToken;
}
/**
* Handle token refresh with queue management
* Ensures only one refresh request is made at a time
*/
async function handleTokenRefresh(): Promise<string> {
if (isRefreshing) {
// Wait for the ongoing refresh to complete
return new Promise<string>((resolve, reject) => {
refreshQueue.push({ resolve, reject });
});
}
isRefreshing = true;
try {
const newToken = await refreshAccessToken();
// Resolve all queued requests with the new token
refreshQueue.forEach(({ resolve }) => resolve(newToken));
refreshQueue = [];
return newToken;
} catch (error) {
// Reject all queued requests
refreshQueue.forEach(({ reject }) => reject(error as Error));
refreshQueue = [];
throw error;
} finally {
isRefreshing = false;
}
}
/**
* Parse response and handle errors
*/
async function parseResponse<T>(response: Response): Promise<T> {
const contentType = response.headers.get('content-type');
// Handle empty responses
if (response.status === 204 || !contentType) {
return undefined as T;
}
// Parse JSON response
if (contentType.includes('application/json')) {
const data = await response.json();
// Handle wrapped API responses
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(
errorMessage,
response.status,
errorDetails
);
}
// If response has pagination, return object with data and pagination
if ('pagination' in data) {
return {
data: data.data,
pagination: data.pagination,
} as T;
}
return data.data as T;
}
}
return data as T;
}
// Handle text responses
const text = await response.text();
return text as T;
}
/**
* Core request function with retry logic for 401 errors
*/
async function request<T>(config: InternalRequestConfig): Promise<T> {
const { method, url, data, options } = config;
const makeRequest = async (authToken?: string): Promise<Response> => {
const headers = buildHeaders(options);
// Override with new token if provided (after refresh)
if (authToken) {
headers.set('Authorization', `Bearer ${authToken}`);
}
const fetchOptions: RequestInit = {
method,
headers,
};
if (data !== undefined && method !== 'GET' && method !== 'HEAD') {
fetchOptions.body = JSON.stringify(data);
}
return fetch(buildUrl(url, options?.params), fetchOptions);
};
try {
let response = await makeRequest();
// Handle 401 Unauthorized - attempt token refresh
if (response.status === 401 && !options?.skipAuth) {
try {
const newToken = await handleTokenRefresh();
// Retry the original request with new token
response = await makeRequest(newToken);
} catch (refreshError) {
// Refresh failed - redirect to login
redirectToLogin();
throw new ApiError('Session expired. Please log in again.', 401);
}
}
// Handle other error responses
if (!response.ok) {
let errorMessage = `Request failed with status ${response.status}`;
let errors: string[] | undefined;
try {
const errorData = await response.json();
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) {
errorMessage = errorData.message;
}
} catch {
// Unable to parse error response, use default message
}
throw new ApiError(errorMessage, response.status, errors);
}
return parseResponse<T>(response);
} catch (error) {
// Re-throw ApiError instances
if (error instanceof ApiError) {
throw error;
}
// Handle network errors
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new ApiError('Network error. Please check your connection.', 0);
}
// Handle other errors
throw new ApiError(
error instanceof Error ? error.message : 'An unexpected error occurred',
0
);
}
}
/**
* API Client object with HTTP methods
*/
export const apiClient = {
/**
* Perform a GET request
* @param url - The endpoint URL
* @param options - Optional request configuration
* @returns Promise resolving to the response data
*/
get<T>(url: string, options?: RequestOptions): Promise<T> {
return request<T>({ method: 'GET', url, options });
},
/**
* Perform a POST request
* @param url - The endpoint URL
* @param data - The request body data
* @param options - Optional request configuration
* @returns Promise resolving to the response data
*/
post<T>(url: string, data?: unknown, options?: RequestOptions): Promise<T> {
return request<T>({ method: 'POST', url, data, options });
},
/**
* Perform a PUT request
* @param url - The endpoint URL
* @param data - The request body data
* @param options - Optional request configuration
* @returns Promise resolving to the response data
*/
put<T>(url: string, data?: unknown, options?: RequestOptions): Promise<T> {
return request<T>({ method: 'PUT', url, data, options });
},
/**
* Perform a PATCH request
* @param url - The endpoint URL
* @param data - The request body data
* @param options - Optional request configuration
* @returns Promise resolving to the response data
*/
patch<T>(url: string, data?: unknown, options?: RequestOptions): Promise<T> {
return request<T>({ method: 'PATCH', url, data, options });
},
/**
* Perform a DELETE request
* @param url - The endpoint URL
* @param data - Optional request body data
* @param options - Optional request configuration
* @returns Promise resolving to the response data
*/
delete<T>(url: string, data?: unknown, options?: RequestOptions): Promise<T> {
return request<T>({ method: 'DELETE', url, data, options });
},
};
export default apiClient;

View File

@@ -1,210 +1,146 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
export const CONCENTRATORS_API_URL = `${API_BASE_URL}/api/v3/data/pirzzp3t8kclgo3/mheif1vdgnyt8x2/records`;
const API_TOKEN = import.meta.env.VITE_API_TOKEN;
/**
* Concentrators API
* Handles all concentrator-related API operations using the backend API client
*/
const getAuthHeaders = () => ({
Authorization: `Bearer ${API_TOKEN}`,
"Content-Type": "application/json",
});
import { apiClient } from './client';
export interface ConcentratorRecord {
id: string;
fields: {
"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;
};
// Helper to convert snake_case to camelCase
function snakeToCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}
export interface ConcentratorsResponse {
records: ConcentratorRecord[];
next?: string;
prev?: string;
nestedNext?: string;
nestedPrev?: string;
// Transform object keys from snake_case to camelCase
function transformKeys<T>(obj: Record<string, unknown>): T {
const transformed: Record<string, unknown> = {};
for (const key in obj) {
const camelKey = snakeToCamel(key);
const value = obj[key];
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
transformed[camelKey] = transformKeys(value as Record<string, unknown>);
} else {
transformed[camelKey] = value;
}
}
return transformed as T;
}
// Transform array of objects
function transformArray<T>(arr: Record<string, unknown>[]): T[] {
return arr.map(item => transformKeys<T>(item));
}
/**
* Concentrator type enum
*/
export type ConcentratorType = 'LORA' | 'LORAWAN' | 'GRANDES';
/**
* Concentrator entity from the backend
*/
export 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;
serialNumber: string;
name: string;
projectId: string;
location: string | null;
type: ConcentratorType;
status: string;
ipAddress: string | null;
firmwareVersion: string | null;
lastCommunication: string | null;
createdAt: string;
updatedAt: string;
}
export const fetchConcentrators = async (): Promise<Concentrator[]> => {
try {
const url = new URL(CONCENTRATORS_API_URL);
url.searchParams.set('viewId', 'vw93mj98ylyxratm');
const response = await fetch(url.toString(), {
method: "GET",
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error("Failed to fetch concentrators");
/**
* Input data for creating or updating a concentrator
*/
export interface ConcentratorInput {
serialNumber: string;
name: string;
projectId: string;
location?: string;
type?: ConcentratorType;
status?: string;
ipAddress?: string;
firmwareVersion?: string;
}
const data: ConcentratorsResponse = await response.json();
/**
* Fetch all concentrators, optionally filtered by project
* @param projectId - Optional project ID to filter concentrators
* @returns Promise resolving to an array of concentrators
*/
export async function fetchConcentrators(projectId?: string): Promise<Concentrator[]> {
const params = projectId ? { project_id: projectId } : undefined;
const response = await apiClient.get<{ data: Record<string, unknown>[]; pagination?: unknown } | Record<string, unknown>[]>('/api/concentrators', { params });
return data.records.map((r: ConcentratorRecord) => ({
id: r.id,
"Area Name": r.fields["Area Name"] || "",
"Device S/N": r.fields["Device S/N"] || "",
"Device Name": r.fields["Device Name"] || "",
"Device Time": r.fields["Device Time"] || "",
"Device Status": r.fields["Device Status"] || "",
"Operator": r.fields["Operator"] || "",
"Installed Time": r.fields["Installed Time"] || "",
"Communication Time": r.fields["Communication Time"] || "",
"Instruction Manual": r.fields["Instruction Manual"] || "",
}));
} catch (error) {
console.error("Error fetching concentrators:", error);
throw error;
// Handle paginated response
if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) {
return transformArray<Concentrator>(response.data);
}
// Handle array response (fallback)
return transformArray<Concentrator>(response as Record<string, unknown>[]);
}
/**
* Fetch a single concentrator by ID
* @param id - The concentrator ID
* @returns Promise resolving to the concentrator
*/
export async function fetchConcentrator(id: string): Promise<Concentrator> {
const response = await apiClient.get<Record<string, unknown>>(`/api/concentrators/${id}`);
return transformKeys<Concentrator>(response);
}
/**
* Create a new concentrator
* @param data - The concentrator data
* @returns Promise resolving to the created concentrator
*/
export async function createConcentrator(data: ConcentratorInput): Promise<Concentrator> {
const backendData = {
serial_number: data.serialNumber,
name: data.name,
project_id: data.projectId,
location: data.location,
type: data.type,
status: data.status,
ip_address: data.ipAddress,
firmware_version: data.firmwareVersion,
};
export const createConcentrator = async (
concentratorData: Omit<Concentrator, "id">
): Promise<Concentrator> => {
try {
const response = await fetch(CONCENTRATORS_API_URL, {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify({
fields: {
"Area Name": concentratorData["Area Name"],
"Device S/N": concentratorData["Device S/N"],
"Device Name": concentratorData["Device Name"],
"Device Time": concentratorData["Device Time"],
"Device Status": concentratorData["Device Status"],
"Operator": concentratorData["Operator"],
"Installed Time": concentratorData["Installed Time"],
"Communication Time": concentratorData["Communication Time"],
"Instruction Manual": concentratorData["Instruction Manual"],
},
}),
});
if (!response.ok) {
throw new Error(`Failed to create concentrator: ${response.status} ${response.statusText}`);
const response = await apiClient.post<Record<string, unknown>>('/api/concentrators', backendData);
return transformKeys<Concentrator>(response);
}
const data = await response.json();
const createdRecord = data.records?.[0];
/**
* Update an existing concentrator
* @param id - The concentrator ID
* @param data - The updated concentrator data
* @returns Promise resolving to the updated concentrator
*/
export async function updateConcentrator(id: string, data: Partial<ConcentratorInput>): Promise<Concentrator> {
const backendData: Record<string, unknown> = {};
if (data.serialNumber !== undefined) backendData.serial_number = data.serialNumber;
if (data.name !== undefined) backendData.name = data.name;
if (data.projectId !== undefined) backendData.project_id = data.projectId;
if (data.location !== undefined) backendData.location = data.location;
if (data.type !== undefined) backendData.type = data.type;
if (data.status !== undefined) backendData.status = data.status;
if (data.ipAddress !== undefined) backendData.ip_address = data.ipAddress;
if (data.firmwareVersion !== undefined) backendData.firmware_version = data.firmwareVersion;
if (!createdRecord) {
throw new Error("Invalid response format: no record returned");
const response = await apiClient.patch<Record<string, unknown>>(`/api/concentrators/${id}`, backendData);
return transformKeys<Concentrator>(response);
}
return {
id: createdRecord.id,
"Area Name": createdRecord.fields["Area Name"] || concentratorData["Area Name"],
"Device S/N": createdRecord.fields["Device S/N"] || concentratorData["Device S/N"],
"Device Name": createdRecord.fields["Device Name"] || concentratorData["Device Name"],
"Device Time": createdRecord.fields["Device Time"] || concentratorData["Device Time"],
"Device Status": createdRecord.fields["Device Status"] || concentratorData["Device Status"],
"Operator": createdRecord.fields["Operator"] || concentratorData["Operator"],
"Installed Time": createdRecord.fields["Installed Time"] || concentratorData["Installed Time"],
"Communication Time": createdRecord.fields["Communication Time"] || concentratorData["Communication Time"],
"Instruction Manual": createdRecord.fields["Instruction Manual"] || concentratorData["Instruction Manual"],
};
} catch (error) {
console.error("Error creating concentrator:", error);
throw error;
/**
* Delete a concentrator
* @param id - The concentrator ID
* @returns Promise resolving when the concentrator is deleted
*/
export async function deleteConcentrator(id: string): Promise<void> {
return apiClient.delete<void>(`/api/concentrators/${id}`);
}
};
export const updateConcentrator = async (
id: string,
concentratorData: Omit<Concentrator, "id">
): Promise<Concentrator> => {
try {
const response = await fetch(CONCENTRATORS_API_URL, {
method: "PATCH",
headers: getAuthHeaders(),
body: JSON.stringify({
id: id,
fields: {
"Area Name": concentratorData["Area Name"],
"Device S/N": concentratorData["Device S/N"],
"Device Name": concentratorData["Device Name"],
"Device Time": concentratorData["Device Time"],
"Device Status": concentratorData["Device Status"],
"Operator": concentratorData["Operator"],
"Installed Time": concentratorData["Installed Time"],
"Communication Time": concentratorData["Communication Time"],
"Instruction Manual": concentratorData["Instruction Manual"],
},
}),
});
if (!response.ok) {
if (response.status === 400) {
const errorData = await response.json();
throw new Error(`Bad Request: ${errorData.msg || "Invalid data provided"}`);
}
throw new Error(`Failed to update concentrator: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const updatedRecord = data.records?.[0];
if (!updatedRecord) {
throw new Error("Invalid response format: no record returned");
}
return {
id: updatedRecord.id,
"Area Name": updatedRecord.fields["Area Name"] || concentratorData["Area Name"],
"Device S/N": updatedRecord.fields["Device S/N"] || concentratorData["Device S/N"],
"Device Name": updatedRecord.fields["Device Name"] || concentratorData["Device Name"],
"Device Time": updatedRecord.fields["Device Time"] || concentratorData["Device Time"],
"Device Status": updatedRecord.fields["Device Status"] || concentratorData["Device Status"],
"Operator": updatedRecord.fields["Operator"] || concentratorData["Operator"],
"Installed Time": updatedRecord.fields["Installed Time"] || concentratorData["Installed Time"],
"Communication Time": updatedRecord.fields["Communication Time"] || concentratorData["Communication Time"],
"Instruction Manual": updatedRecord.fields["Instruction Manual"] || concentratorData["Instruction Manual"],
};
} catch (error) {
console.error("Error updating concentrator:", error);
throw error;
}
};
export const deleteConcentrator = async (id: string): Promise<void> => {
try {
const response = await fetch(CONCENTRATORS_API_URL, {
method: "DELETE",
headers: getAuthHeaders(),
body: JSON.stringify({
id: id,
}),
});
if (!response.ok) {
if (response.status === 400) {
const errorData = await response.json();
throw new Error(`Bad Request: ${errorData.msg || "Invalid data provided"}`);
}
throw new Error(`Failed to delete concentrator: ${response.status} ${response.statusText}`);
}
} catch (error) {
console.error("Error deleting concentrator:", error);
throw error;
}
};

View File

@@ -1,45 +1,83 @@
export async function uploadMyAvatar(file: File): Promise<{ avatarUrl: string }> {
const form = new FormData();
form.append("avatar", file);
/**
* User Profile API
* Handles all user profile-related API operations using the backend API client
*/
const res = await fetch("/api/me/avatar", {
method: "POST",
body: form,
// NO pongas Content-Type; el browser lo agrega con boundary
headers: {
// Si usas token:
// Authorization: `Bearer ${localStorage.getItem("token") ?? ""}`,
},
});
import { apiClient } from './client';
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Upload avatar failed: ${res.status} ${text}`);
}
const data = await res.json();
if (!data?.avatarUrl) throw new Error("Respuesta sin avatarUrl");
return { avatarUrl: data.avatarUrl };
}
export async function updateMyProfile(input: {
name: string;
/**
* User entity from the backend
*/
export interface User {
id: string;
email: string;
}): Promise<{ name?: string; email?: string; avatarUrl?: string | null }> {
const res = await fetch("/api/me", {
method: "PUT",
name: string;
avatarUrl: string | null;
role: string;
createdAt: string;
updatedAt: string;
}
/**
* Get the current user's profile
* @returns Promise resolving to the user profile
*/
export async function getMyProfile(): Promise<User> {
return apiClient.get<User>('/api/me');
}
/**
* Update the current user's profile
* @param data - The updated profile data
* @returns Promise resolving to the updated user profile
*/
export async function updateMyProfile(data: { name?: string; email?: string }): Promise<User> {
return apiClient.put<User>('/api/me', data);
}
/**
* Upload a new avatar for the current user
* @param file - The avatar image file
* @returns Promise resolving to the new avatar URL
*/
export async function uploadMyAvatar(file: File): Promise<{ avatarUrl: string }> {
// For file uploads, we need to use FormData and handle it differently
const formData = new FormData();
formData.append('avatar', file);
const token = localStorage.getItem('grh_access_token');
const response = await fetch('/api/me/avatar', {
method: 'POST',
headers: {
"Content-Type": "application/json",
// Authorization: `Bearer ${localStorage.getItem("token") ?? ""}`,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(input),
body: formData,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Update profile failed: ${res.status} ${text}`);
if (!response.ok) {
const errorText = await response.text().catch(() => '');
throw new Error(`Upload avatar failed: ${response.status} ${errorText}`);
}
return res.json();
const data = await response.json();
if (!data?.avatarUrl && !data?.data?.avatarUrl) {
throw new Error('Response missing avatarUrl');
}
return { avatarUrl: data.avatarUrl || data.data?.avatarUrl };
}
/**
* Change the current user's password
* @param currentPassword - The current password
* @param newPassword - The new password
* @returns Promise resolving when the password is changed
*/
export async function changeMyPassword(currentPassword: string, newPassword: string): Promise<void> {
return apiClient.post<void>('/api/me/password', {
currentPassword,
newPassword,
});
}

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

@@ -1,312 +1,344 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
export const METERS_API_URL = `${API_BASE_URL}/api/v3/data/pirzzp3t8kclgo3/m4hzpnopjkppaav/records`;
const API_TOKEN = import.meta.env.VITE_API_TOKEN;
/**
* Meters API
* Handles all meter-related API operations using the backend API client
*/
const getAuthHeaders = () => ({
Authorization: `Bearer ${API_TOKEN}`,
"Content-Type": "application/json",
});
import { apiClient } from './client';
export interface MeterRecord {
id: string;
fields: {
CreatedAt: string;
UpdatedAt: string;
"Area Name": string;
"Account Number": string | null;
"User Name": string | null;
"User Address": string | null;
"Meter S/N": string;
"Meter Name": string;
"Meter Status": string;
"Protocol Type": string;
"Price No.": string | null;
"Price Name": string | null;
"DMA Partition": string | null;
"Supply Types": string;
"Device ID": string;
"Device Name": string;
"Device Type": string;
"Usage Analysis Type": string;
"installed Time": string;
};
// Helper to convert snake_case to camelCase
function snakeToCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}
export interface MetersResponse {
records: MeterRecord[];
next?: string;
prev?: string;
nestedNext?: string;
nestedPrev?: string;
// Transform object keys from snake_case to camelCase
function transformKeys<T>(obj: Record<string, unknown>): T {
const transformed: Record<string, unknown> = {};
for (const key in obj) {
const camelKey = snakeToCamel(key);
const value = obj[key];
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
transformed[camelKey] = transformKeys(value as Record<string, unknown>);
} else {
transformed[camelKey] = value;
}
}
return transformed as T;
}
// Transform array of objects
function transformArray<T>(arr: Record<string, unknown>[]): T[] {
return arr.map(item => transformKeys<T>(item));
}
/**
* Meter entity from the backend
* Meters belong to Concentrators (LORA protocol)
*/
export interface Meter {
id: string;
serialNumber: string;
meterId: string | null;
name: string;
concentratorId: string;
location: string | null;
type: string;
status: string;
lastReadingValue: number | null;
lastReadingAt: string | null;
installationDate: string | null;
createdAt: string;
updatedAt: string;
areaName: string;
accountNumber: string | null;
userName: string | null;
userAddress: string | null;
// From joins
concentratorName?: string;
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;
}
/**
* Input data for creating or updating a meter
*/
export interface MeterInput {
serialNumber: string;
meterId?: string;
name: string;
concentratorId: string;
location?: string;
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 (from /api/meters/:id/readings)
*/
export interface MeterReading {
id: string;
meterId: string;
readingValue: number;
readingType: string;
batteryLevel: number | null;
signalStrength: number | null;
receivedAt: string;
createdAt: string;
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;
meterLocation: string | null;
concentratorId: string;
concentratorName: string;
projectId: string;
projectName: string;
}
export const fetchMeters = async (): Promise<Meter[]> => {
const pageSize = 9999;
try {
const url = new URL(METERS_API_URL);
url.searchParams.set('viewId', 'vwo7tqwu8fi6ie83');
url.searchParams.set('pageSize', pageSize.toString());
const response = await fetch(url.toString(), {
method: "GET",
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error("Failed to fetch meters");
export interface MeterReadingFilters {
startDate?: string;
endDate?: string;
page?: number;
pageSize?: number;
}
const data: MetersResponse = await response.json();
const ans = data.records.map((r: MeterRecord) => ({
id: r.id,
createdAt: r.fields.CreatedAt || "",
updatedAt: r.fields.UpdatedAt || "",
areaName: r.fields["Area Name"] || "",
accountNumber: r.fields["Account Number"] || null,
userName: r.fields["User Name"] || null,
userAddress: r.fields["User Address"] || null,
meterSerialNumber: r.fields["Meter S/N"] || "",
meterName: r.fields["Meter Name"] || "",
meterStatus: r.fields["Meter Status"] || "",
protocolType: r.fields["Protocol Type"] || "",
priceNo: r.fields["Price No."] || null,
priceName: r.fields["Price Name"] || null,
dmaPartition: r.fields["DMA Partition"] || null,
supplyTypes: r.fields["Supply Types"] || "",
deviceId: r.fields["Device ID"] || "",
deviceName: r.fields["Device Name"] || "",
deviceType: r.fields["Device Type"] || "",
usageAnalysisType: r.fields["Usage Analysis Type"] || "",
installedTime: r.fields["installed Time"] || "",
}));
return ans;
} catch (error) {
console.error("Error fetching meters:", error);
throw error;
}
export interface PaginatedMeterReadings {
data: MeterReading[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
export const createMeter = async (
meterData: Omit<Meter, "id">
): Promise<Meter> => {
try {
const response = await fetch(METERS_API_URL, {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify({
fields: {
CreatedAt: meterData.createdAt,
UpdatedAt: meterData.updatedAt,
"Area Name": meterData.areaName,
"Account Number": meterData.accountNumber,
"User Name": meterData.userName,
"User Address": meterData.userAddress,
"Meter S/N": meterData.meterSerialNumber,
"Meter Name": meterData.meterName,
"Meter Status": meterData.meterStatus,
"Protocol Type": meterData.protocolType,
"Price No.": meterData.priceNo,
"Price Name": meterData.priceName,
"DMA Partition": meterData.dmaPartition,
"Supply Types": meterData.supplyTypes,
"Device ID": meterData.deviceId,
"Device Name": meterData.deviceName,
"Device Type": meterData.deviceType,
"Usage Analysis Type": meterData.usageAnalysisType,
"Installed Time": meterData.installedTime,
},
}),
/**
* Fetch all meters, optionally filtered by concentrator or project
* @param filters - Optional filters (concentratorId, projectId)
* @returns Promise resolving to an array of meters
*/
export async function fetchMeters(filters?: { concentratorId?: string; projectId?: string }): Promise<Meter[]> {
const params: Record<string, string> = {
pageSize: '1000', // Request up to 1000 meters
};
if (filters?.concentratorId) params.concentrator_id = filters.concentratorId;
if (filters?.projectId) params.project_id = filters.projectId;
const response = await apiClient.get<{ data: Record<string, unknown>[]; pagination: unknown }>('/api/meters', {
params
});
if (!response.ok) {
throw new Error(
`Failed to create meter: ${response.status} ${response.statusText}`
);
// Handle paginated response
if (response && typeof response === 'object' && 'data' in response) {
return transformArray<Meter>(response.data);
}
const data = await response.json();
const createdRecord = data.records?.[0];
if (!createdRecord) {
throw new Error("Invalid response format: no record returned");
// Fallback for non-paginated response
return transformArray<Meter>(response as unknown as Record<string, unknown>[]);
}
/**
* Fetch a single meter by ID
* @param id - The meter ID
* @returns Promise resolving to the meter
*/
export async function fetchMeter(id: string): Promise<Meter> {
const response = await apiClient.get<Record<string, unknown>>(`/api/meters/${id}`);
return transformKeys<Meter>(response);
}
/**
* Create a new meter
* @param data - The meter data
* @returns Promise resolving to the created meter
*/
export async function createMeter(data: MeterInput): Promise<Meter> {
// Convert camelCase to snake_case for backend
const backendData = {
serial_number: data.serialNumber,
meter_id: data.meterId,
name: data.name,
concentrator_id: data.concentratorId,
location: data.location,
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);
}
/**
* Update an existing meter
* @param id - The meter ID
* @param data - The updated meter data
* @returns Promise resolving to the updated meter
*/
export async function updateMeter(id: string, data: Partial<MeterInput>): Promise<Meter> {
// Convert camelCase to snake_case for backend
const backendData: Record<string, unknown> = {};
if (data.serialNumber !== undefined) backendData.serial_number = data.serialNumber;
if (data.meterId !== undefined) backendData.meter_id = data.meterId;
if (data.name !== undefined) backendData.name = data.name;
if (data.concentratorId !== undefined) backendData.concentrator_id = data.concentratorId;
if (data.location !== undefined) backendData.location = data.location;
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);
}
/**
* Delete a meter
* @param id - The meter ID
* @returns Promise resolving when the meter is deleted
*/
export async function deleteMeter(id: string): Promise<void> {
return apiClient.delete<void>(`/api/meters/${id}`);
}
/**
* Fetch readings for a specific meter with pagination and date filters
* @param id - The meter ID
* @param filters - Optional pagination and date filters
* @returns Promise resolving to paginated meter 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 {
id: createdRecord.id,
createdAt: createdRecord.fields.CreatedAt || meterData.createdAt,
updatedAt: createdRecord.fields.UpdatedAt || meterData.updatedAt,
areaName: createdRecord.fields["Area Name"] || meterData.areaName,
accountNumber:
createdRecord.fields["Account Number"] || meterData.accountNumber,
userName: createdRecord.fields["User Name"] || meterData.userName,
userAddress:
createdRecord.fields["User Address"] || meterData.userAddress,
meterSerialNumber:
createdRecord.fields["Meter S/N"] || meterData.meterSerialNumber,
meterName: createdRecord.fields["Meter Name"] || meterData.meterName,
meterStatus:
createdRecord.fields["Meter Status"] || meterData.meterStatus,
protocolType:
createdRecord.fields["Protocol Type"] || meterData.protocolType,
priceNo: createdRecord.fields["Price No."] || meterData.priceNo,
priceName: createdRecord.fields["Price Name"] || meterData.priceName,
dmaPartition:
createdRecord.fields["DMA Partition"] || meterData.dmaPartition,
supplyTypes:
createdRecord.fields["Supply Types"] || meterData.supplyTypes,
deviceId: createdRecord.fields["Device ID"] || meterData.deviceId,
deviceName: createdRecord.fields["Device Name"] || meterData.deviceName,
deviceType: createdRecord.fields["Device Type"] || meterData.deviceType,
usageAnalysisType:
createdRecord.fields["Usage Analysis Type"] ||
meterData.usageAnalysisType,
installedTime:
createdRecord.fields["Installed Time"] || meterData.installedTime,
data: transformArray<MeterReading>(response.data),
pagination: response.pagination,
};
} catch (error) {
console.error("Error creating meter:", error);
throw error;
}
};
export const updateMeter = async (
id: string,
meterData: Omit<Meter, "id">
): Promise<Meter> => {
try {
const response = await fetch(METERS_API_URL, {
method: "PATCH",
headers: getAuthHeaders(),
body: JSON.stringify({
id: id,
fields: {
CreatedAt: meterData.createdAt,
UpdatedAt: meterData.updatedAt,
"Area Name": meterData.areaName,
"Account Number": meterData.accountNumber,
"User Name": meterData.userName,
"User Address": meterData.userAddress,
"Meter S/N": meterData.meterSerialNumber,
"Meter Name": meterData.meterName,
"Meter Status": meterData.meterStatus,
"Protocol Type": meterData.protocolType,
"Price No.": meterData.priceNo,
"Price Name": meterData.priceName,
"DMA Partition": meterData.dmaPartition,
"Supply Types": meterData.supplyTypes,
"Device ID": meterData.deviceId,
"Device Name": meterData.deviceName,
"Device Type": meterData.deviceType,
"Usage Analysis Type": meterData.usageAnalysisType,
"Installed Time": meterData.installedTime,
/**
* Bulk upload result interface
*/
export interface BulkUploadResult {
success: boolean;
data: {
totalRows: number;
inserted: number;
failed: number;
errors: Array<{
row: number;
error: string;
data?: Record<string, unknown>;
}>;
};
}
/**
* Bulk upload meters from Excel file
* @param file - Excel file to upload
* @returns Promise resolving to upload result
*/
export async function bulkUploadMeters(file: File): Promise<BulkUploadResult> {
const token = localStorage.getItem('grh_access_token');
if (!token) {
throw new Error('No hay sesión activa. Por favor inicia sesión nuevamente.');
}
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/meters`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
}),
body: formData,
});
if (!response.ok) {
if (response.status === 400) {
const errorData = await response.json();
throw new Error(
`Bad Request: ${errorData.msg || "Invalid data provided"}`
);
}
throw new Error(
`Failed to update meter: ${response.status} ${response.statusText}`
);
const error = await response.json();
throw new Error(error.error || 'Error en la carga masiva');
}
const data = await response.json();
const updatedRecord = data.records?.[0];
if (!updatedRecord) {
throw new Error("Invalid response format: no record returned");
return response.json();
}
return {
id: updatedRecord.id,
createdAt: updatedRecord.fields.CreatedAt || meterData.createdAt,
updatedAt: updatedRecord.fields.UpdatedAt || meterData.updatedAt,
areaName: updatedRecord.fields["Area Name"] || meterData.areaName,
accountNumber:
updatedRecord.fields["Account Number"] || meterData.accountNumber,
userName: updatedRecord.fields["User Name"] || meterData.userName,
userAddress:
updatedRecord.fields["User Address"] || meterData.userAddress,
meterSerialNumber:
updatedRecord.fields["Meter S/N"] || meterData.meterSerialNumber,
meterName: updatedRecord.fields["Meter Name"] || meterData.meterName,
meterStatus:
updatedRecord.fields["Meter Status"] || meterData.meterStatus,
protocolType:
updatedRecord.fields["Protocol Type"] || meterData.protocolType,
priceNo: updatedRecord.fields["Price No."] || meterData.priceNo,
priceName: updatedRecord.fields["Price Name"] || meterData.priceName,
dmaPartition:
updatedRecord.fields["DMA Partition"] || meterData.dmaPartition,
supplyTypes:
updatedRecord.fields["Supply Types"] || meterData.supplyTypes,
deviceId: updatedRecord.fields["Device ID"] || meterData.deviceId,
deviceName: updatedRecord.fields["Device Name"] || meterData.deviceName,
deviceType: updatedRecord.fields["Device Type"] || meterData.deviceType,
usageAnalysisType:
updatedRecord.fields["Usage Analysis Type"] ||
meterData.usageAnalysisType,
installedTime:
updatedRecord.fields["Installed Time"] || meterData.installedTime,
};
} catch (error) {
console.error("Error updating meter:", error);
throw error;
}
};
/**
* Download meter template Excel file
*/
export async function downloadMeterTemplate(): Promise<void> {
const token = localStorage.getItem('grh_access_token');
export const deleteMeter = async (id: string): Promise<void> => {
try {
const response = await fetch(METERS_API_URL, {
method: "DELETE",
headers: getAuthHeaders(),
body: JSON.stringify({
id: id,
}),
if (!token) {
throw new Error('No hay sesión activa. Por favor inicia sesión nuevamente.');
}
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/meters/template`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
if (response.status === 400) {
// Try to get error message from response
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const errorData = await response.json();
throw new Error(
`Bad Request: ${errorData.msg || "Invalid data provided"}`
);
throw new Error(errorData.error || `Error ${response.status}: ${response.statusText}`);
}
throw new Error(
`Failed to delete meter: ${response.status} ${response.statusText}`
);
throw new Error(`Error ${response.status}: ${response.statusText}`);
}
} catch (error) {
console.error("Error deleting meter:", error);
throw error;
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'plantilla_medidores.xlsx';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
};

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

@@ -1,247 +1,155 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
export const PROJECTS_API_URL = `${API_BASE_URL}/api/v3/data/pirzzp3t8kclgo3/m9882vn3xb31e29/records`;
const API_TOKEN = import.meta.env.VITE_API_TOKEN;
/**
* Projects API
* Handles all project-related API operations using the backend API client
*/
export const getAuthHeaders = () => ({
"Content-Type": "application/json",
Authorization: `Bearer ${API_TOKEN}`,
});
import { apiClient } from './client';
export interface ProjectRecord {
id: number;
fields: {
"Area Name"?: string;
"Device S/N"?: string;
"Device Name"?: string;
"Device Type"?: string;
"Device Status"?: string;
Operator?: string;
"Installed Time"?: string;
"Communication time"?: string;
"Instruction Manual"?: string | null;
};
// Helper to convert snake_case to camelCase
function snakeToCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}
export interface ProjectsResponse {
records: ProjectRecord[];
next?: string;
prev?: string;
nestedNext?: string;
nestedPrev?: string;
// Transform object keys from snake_case to camelCase
function transformKeys<T>(obj: Record<string, unknown>): T {
const transformed: Record<string, unknown> = {};
for (const key in obj) {
const camelKey = snakeToCamel(key);
const value = obj[key];
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
transformed[camelKey] = transformKeys(value as Record<string, unknown>);
} else {
transformed[camelKey] = value;
}
}
return transformed as T;
}
// Transform array of objects
function transformArray<T>(arr: Record<string, unknown>[]): T[] {
return arr.map(item => transformKeys<T>(item));
}
/**
* Project entity from the backend
*/
export interface Project {
id: string;
name: string;
description: string | null;
areaName: string;
deviceSN: string;
deviceName: string;
deviceType: string;
deviceStatus: "ACTIVE" | "INACTIVE";
operator: string;
installedTime: string;
communicationTime: string;
location: string | null;
status: string;
meterTypeId: string | null;
organismoOperadorId: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
}
export const fetchProjectNames = async (): Promise<string[]> => {
try {
const response = await fetch(PROJECTS_API_URL, {
method: "GET",
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error("Failed to fetch projects");
/**
* Input data for creating or updating a project
*/
export interface ProjectInput {
name: string;
description?: string;
areaName: string;
location?: string;
status?: string;
meterTypeId?: string | null;
organismoOperadorId?: string | null;
}
const data: ProjectsResponse = await response.json();
/**
* Fetch all projects
* @returns Promise resolving to an array of projects
*/
export async function fetchProjects(): Promise<Project[]> {
const response = await apiClient.get<{ data: Record<string, unknown>[]; pagination?: unknown } | Record<string, unknown>[]>('/api/projects');
if (!data.records || data.records.length === 0) {
console.warn("No project records found from API");
return [];
// Handle paginated response
if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) {
return transformArray<Project>(response.data);
}
const projectNames = [
...new Set(
data.records
.map((record) => record.fields["Area Name"] || "")
.filter((name) => name)
),
];
return projectNames;
} catch (error) {
console.error("Error fetching project names:", error);
return [];
// Handle array response (fallback)
return transformArray<Project>(response as Record<string, unknown>[]);
}
/**
* Fetch a single project by ID
* @param id - The project ID
* @returns Promise resolving to the project
*/
export async function fetchProject(id: string): Promise<Project> {
const response = await apiClient.get<Record<string, unknown>>(`/api/projects/${id}`);
return transformKeys<Project>(response);
}
/**
* Create a new project
* @param data - The project data
* @returns Promise resolving to the created project
*/
export async function createProject(data: ProjectInput): Promise<Project> {
const backendData = {
name: data.name,
description: data.description,
area_name: data.areaName,
location: data.location,
status: data.status,
meter_type_id: data.meterTypeId,
organismo_operador_id: data.organismoOperadorId,
};
export const fetchProjects = async (): Promise<Project[]> => {
try {
const url = new URL(PROJECTS_API_URL);
url.searchParams.set('viewId', 'vwrrxvlzlxi7jfe7');
const response = await fetch(url.toString(), {
method: "GET",
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error("Failed to fetch projects");
const response = await apiClient.post<Record<string, unknown>>('/api/projects', backendData);
return transformKeys<Project>(response);
}
const data: ProjectsResponse = await response.json();
/**
* Update an existing project
* @param id - The project ID
* @param data - The updated project data
* @returns Promise resolving to the updated project
*/
export async function updateProject(id: string, data: Partial<ProjectInput>): Promise<Project> {
const backendData: Record<string, unknown> = {};
if (data.name !== undefined) backendData.name = data.name;
if (data.description !== undefined) backendData.description = data.description;
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;
return data.records.map((r: ProjectRecord) => ({
id: r.id.toString(),
areaName: r.fields["Area Name"] ?? "",
deviceSN: r.fields["Device S/N"] ?? "",
deviceName: r.fields["Device Name"] ?? "",
deviceType: r.fields["Device Type"] ?? "",
deviceStatus:
r.fields["Device Status"] === "Installed" ? "ACTIVE" : "INACTIVE",
operator: r.fields["Operator"] ?? "",
installedTime: r.fields["Installed Time"] ?? "",
communicationTime: r.fields["Communication time"] ?? "",
instructionManual: "",
}));
} catch (error) {
console.error("Error fetching projects:", error);
throw error;
}
};
export const createProject = async (
projectData: Omit<Project, "id">
): Promise<Project> => {
const response = await fetch(PROJECTS_API_URL, {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify({
fields: {
"Area Name": projectData.areaName,
"Device S/N": projectData.deviceSN,
"Device Name": projectData.deviceName,
"Device Type": projectData.deviceType,
"Device Status":
projectData.deviceStatus === "ACTIVE" ? "Installed" : "Inactive",
Operator: projectData.operator,
"Installed Time": projectData.installedTime,
"Communication time": projectData.communicationTime,
},
}),
});
if (!response.ok) {
throw new Error(
`Failed to create project: ${response.status} ${response.statusText}`
);
const response = await apiClient.patch<Record<string, unknown>>(`/api/projects/${id}`, backendData);
return transformKeys<Project>(response);
}
const data = await response.json();
const createdRecord = data.records?.[0];
if (!createdRecord) {
throw new Error("Invalid response format: no record returned");
/**
* Delete a project
* @param id - The project ID
* @returns Promise resolving when the project is deleted
*/
export async function deleteProject(id: string): Promise<void> {
return apiClient.delete<void>(`/api/projects/${id}`);
}
return {
id: createdRecord.id.toString(),
areaName: createdRecord.fields["Area Name"] ?? projectData.areaName,
deviceSN: createdRecord.fields["Device S/N"] ?? projectData.deviceSN,
deviceName: createdRecord.fields["Device Name"] ?? projectData.deviceName,
deviceType: createdRecord.fields["Device Type"] ?? projectData.deviceType,
deviceStatus:
createdRecord.fields["Device Status"] === "Installed"
? "ACTIVE"
: "INACTIVE",
operator: createdRecord.fields["Operator"] ?? projectData.operator,
installedTime:
createdRecord.fields["Installed Time"] ?? projectData.installedTime,
communicationTime:
createdRecord.fields["Communication time"] ??
projectData.communicationTime,
};
};
export const updateProject = async (
id: string,
projectData: Omit<Project, "id">
): Promise<Project> => {
const response = await fetch(PROJECTS_API_URL, {
method: "PATCH",
headers: getAuthHeaders(),
body: JSON.stringify({
id: parseInt(id),
fields: {
"Area Name": projectData.areaName,
"Device S/N": projectData.deviceSN,
"Device Name": projectData.deviceName,
"Device Type": projectData.deviceType,
"Device Status":
projectData.deviceStatus === "ACTIVE" ? "Installed" : "Inactive",
Operator: projectData.operator,
"Installed Time": projectData.installedTime,
"Communication time": projectData.communicationTime,
},
}),
});
if (!response.ok) {
if (response.status === 400) {
const errorData = await response.json();
throw new Error(
`Bad Request: ${errorData.msg || "Invalid data provided"}`
);
}
throw new Error(
`Failed to update project: ${response.status} ${response.statusText}`
);
/**
* 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);
}
const data = await response.json();
const updatedRecord = data.records?.[0];
if (!updatedRecord) {
throw new Error("Invalid response format: no record returned");
/**
* Fetch unique area names from all projects
* @returns Promise resolving to an array of unique area names
*/
export async function fetchProjectNames(): Promise<string[]> {
const projects = await fetchProjects();
const areaNames = [...new Set(projects.map(p => p.areaName).filter(Boolean))];
return areaNames;
}
return {
id: updatedRecord.id.toString(),
areaName: updatedRecord.fields["Area Name"] ?? projectData.areaName,
deviceSN: updatedRecord.fields["Device S/N"] ?? projectData.deviceSN,
deviceName: updatedRecord.fields["Device Name"] ?? projectData.deviceName,
deviceType: updatedRecord.fields["Device Type"] ?? projectData.deviceType,
deviceStatus:
updatedRecord.fields["Device Status"] === "Installed"
? "ACTIVE"
: "INACTIVE",
operator: updatedRecord.fields["Operator"] ?? projectData.operator,
installedTime:
updatedRecord.fields["Installed Time"] ?? projectData.installedTime,
communicationTime:
updatedRecord.fields["Communication time"] ??
projectData.communicationTime,
};
};
export const deleteProject = async (id: string): Promise<void> => {
const response = await fetch(PROJECTS_API_URL, {
method: "DELETE",
headers: getAuthHeaders(),
body: JSON.stringify({
id: id,
}),
});
if (!response.ok) {
if (response.status === 400) {
const errorData = await response.json();
throw new Error(
`Bad Request: ${errorData.msg || "Invalid data provided"}`
);
}
throw new Error(
`Failed to delete project: ${response.status} ${response.statusText}`
);
}
};

271
src/api/readings.ts Normal file
View File

@@ -0,0 +1,271 @@
/**
* Readings API
* Handles all meter reading-related API operations using the backend API client
*/
import { apiClient } from './client';
// Helper to convert snake_case to camelCase
function snakeToCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}
// Transform object keys from snake_case to camelCase
function transformKeys<T>(obj: Record<string, unknown>): T {
const transformed: Record<string, unknown> = {};
for (const key in obj) {
const camelKey = snakeToCamel(key);
const value = obj[key];
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
transformed[camelKey] = transformKeys(value as Record<string, unknown>);
} else {
transformed[camelKey] = value;
}
}
return transformed as T;
}
// Transform array of objects
function transformArray<T>(arr: Record<string, unknown>[]): T[] {
return arr.map(item => transformKeys<T>(item));
}
/**
* Meter reading entity from the backend
*/
export interface MeterReading {
id: string;
meterId: string;
readingValue: number;
readingType: string;
batteryLevel: number | null;
signalStrength: number | null;
rawPayload: string | null;
receivedAt: string;
createdAt: string;
// From join with meters
meterSerialNumber: string;
meterName: string;
meterLocation: string | null;
concentratorId: string;
concentratorName: string;
projectId: string;
projectName: string;
}
/**
* Consumption summary statistics
*/
export interface ConsumptionSummary {
totalReadings: number;
totalMeters: number;
avgReading: number;
lastReadingDate: string | null;
}
/**
* Pagination info from API response
*/
export interface Pagination {
page: number;
pageSize: number;
total: number;
totalPages: number;
}
/**
* Paginated response
*/
export interface PaginatedResponse<T> {
data: T[];
pagination: Pagination;
}
/**
* Filters for fetching readings
*/
export interface ReadingFilters {
meterId?: string;
projectId?: string;
concentratorId?: string;
startDate?: string;
endDate?: string;
readingType?: string;
page?: number;
pageSize?: number;
}
/**
* Fetch all readings with optional filtering and pagination
* @param filters - Optional filters for the query
* @returns Promise resolving to paginated readings
*/
export async function fetchReadings(filters?: ReadingFilters): Promise<PaginatedResponse<MeterReading>> {
const params: Record<string, string | number> = {};
if (filters?.meterId) params.meter_id = filters.meterId;
if (filters?.projectId) params.project_id = filters.projectId;
if (filters?.concentratorId) params.concentrator_id = filters.concentratorId;
if (filters?.startDate) params.start_date = filters.startDate;
if (filters?.endDate) params.end_date = filters.endDate;
if (filters?.readingType) params.reading_type = filters.readingType;
if (filters?.page) params.page = filters.page;
if (filters?.pageSize) params.pageSize = filters.pageSize;
const response = await apiClient.get<{
data: Record<string, unknown>[];
pagination: Pagination;
}>('/api/readings', { params });
return {
data: transformArray<MeterReading>(response.data),
pagination: response.pagination,
};
}
/**
* Fetch a single reading by ID
* @param id - The reading ID
* @returns Promise resolving to the reading
*/
export async function fetchReading(id: string): Promise<MeterReading> {
const response = await apiClient.get<Record<string, unknown>>(`/api/readings/${id}`);
return transformKeys<MeterReading>(response);
}
/**
* Fetch consumption summary statistics
* @param projectId - Optional project ID to filter
* @returns Promise resolving to the summary
*/
export async function fetchConsumptionSummary(projectId?: string): Promise<ConsumptionSummary> {
const params = projectId ? { project_id: projectId } : undefined;
const response = await apiClient.get<Record<string, unknown>>('/api/readings/summary', { params });
return transformKeys<ConsumptionSummary>(response);
}
/**
* Input data for creating a reading
*/
export interface ReadingInput {
meterId: string;
readingValue: number;
readingType?: string;
batteryLevel?: number;
signalStrength?: number;
rawPayload?: string;
receivedAt?: string;
}
/**
* Create a new reading
* @param data - The reading data
* @returns Promise resolving to the created reading
*/
export async function createReading(data: ReadingInput): Promise<MeterReading> {
const backendData = {
meter_id: data.meterId,
reading_value: data.readingValue,
reading_type: data.readingType,
battery_level: data.batteryLevel,
signal_strength: data.signalStrength,
raw_payload: data.rawPayload,
received_at: data.receivedAt,
};
const response = await apiClient.post<Record<string, unknown>>('/api/readings', backendData);
return transformKeys<MeterReading>(response);
}
/**
* Delete a reading
* @param id - The reading ID
* @returns Promise resolving when the reading is deleted
*/
export async function deleteReading(id: string): Promise<void> {
return apiClient.delete<void>(`/api/readings/${id}`);
}
/**
* Bulk upload result interface
*/
export interface BulkUploadResult {
success: boolean;
data: {
totalRows: number;
inserted: number;
failed: number;
errors: Array<{
row: number;
error: string;
data?: Record<string, unknown>;
}>;
};
}
/**
* Bulk upload readings from Excel file
* @param file - Excel file to upload
* @returns Promise resolving to upload result
*/
export async function bulkUploadReadings(file: File): Promise<BulkUploadResult> {
const token = localStorage.getItem('grh_access_token');
if (!token) {
throw new Error('No hay sesión activa. Por favor inicia sesión nuevamente.');
}
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/readings`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Error en la carga masiva');
}
return response.json();
}
/**
* Download readings template Excel file
*/
export async function downloadReadingTemplate(): Promise<void> {
const token = localStorage.getItem('grh_access_token');
if (!token) {
throw new Error('No hay sesión activa. Por favor inicia sesión nuevamente.');
}
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/readings/template`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const errorData = await response.json();
throw new Error(errorData.error || `Error ${response.status}: ${response.statusText}`);
}
throw new Error(`Error ${response.status}: ${response.statusText}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'plantilla_lecturas.xlsx';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}

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}`);
}

133
src/api/types.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* API Types and Error Classes
* Common types used across the API client
*/
/**
* Standard API response wrapper for successful responses
*/
export interface ApiSuccessResponse<T> {
success: true;
data: T;
}
/**
* Standard API response wrapper for error responses
*/
export interface ApiErrorResponse {
success: false;
error: {
message: string;
errors?: string[];
};
}
/**
* Union type for all API responses
*/
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;
/**
* Pagination metadata
*/
export interface PaginationMeta {
page: number;
pageSize: number;
total: number;
totalPages: number;
}
/**
* Paginated response wrapper
*/
export interface PaginatedResponse<T> {
success: true;
data: T[];
pagination: PaginationMeta;
}
/**
* Custom API Error class with status code and validation errors
*/
export class ApiError extends Error {
public readonly status: number;
public readonly errors?: string[];
constructor(message: string, status: number, errors?: string[]) {
super(message);
this.name = 'ApiError';
this.status = status;
this.errors = errors;
// Ensure instanceof works correctly
Object.setPrototypeOf(this, ApiError.prototype);
}
/**
* Check if this error is an authentication error
*/
isAuthError(): boolean {
return this.status === 401;
}
/**
* Check if this error is a forbidden error
*/
isForbiddenError(): boolean {
return this.status === 403;
}
/**
* Check if this error is a not found error
*/
isNotFoundError(): boolean {
return this.status === 404;
}
/**
* Check if this error is a validation error
*/
isValidationError(): boolean {
return this.status === 400 || this.status === 422;
}
/**
* Check if this error is a server error
*/
isServerError(): boolean {
return this.status >= 500;
}
/**
* Convert error to a plain object for logging or serialization
*/
toJSON(): Record<string, unknown> {
return {
name: this.name,
message: this.message,
status: this.status,
errors: this.errors,
};
}
}
/**
* Type guard to check if a response is successful
*/
export function isApiSuccess<T>(response: ApiResponse<T>): response is ApiSuccessResponse<T> {
return response.success === true;
}
/**
* Type guard to check if a response is an error
*/
export function isApiError<T>(response: ApiResponse<T>): response is ApiErrorResponse {
return response.success === false;
}
/**
* Type guard to check if an error is an ApiError instance
*/
export function isApiErrorInstance(error: unknown): error is ApiError {
return error instanceof ApiError;
}

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,14 +1,17 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import {
Home,
Settings,
WaterDrop,
ExpandMore,
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;
@@ -17,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 (
@@ -50,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")}
@@ -61,7 +71,7 @@ export default function Sidebar({ setPage }: SidebarProps) {
</button>
</li>
{/* PROJECT MANAGEMENT */}
{/* PROJECT MANAGEMENT - visible to all */}
<li>
<button
onClick={() => isExpanded && setSystemOpen(!systemOpen)}
@@ -106,11 +116,42 @@ export default function Sidebar({ setPage }: SidebarProps) {
Meters
</button>
</li>
<li>
<button
onClick={() => setPage("consumption")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
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)}
@@ -137,6 +178,8 @@ export default function Sidebar({ setPage }: SidebarProps) {
Users
</button>
</li>
{/* Roles - ADMIN only */}
{isAdmin && (
<li>
<button
onClick={() => setPage("roles")}
@@ -145,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.areaName))],
() => [...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.areaName === 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

@@ -1,27 +1,26 @@
import { useMemo, useState } from "react";
import { Lock, User, Eye, EyeOff, Loader2, Check } from "lucide-react";
import { Lock, Mail, Eye, EyeOff, Loader2 } from "lucide-react";
import grhWatermark from "../assets/images/grhWatermark.png";
import { login } from "../api/auth";
type Form = { usuario: string; contrasena: string };
type Form = { email: string; password: string };
type LoginPageProps = {
onSuccess: (payload?: { token?: string }) => void;
onSuccess: () => void;
};
export default function LoginPage({ onSuccess }: LoginPageProps) {
const [form, setForm] = useState<Form>({ usuario: "", contrasena: "" });
const [form, setForm] = useState<Form>({ email: "", password: "" });
const [showPass, setShowPass] = useState(false);
const [loading, setLoading] = useState(false);
const [serverError, setServerError] = useState("");
const [notRobot, setNotRobot] = useState(false);
const errors = useMemo(() => {
const e: Partial<Record<keyof Form | "robot", string>> = {};
if (!form.usuario.trim()) e.usuario = "El usuario es obligatorio.";
if (!form.contrasena) e.contrasena = "La contraseña es obligatoria.";
if (!notRobot) e.robot = "Confirma que no eres un robot.";
const e: Partial<Record<keyof Form, string>> = {};
if (!form.email.trim()) e.email = "El correo es obligatorio.";
if (!form.password) e.password = "La contraseña es obligatoria.";
return e;
}, [form.usuario, form.contrasena, notRobot]);
}, [form.email, form.password]);
const canSubmit = Object.keys(errors).length === 0 && !loading;
@@ -30,188 +29,168 @@ export default function LoginPage({ onSuccess }: LoginPageProps) {
setServerError("");
if (!canSubmit) return;
try {
setLoading(true);
await new Promise((r) => setTimeout(r, 700));
onSuccess({ token: "demo" });
} catch {
setServerError("No se pudo iniciar sesión. Verifica tus datos.");
try {
await login({ email: form.email, password: form.password });
onSuccess();
} catch (err) {
setServerError(err instanceof Error ? err.message : "Error de autenticación");
} finally {
setLoading(false);
}
}
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>
)}
{/* Usuario */}
{/* Email */}
<div>
<label className="block text-sm font-medium text-slate-700">
Usuario
<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
value={form.usuario}
type="email"
value={form.email}
onChange={(e) =>
setForm((s) => ({ ...s, usuario: e.target.value }))
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"
/>
<User
className="absolute right-1 top-1/2 -translate-y-1/2 text-slate-500"
<Mail
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400"
size={18}
/>
</div>
{errors.usuario && (
<p className="mt-1 text-xs text-red-600">
{errors.usuario}
</p>
{errors.email && (
<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.contrasena}
value={form.password}
onChange={(e) =>
setForm((s) => ({ ...s, contrasena: e.target.value }))
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.contrasena && (
<p className="mt-1 text-xs text-red-600">
{errors.contrasena}
</p>
{errors.password && (
<p className="mt-1.5 text-xs text-red-600">{errors.password}</p>
)}
</div>
{/* NO SOY UN ROBOT */}
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
<button
type="button"
onClick={() => setNotRobot((v) => !v)}
className={`h-5 w-5 rounded border flex items-center justify-center ${
notRobot
? "bg-blue-600 border-blue-600 text-white"
: "bg-white border-slate-300"
}`}
>
{notRobot && <Check size={14} />}
</button>
<span className="text-sm text-slate-700">No soy un robot</span>
<span className="ml-auto text-xs text-slate-400">
reCAPTCHA
</span>
</div>
{errors.robot && (
<p className="text-xs text-red-600">{errors.robot}</p>
)}
{/* Botón */}
<button
type="submit"
disabled={!canSubmit}
className="mt-2 w-full rounded-full bg-gradient-to-r from-[#4c5f9e] to-[#566bb8] py-2.5 text-white shadow-md flex items-center justify-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
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

@@ -1,381 +1,191 @@
// src/pages/concentrators/ConcentratorsModal.tsx
import type React from "react";
import type { Concentrator } from "../../api/concentrators";
import type { GatewayData } from "./ConcentratorsPage";
import { useEffect, useState } from "react";
import type { ConcentratorInput } from "../../api/concentrators";
import { fetchProjects, type Project } from "../../api/projects";
type Props = {
editingSerial: string | null;
form: Omit<Concentrator, "id">;
setForm: React.Dispatch<React.SetStateAction<Omit<Concentrator, "id">>>;
gatewayForm: GatewayData;
setGatewayForm: React.Dispatch<React.SetStateAction<GatewayData>>;
editingId: string | null;
form: ConcentratorInput;
setForm: React.Dispatch<React.SetStateAction<ConcentratorInput>>;
errors: Record<string, boolean>;
setErrors: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
toDatetimeLocalValue: (value?: string) => string;
fromDatetimeLocalValue: (value: string) => string;
allProjects: string[];
onClose: () => void;
onSave: () => void | Promise<void>;
};
export default function ConcentratorsModal({
editingSerial,
editingId,
form,
setForm,
gatewayForm,
setGatewayForm,
errors,
setErrors,
toDatetimeLocalValue,
fromDatetimeLocalValue,
onClose,
onSave,
}: Props) {
const title = editingSerial ? "Edit Concentrator" : "Add Concentrator";
const title = editingId ? "Editar Concentrador" : "Agregar Concentrador";
const [projects, setProjects] = useState<Project[]>([]);
const [loadingProjects, setLoadingProjects] = useState(true);
useEffect(() => {
const load = async () => {
try {
const data = await fetchProjects();
setProjects(data);
} catch (error) {
console.error("Error loading projects:", error);
} finally {
setLoadingProjects(false);
}
};
load();
}, []);
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-[700px] 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">
Concentrator Information
<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>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-gray-50"
placeholder="Area Name"
value={form["Area Name"] ?? ""}
disabled
/>
<p className="text-xs text-gray-400 mt-1">
El proyecto seleccionado define el Area Name.
</p>
</div>
<div>
<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["Device S/N"] ? "border-red-500" : ""
errors["serialNumber"] ? "border-red-500" : ""
}`}
placeholder="Device S/N *"
value={form["Device S/N"]}
placeholder="Número de serie"
value={form.serialNumber}
onChange={(e) => {
setForm({ ...form, "Device S/N": e.target.value });
if (errors["Device S/N"])
setErrors({ ...errors, "Device S/N": false });
setForm({ ...form, serialNumber: e.target.value });
if (errors["serialNumber"]) setErrors({ ...errors, serialNumber: false });
}}
required
/>
{errors["Device S/N"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
{errors["serialNumber"] && (
<p className="text-red-500 text-xs mt-1">Campo requerido</p>
)}
</div>
<div>
<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" : ""
}`}
placeholder="Nombre del concentrador"
value={form.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value });
if (errors["name"]) setErrors({ ...errors, name: false });
}}
required
/>
{errors["name"] && <p className="text-red-500 text-xs mt-1">Campo requerido</p>}
</div>
</div>
<div>
<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" : ""
}`}
value={form.projectId}
onChange={(e) => {
setForm({ ...form, projectId: e.target.value });
if (errors["projectId"]) setErrors({ ...errors, projectId: false });
}}
disabled={loadingProjects}
required
>
<option value="">
{loadingProjects ? "Cargando..." : "Selecciona un proyecto"}
</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
{errors["projectId"] && (
<p className="text-red-500 text-xs mt-1">Selecciona un proyecto</p>
)}
</div>
<div>
<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)"
value={form.location ?? ""}
onChange={(e) => setForm({ ...form, location: e.target.value || undefined })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Device Name"] ? "border-red-500" : ""
}`}
placeholder="Device Name *"
value={form["Device Name"]}
onChange={(e) => {
setForm({ ...form, "Device Name": e.target.value });
if (errors["Device Name"])
setErrors({ ...errors, "Device Name": false });
}}
required
/>
{errors["Device Name"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
<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["Device Status"]}
onChange={(e) =>
setForm({
...form,
"Device Status": e.target.value as any,
})
}
value={form.type ?? "LORA"}
onChange={(e) => setForm({ ...form, type: e.target.value as "LORA" | "LORAWAN" | "GRANDES" })}
>
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
<option value="LORA">LoRa</option>
<option value="LORAWAN">LoRaWAN</option>
<option value="GRANDES">Grandes Consumidores</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Operator"] ? "border-red-500" : ""
}`}
placeholder="Operator *"
value={form["Operator"]}
onChange={(e) => {
setForm({ ...form, Operator: e.target.value });
if (errors["Operator"])
setErrors({ ...errors, Operator: false });
}}
required
/>
{errors["Operator"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
<div>
<input
type="date"
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Installed Time"] ? "border-red-500" : ""
}`}
value={(form["Installed Time"] ?? "").slice(0, 10)}
onChange={(e) => {
setForm({ ...form, "Installed Time": e.target.value });
if (errors["Installed Time"])
setErrors({ ...errors, "Installed Time": false });
}}
required
/>
{errors["Installed Time"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<input
type="datetime-local"
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Device Time"] ? "border-red-500" : ""
}`}
value={toDatetimeLocalValue(form["Device Time"])}
onChange={(e) => {
setForm({
...form,
"Device Time": fromDatetimeLocalValue(e.target.value),
});
if (errors["Device Time"])
setErrors({ ...errors, "Device Time": false });
}}
required
/>
{errors["Device Time"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
<div>
<input
type="datetime-local"
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Communication Time"] ? "border-red-500" : ""
}`}
value={toDatetimeLocalValue(form["Communication Time"])}
onChange={(e) => {
setForm({
...form,
"Communication Time": fromDatetimeLocalValue(e.target.value),
});
if (errors["Communication Time"])
setErrors({ ...errors, "Communication Time": false });
}}
required
/>
{errors["Communication Time"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
</div>
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Instruction Manual"] ? "border-red-500" : ""
}`}
placeholder="Instruction Manual *"
value={form["Instruction Manual"]}
onChange={(e) => {
setForm({ ...form, "Instruction Manual": e.target.value });
if (errors["Instruction Manual"])
setErrors({ ...errors, "Instruction Manual": false });
}}
required
/>
{errors["Instruction Manual"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
</div>
{/* GATEWAY */}
<div className="space-y-3 pt-4">
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
Gateway Configuration
</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<input
type="number"
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Gateway ID"] ? "border-red-500" : ""
}`}
placeholder="Gateway ID *"
value={gatewayForm["Gateway ID"] || ""}
onChange={(e) => {
setGatewayForm({
...gatewayForm,
"Gateway ID": parseInt(e.target.value) || 0,
});
if (errors["Gateway ID"])
setErrors({ ...errors, "Gateway ID": false });
}}
required
min={1}
/>
{errors["Gateway ID"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Gateway EUI"] ? "border-red-500" : ""
}`}
placeholder="Gateway EUI *"
value={gatewayForm["Gateway EUI"]}
onChange={(e) => {
setGatewayForm({
...gatewayForm,
"Gateway EUI": e.target.value,
});
if (errors["Gateway EUI"])
setErrors({ ...errors, "Gateway EUI": false });
}}
required
/>
{errors["Gateway EUI"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Gateway Name"] ? "border-red-500" : ""
}`}
placeholder="Gateway Name *"
value={gatewayForm["Gateway Name"]}
onChange={(e) => {
setGatewayForm({
...gatewayForm,
"Gateway Name": e.target.value,
});
if (errors["Gateway Name"])
setErrors({ ...errors, "Gateway Name": false });
}}
required
/>
{errors["Gateway Name"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</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={gatewayForm["Antenna Placement"]}
onChange={(e) =>
setGatewayForm({
...gatewayForm,
"Antenna Placement": e.target.value as "Indoor" | "Outdoor",
})
}
value={form.status ?? "ACTIVE"}
onChange={(e) => setForm({ ...form, status: e.target.value })}
>
<option value="Indoor">Indoor</option>
<option value="Outdoor">Outdoor</option>
<option value="ACTIVE">Activo</option>
<option value="INACTIVE">Inactivo</option>
<option value="MAINTENANCE">Mantenimiento</option>
<option value="OFFLINE">Sin conexión</option>
</select>
</div>
</div>
<div>
<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 ${
errors["Gateway Description"] ? "border-red-500" : ""
}`}
placeholder="Gateway Description *"
value={gatewayForm["Gateway Description"]}
onChange={(e) => {
setGatewayForm({
...gatewayForm,
"Gateway Description": e.target.value,
});
if (errors["Gateway Description"])
setErrors({ ...errors, "Gateway Description": false });
}}
required
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="192.168.1.100"
value={form.ipAddress ?? ""}
onChange={(e) => setForm({ ...form, ipAddress: e.target.value || undefined })}
/>
</div>
<div>
<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"
value={form.firmwareVersion ?? ""}
onChange={(e) => setForm({ ...form, firmwareVersion: e.target.value || undefined })}
/>
{errors["Gateway Description"] && (
<p className="text-red-500 text-xs mt-1">
This field is required
</p>
)}
</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"
>
Cancel
<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
onClick={onSave}
className="bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e]"
>
Save
Guardar
</button>
</div>
</div>

View File

@@ -1,23 +1,23 @@
// src/pages/concentrators/ConcentratorsPage.tsx
import { useMemo, useState } from "react";
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
import ConfirmModal from "../../components/layout/common/ConfirmModal";
import { createConcentrator, deleteConcentrator, updateConcentrator, type Concentrator } from "../../api/concentrators";
// ✅ hook es named export y pide currentUser
import {
createConcentrator,
deleteConcentrator,
updateConcentrator,
type Concentrator,
type ConcentratorInput,
} from "../../api/concentrators";
import { useConcentrators } from "./useConcentrators";
// ✅ UI pieces
import ConcentratorsSidebar from "./ConcentratorsSidebar";
import ConcentratorsTable from "./ConcentratorsTable";
import ConcentratorsModal from "./ConcentratorsModal";
export type SampleView = "GENERAL" | "LORA" | "LORAWAN" | "GRANDES";
export type ProjectStatus = "ACTIVO" | "INACTIVO";
export type ProjectCard = {
id: string;
name: string;
region: string;
projects: number;
@@ -28,123 +28,67 @@ export type ProjectCard = {
status: ProjectStatus;
};
type User = {
role: "SUPER_ADMIN" | "USER";
project?: string;
};
export type GatewayData = {
"Gateway ID": number;
"Gateway EUI": string;
"Gateway Name": string;
"Gateway Description": string;
"Antenna Placement": "Indoor" | "Outdoor";
concentratorId?: string;
};
export default function ConcentratorsPage() {
// ✅ Simulación de usuario actual
const currentUser: User = {
role: "SUPER_ADMIN",
project: "CESPT",
};
// ✅ Hook (solo cubre: projects + fetch + sampleView + selectedProject + loading + projectsData)
const c = useConcentrators(currentUser);
const c = useConcentrators();
const [typesMenuOpen, setTypesMenuOpen] = useState(false);
const [search, setSearch] = useState("");
const [activeConcentrator, setActiveConcentrator] = useState<Concentrator | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [showModal, setShowModal] = useState(false);
const [editingSerial, setEditingSerial] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const getEmptyConcentrator = (): Omit<Concentrator, "id"> => ({
"Area Name": c.selectedProject,
"Device S/N": "",
"Device Name": "",
"Device Time": new Date().toISOString(),
"Device Status": "ACTIVE",
Operator: "",
"Installed Time": new Date().toISOString().slice(0, 10),
"Communication Time": new Date().toISOString(),
"Instruction Manual": "",
const getEmptyForm = (): ConcentratorInput => ({
serialNumber: "",
name: "",
projectId: "",
location: "",
type: "LORA",
status: "ACTIVE",
ipAddress: "",
firmwareVersion: "",
});
const getEmptyGatewayData = (): GatewayData => ({
"Gateway ID": 0,
"Gateway EUI": "",
"Gateway Name": "",
"Gateway Description": "",
"Antenna Placement": "Indoor",
});
const [form, setForm] = useState<ConcentratorInput>(getEmptyForm());
const [errors, setErrors] = useState<Record<string, boolean>>({});
const [form, setForm] = useState<Omit<Concentrator, "id">>(getEmptyConcentrator());
const [gatewayForm, setGatewayForm] = useState<GatewayData>(getEmptyGatewayData());
const [errors, setErrors] = useState<{ [key: string]: boolean }>({});
// ✅ Tabla filtrada por search (usa lo que YA filtró el hook por proyecto)
const searchFiltered = useMemo(() => {
if (!c.isGeneral) return [];
return c.filteredConcentrators.filter((row) => {
const q = search.trim().toLowerCase();
if (!q) return true;
const name = (row["Device Name"] ?? "").toLowerCase();
const sn = (row["Device S/N"] ?? "").toLowerCase();
const name = (row.name ?? "").toLowerCase();
const sn = (row.serialNumber ?? "").toLowerCase();
return name.includes(q) || sn.includes(q);
});
}, [c.filteredConcentrators, c.isGeneral, search]);
}, [c.filteredConcentrators, search]);
// =========================
// CRUD (solo GENERAL)
// =========================
const validateForm = () => {
const next: { [key: string]: boolean } = {};
const next: Record<string, boolean> = {};
if (!form["Device Name"].trim()) next["Device Name"] = true;
if (!form["Device S/N"].trim()) next["Device S/N"] = true;
if (!form["Operator"].trim()) next["Operator"] = true;
if (!form["Instruction Manual"].trim()) next["Instruction Manual"] = true;
if (!form["Installed Time"]) next["Installed Time"] = true;
if (!form["Device Time"]) next["Device Time"] = true;
if (!form["Communication Time"]) next["Communication Time"] = true;
if (!gatewayForm["Gateway ID"] || gatewayForm["Gateway ID"] === 0) next["Gateway ID"] = true;
if (!gatewayForm["Gateway EUI"].trim()) next["Gateway EUI"] = true;
if (!gatewayForm["Gateway Name"].trim()) next["Gateway Name"] = true;
if (!gatewayForm["Gateway Description"].trim()) next["Gateway Description"] = true;
if (!form.name.trim()) next["name"] = true;
if (!form.serialNumber.trim()) next["serialNumber"] = true;
if (!form.projectId.trim()) next["projectId"] = true;
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSave = async () => {
if (!c.isGeneral) return;
if (!validateForm()) return;
try {
if (editingSerial) {
const toUpdate = c.concentrators.find((x) => x["Device S/N"] === editingSerial);
if (!toUpdate) throw new Error("Concentrator not found");
const updated = await updateConcentrator(toUpdate.id, form);
// actualiza en memoria (el hook expone setConcentrators)
c.setConcentrators((prev) => prev.map((x) => (x.id === toUpdate.id ? updated : x)));
if (editingId) {
const updated = await updateConcentrator(editingId, form);
c.setConcentrators((prev) => prev.map((x) => (x.id === editingId ? updated : x)));
} else {
const created = await createConcentrator(form);
c.setConcentrators((prev) => [...prev, created]);
}
setShowModal(false);
setEditingSerial(null);
setForm({ ...getEmptyConcentrator(), "Area Name": c.selectedProject });
setGatewayForm(getEmptyGatewayData());
setEditingId(null);
setForm(getEmptyForm());
setErrors({});
setActiveConcentrator(null);
} catch (err) {
@@ -154,7 +98,6 @@ export default function ConcentratorsPage() {
};
const handleDelete = async () => {
if (!c.isGeneral) return;
if (!activeConcentrator) return;
try {
@@ -167,31 +110,33 @@ export default function ConcentratorsPage() {
}
};
// =========================
// Date helpers para modal
// =========================
function toDatetimeLocalValue(value?: string) {
if (!value) return "";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
const yyyy = d.getFullYear();
const mm = pad(d.getMonth() + 1);
const dd = pad(d.getDate());
const hh = pad(d.getHours());
const mi = pad(d.getMinutes());
return `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
}
const openEditModal = () => {
if (!activeConcentrator) return;
function fromDatetimeLocalValue(value: string) {
if (!value) return "";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "";
return d.toISOString();
}
setEditingId(activeConcentrator.id);
setForm({
serialNumber: activeConcentrator.serialNumber,
name: activeConcentrator.name,
projectId: activeConcentrator.projectId,
location: activeConcentrator.location ?? "",
type: activeConcentrator.type ?? "LORA",
status: activeConcentrator.status,
ipAddress: activeConcentrator.ipAddress ?? "",
firmwareVersion: activeConcentrator.firmwareVersion ?? "",
});
setErrors({});
setShowModal(true);
};
const openCreateModal = () => {
setForm(getEmptyForm());
setErrors({});
setEditingId(null);
setShowModal(true);
};
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}
@@ -202,8 +147,6 @@ export default function ConcentratorsPage() {
onChangeSampleView={(next: SampleView) => {
c.setSampleView(next);
setTypesMenuOpen(false);
// resets UI
c.setSelectedProject("");
setActiveConcentrator(null);
setSearch("");
@@ -216,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>
@@ -228,57 +180,26 @@ 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>
<div className="flex gap-3">
<button
onClick={() => {
if (!c.isGeneral) return;
if (!c.selectedProject) return;
setForm({ ...getEmptyConcentrator(), "Area Name": c.selectedProject });
setGatewayForm(getEmptyGatewayData());
setErrors({});
setEditingSerial(null);
setShowModal(true);
}}
disabled={!c.isGeneral || !c.selectedProject}
onClick={openCreateModal}
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
</button>
<button
onClick={() => {
if (!c.isGeneral) return;
if (!activeConcentrator) return;
const a = activeConcentrator;
setEditingSerial(a["Device S/N"]);
setForm({
"Area Name": a["Area Name"],
"Device S/N": a["Device S/N"],
"Device Name": a["Device Name"],
"Device Time": a["Device Time"],
"Device Status": a["Device Status"],
Operator: a["Operator"],
"Installed Time": a["Installed Time"],
"Communication Time": a["Communication Time"],
"Instruction Manual": a["Instruction Manual"],
});
setGatewayForm(getEmptyGatewayData());
setErrors({});
setShowModal(true);
}}
disabled={!c.isGeneral || !activeConcentrator}
onClick={openEditModal}
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
@@ -286,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
@@ -294,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
@@ -303,27 +224,29 @@ export default function ConcentratorsPage() {
</div>
<input
className="bg-white rounded-lg shadow px-4 py-2 text-sm"
placeholder={c.isGeneral ? "Search concentrator..." : "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
? "Select a project to view concentrators."
? "Selecciona un proyecto para ver los concentradores."
: c.loadingConcentrators
? "Loading concentrators..."
: "No concentrators found. Click 'Add' to create your first concentrator."
? "Cargando concentradores..."
: "No hay concentradores. Haz clic en 'Agregar' para crear uno."
}
/>
</div>
@@ -332,15 +255,14 @@ export default function ConcentratorsPage() {
open={confirmOpen}
title="Eliminar concentrador"
message={`¿Estás seguro que quieres eliminar "${
activeConcentrator?.["Device Name"] ?? "este concentrador"
}"? Esta acción no se puede deshacer.`}
activeConcentrator?.name ?? "este concentrador"
}" (${activeConcentrator?.serialNumber ?? "—"})? Esta acción no se puede deshacer.`}
confirmText="Eliminar"
cancelText="Cancelar"
danger
loading={deleting}
onClose={() => setConfirmOpen(false)}
onConfirm={async () => {
if (!c.isGeneral) return;
setDeleting(true);
try {
await handleDelete();
@@ -352,20 +274,16 @@ export default function ConcentratorsPage() {
/>
</main>
{showModal && c.isGeneral && (
{showModal && (
<ConcentratorsModal
editingSerial={editingSerial}
editingId={editingId}
form={form}
setForm={setForm}
gatewayForm={gatewayForm}
setGatewayForm={setGatewayForm}
errors={errors}
setErrors={setErrors}
toDatetimeLocalValue={toDatetimeLocalValue}
fromDatetimeLocalValue={fromDatetimeLocalValue}
allProjects={c.allProjects}
onClose={() => {
setShowModal(false);
setGatewayForm(getEmptyGatewayData());
setErrors({});
}}
onSave={handleSave}

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,16 +59,16 @@ 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">{selectedProject || "—"}</span>
<span className="font-semibold">
{projects.find((p) => p.id === selectedProject)?.name || "—"}
</span>
</p>
</div>
@@ -74,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 (
@@ -103,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}
@@ -122,35 +114,57 @@ 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) => {
const active = p.name === selectedProject;
const active = p.id === selectedProject;
return (
<div
key={p.name}
onClick={() => onSelectProject(p.name)}
key={p.id}
onClick={() => onSelectProject(p.id)}
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
@@ -158,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}
@@ -167,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>
@@ -211,7 +225,7 @@ export default function ConcentratorsSidebar({
].join(" ")}
onClick={(e) => {
e.stopPropagation();
onSelectProject(p.name);
onSelectProject(p.id);
}}
>
{active ? "Seleccionado" : "Seleccionar"}
@@ -223,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

@@ -23,45 +23,69 @@ export default function ConcentratorsTable({
isLoading={isLoading}
columns={[
{
title: "Device Name",
field: "Device Name",
render: (rowData: any) => rowData["Device Name"] || "-",
title: "Serial",
field: "serialNumber",
render: (rowData: Concentrator) => rowData.serialNumber || "-",
},
{
title: "Device S/N",
field: "Device S/N",
render: (rowData: any) => rowData["Device S/N"] || "-",
title: "Nombre",
field: "name",
render: (rowData: Concentrator) => rowData.name || "-",
},
{
title: "Device Status",
field: "Device Status",
render: (rowData: any) => (
title: "Tipo",
field: "type",
render: (rowData: Concentrator) => {
const typeLabels: Record<string, string> = {
LORA: "LoRa",
LORAWAN: "LoRaWAN",
GRANDES: "Grandes Consumidores",
};
const typeColors: Record<string, string> = {
LORA: "text-green-600 border-green-600",
LORAWAN: "text-purple-600 border-purple-600",
GRANDES: "text-orange-600 border-orange-600",
};
const type = rowData.type || "LORA";
return (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${typeColors[type] || "text-gray-600 border-gray-600"}`}
>
{typeLabels[type] || type}
</span>
);
},
},
{
title: "Estado",
field: "status",
render: (rowData: Concentrator) => (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${
rowData["Device Status"] === "ACTIVE"
rowData.status === "ACTIVE"
? "text-blue-600 border-blue-600"
: "text-red-600 border-red-600"
}`}
>
{rowData["Device Status"] || "-"}
{rowData.status || "-"}
</span>
),
},
{
title: "Operator",
field: "Operator",
render: (rowData: any) => rowData["Operator"] || "-",
title: "Ubicación",
field: "location",
render: (rowData: Concentrator) => rowData.location || "-",
},
{
title: "Area Name",
field: "Area Name",
render: (rowData: any) => rowData["Area Name"] || "-",
title: "IP",
field: "ipAddress",
render: (rowData: Concentrator) => rowData.ipAddress || "-",
},
{
title: "Installed Time",
field: "Installed Time",
type: "date",
render: (rowData: any) => rowData["Installed Time"] || "-",
title: "Última Comunicación",
field: "lastCommunication",
type: "datetime",
render: (rowData: Concentrator) => rowData.lastCommunication ? new Date(rowData.lastCommunication).toLocaleString() : "-",
},
]}
data={data}
@@ -70,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,23 +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[]
@@ -43,79 +51,76 @@ 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 loadConcentrators = async () => {
if (!isGeneral) return;
setLoadingConcentrators(true);
setLoadingProjects(true);
const loadMeterTypes = async () => {
setLoadingMeterTypes(true);
try {
const raw = await fetchConcentrators();
const normalized = raw.map((c: any) => {
const preferredName =
c["Device Alias"] ||
c["Device Label"] ||
c["Device Display Name"] ||
c.deviceName ||
c.name ||
c["Device Name"] ||
"";
return {
...c,
"Device Name": preferredName,
const meterTypesData = await fetchMeterTypes();
setMeterTypes(meterTypesData);
} catch (err) {
console.error("Error loading meter types:", err);
setMeterTypes([]);
} finally {
setLoadingMeterTypes(false);
}
};
});
const projectsArray = [
...new Set(normalized.map((r: any) => r["Area Name"])),
].filter(Boolean) as string[];
setAllProjects(projectsArray);
setConcentrators(normalized);
const loadProjects = async () => {
setLoadingProjects(true);
try {
const projectsData = await fetchProjects();
setProjects(projectsData);
const projectIds = projectsData.map((p) => p.id);
setAllProjects(projectIds);
setSelectedProject((prev) => {
if (prev) return prev;
if (currentUser.role !== "SUPER_ADMIN" && currentUser.project) {
return currentUser.project;
if (!isAdmin && userProjectId) {
return userProjectId;
}
return projectsArray[0] ?? "";
return projectIds[0] ?? "";
});
} catch (err) {
console.error("Error loading concentrators:", err);
console.error("Error loading projects:", err);
setProjects([]);
setAllProjects([]);
setConcentrators([]);
setSelectedProject("");
} finally {
setLoadingConcentrators(false);
setLoadingProjects(false);
}
};
// init
const loadConcentrators = async () => {
setLoadingConcentrators(true);
try {
const data = await fetchConcentrators();
setConcentrators(data);
} catch (err) {
console.error("Error loading concentrators:", err);
setConcentrators([]);
} finally {
setLoadingConcentrators(false);
}
};
useEffect(() => {
loadMeterTypes();
loadProjects();
loadConcentrators();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// view changes
useEffect(() => {
if (isGeneral) {
loadProjects();
loadConcentrators();
} else {
setLoadingProjects(false);
setLoadingConcentrators(false);
setSelectedProject("");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sampleView]);
@@ -127,27 +132,48 @@ 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["Area Name"] === 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) => {
const area = c["Area Name"] ?? "SIN PROYECTO";
acc[area] = (acc[area] ?? 0) + 1;
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;
}, {});
const projectNameMap = projects.reduce<Record<string, string>>((acc, p) => {
acc[p.id] = p.name;
return acc;
}, {});
@@ -155,76 +181,46 @@ export function useConcentrators(currentUser: User) {
const baseContact = "Operaciones";
const baseLastSync = "Hace 1 h";
return visibleProjects.map((name) => ({
name,
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,
projects: 1,
concentrators: counts[name] ?? 0,
concentrators: counts[projectId] ?? 0,
activeAlerts: 0,
lastSync: baseLastSync,
contact: baseContact,
status: "ACTIVO",
status: "ACTIVO" as const,
}));
}, [concentrators, visibleProjects]);
// sidebar cards (mock)
const projectsDataMock: Record<Exclude<SampleView, "GENERAL">, ProjectCard[]> =
useMemo(
() => ({
LORA: [
{
name: "LoRa - Zona Centro",
region: "Baja California",
projects: 1,
concentrators: 12,
activeAlerts: 1,
lastSync: "Hace 15 min",
contact: "Operaciones",
status: "ACTIVO",
},
{
name: "LoRa - Zona Este",
region: "Baja California",
projects: 1,
concentrators: 8,
activeAlerts: 0,
lastSync: "Hace 40 min",
contact: "Operaciones",
status: "ACTIVO",
},
],
LORAWAN: [
{
name: "LoRaWAN - Industrial",
region: "Baja California",
projects: 1,
concentrators: 5,
activeAlerts: 0,
lastSync: "Hace 1 h",
contact: "Operaciones",
status: "ACTIVO",
},
],
GRANDES: [
{
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
@@ -236,6 +232,7 @@ export function useConcentrators(currentUser: User) {
// loading
loadingProjects,
loadingConcentrators,
loadingMeterTypes,
// projects
allProjects,
@@ -244,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

@@ -0,0 +1,727 @@
import { useEffect, useState, useMemo } from "react";
import {
RefreshCcw,
Download,
Search,
Droplets,
TrendingUp,
Zap,
Clock,
ChevronLeft,
ChevronRight,
Filter,
X,
Activity,
Upload,
} from "lucide-react";
import {
fetchReadings,
fetchConsumptionSummary,
type MeterReading,
type ConsumptionSummary,
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: 10,
total: 0,
totalPages: 0,
});
const [loadingReadings, setLoadingReadings] = useState(false);
const [loadingSummary, setLoadingSummary] = useState(false);
const [selectedProject, setSelectedProject] = useState<string>("");
const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>("");
const [search, setSearch] = useState<string>("");
const [showFilters, setShowFilters] = useState(false);
const [showBulkUpload, setShowBulkUpload] = useState(false);
useEffect(() => {
const loadProjects = async () => {
try {
const data = await fetchProjects();
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, pageSize?: number) => {
setLoadingReadings(true);
setLoadingSummary(true);
const currentPageSize = pageSize || pagination.pageSize;
try {
const [readingsResult, summaryResult] = await Promise.all([
fetchReadings({
projectId: selectedProject || undefined,
startDate: startDate || undefined,
endDate: endDate || undefined,
page,
pageSize: currentPageSize,
}),
fetchConsumptionSummary(selectedProject || undefined),
]);
setReadings(readingsResult.data);
setPagination(readingsResult.pagination);
setSummary(summaryResult);
} catch (error) {
console.error("Error loading data:", error);
} finally {
setLoadingReadings(false);
setLoadingSummary(false);
}
};
useEffect(() => {
if (isOperator && !selectedProject) {
return;
}
loadData(1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedProject, startDate, endDate]);
const filteredReadings = useMemo(() => {
if (!search.trim()) return readings;
const q = search.toLowerCase();
return readings.filter(
(r) =>
(r.meterSerialNumber ?? "").toLowerCase().includes(q) ||
(r.meterName ?? "").toLowerCase().includes(q) ||
(r.meterLocation ?? "").toLowerCase().includes(q) ||
String(r.readingValue).includes(q)
);
}, [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);
return date.toLocaleString("es-MX", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
};
const formatFullDate = (dateStr: string | null): string => {
if (!dateStr) return "Sin datos";
const date = new Date(dateStr);
return date.toLocaleString("es-MX", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
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) => [
formatFullDate(r.receivedAt),
r.meterName || "—",
r.meterSerialNumber || "—",
r.meterLocation || "—",
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);
link.download = `consumo_${new Date().toISOString().split("T")[0]}.csv`;
link.click();
};
const clearFilters = () => {
if (!isOperator) {
setSelectedProject("");
}
setStartDate("");
setEndDate("");
setSearch("");
};
const hasFilters = selectedProject || startDate || endDate;
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 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">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>
<div className="flex items-center gap-2">
<button
onClick={() => setShowBulkUpload(true)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-emerald-500 to-teal-600 rounded-xl hover:from-emerald-600 hover:to-teal-700 transition-all shadow-sm shadow-emerald-500/25"
>
<Upload size={16} />
Carga Masiva
</button>
<button
onClick={() => loadData(pagination.page)}
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
</button>
<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-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
</button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={<Activity />}
label="Total Lecturas"
value={summary?.totalReadings.toLocaleString() ?? "0"}
trend="+12%"
loading={loadingSummary}
gradient="from-blue-500 to-blue-600"
/>
<StatCard
icon={<Zap />}
label="Medidores Activos"
value={summary?.totalMeters.toLocaleString() ?? "0"}
loading={loadingSummary}
gradient="from-emerald-500 to-teal-600"
/>
<StatCard
icon={<Droplets />}
label="Consumo Promedio"
value={`${summary?.avgReading != null ? Number(summary.avgReading).toFixed(1) : "0"}`}
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"
value={summary?.lastReadingDate ? formatDate(summary.lastReadingDate) : "Sin datos"}
loading={loadingSummary}
gradient="from-amber-500 to-orange-600"
/>
</div>
{/* Table 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 overflow-hidden">
{/* Table Header */}
<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
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
/>
<input
type="text"
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 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>
<button
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 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} />
Filtros
{activeFiltersCount > 0 && (
<span className="inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-blue-600 rounded-full">
{activeFiltersCount}
</span>
)}
</button>
{hasFilters && (
<button
onClick={clearFilters}
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>
<div className="flex items-center gap-4 text-sm text-slate-500 dark:text-zinc-400">
<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 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 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={() => loadData(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>
{/* Filters Panel */}
{showFilters && (
<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 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}
>
{!isOperator && <option value="">Todos</option>}
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<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-white border border-slate-200 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-white border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20"
/>
</div>
</div>
)}
{/* Table */}
<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
</th>
<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 dark:text-zinc-400 uppercase tracking-wider">
Serial
</th>
<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 dark:text-zinc-400 uppercase tracking-wider">
Consumo
</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">
Estado
</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: 7 }).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>
))
) : filteredReadings.length === 0 ? (
<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 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">
{hasFilters
? "Intenta ajustar los filtros de búsqueda"
: "Las lecturas aparecerán aquí cuando se reciban datos"}
</p>
</div>
</td>
</tr>
) : (
filteredReadings.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">
<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 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 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 dark:text-zinc-100 tabular-nums">
{Number(reading.readingValue).toFixed(2)}
</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} />
</td>
<td className="px-5 py-3.5">
<div className="flex items-center justify-center gap-2">
<BatteryIndicator level={reading.batteryLevel} />
<SignalIndicator strength={reading.signalStrength} />
</div>
</td>
</tr>
))
)}
</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>
{showBulkUpload && (
<ReadingsBulkUploadModal
onClose={() => setShowBulkUpload(false)}
onSuccess={() => {
loadData(1);
setShowBulkUpload(false);
}}
/>
)}
</div>
);
}
function StatCard({
icon,
label,
value,
trend,
loading,
gradient,
}: {
icon: React.ReactNode;
label: string;
value: string;
trend?: string;
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 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 dark:text-zinc-400">{label}</p>
{loading ? (
<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 dark:text-white">{value}</p>
)}
{trend && !loading && (
<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>
)}
</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`}
>
{icon}
</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 | null }) {
if (level === null) return null;
const getColor = () => {
if (level > 50) return "bg-emerald-500";
if (level > 20) return "bg-amber-500";
return "bg-red-500";
};
return (
<div className="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 | null }) {
if (strength === null) return null;
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="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

@@ -0,0 +1,210 @@
import { useState, useRef } from "react";
import { Upload, Download, X, AlertCircle, CheckCircle } from "lucide-react";
import { bulkUploadReadings, downloadReadingTemplate, type BulkUploadResult } from "../../api/readings";
type Props = {
onClose: () => void;
onSuccess: () => void;
};
export default function ReadingsBulkUploadModal({ onClose, onSuccess }: Props) {
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<BulkUploadResult | null>(null);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
// Validate file type
const validTypes = [
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel",
];
if (!validTypes.includes(selectedFile.type)) {
setError("Solo se permiten archivos Excel (.xlsx, .xls)");
return;
}
setFile(selectedFile);
setError(null);
setResult(null);
}
};
const handleUpload = async () => {
if (!file) return;
setUploading(true);
setError(null);
setResult(null);
try {
const uploadResult = await bulkUploadReadings(file);
setResult(uploadResult);
if (uploadResult.data.inserted > 0) {
onSuccess();
}
} catch (err) {
setError(err instanceof Error ? err.message : "Error en la carga");
} finally {
setUploading(false);
}
};
const handleDownloadTemplate = async () => {
try {
await downloadReadingTemplate();
} catch (err) {
setError(err instanceof Error ? err.message : "Error descargando plantilla");
}
};
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-[600px] max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Carga Masiva de Lecturas</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
<X size={20} />
</button>
</div>
{/* Instructions */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<h3 className="font-medium text-blue-800 mb-2">Instrucciones:</h3>
<ol className="text-sm text-blue-700 space-y-1 list-decimal list-inside">
<li>Descarga la plantilla Excel con el formato correcto</li>
<li>Llena los datos de las lecturas (meter_serial y reading_value son obligatorios)</li>
<li>El meter_serial debe coincidir con un medidor existente</li>
<li>Sube el archivo Excel completado</li>
</ol>
</div>
{/* Download Template Button */}
<button
onClick={handleDownloadTemplate}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 mb-4"
>
<Download size={16} />
Descargar Plantilla Excel
</button>
{/* File Input */}
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 mb-4 text-center">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".xlsx,.xls"
className="hidden"
/>
{file ? (
<div className="flex items-center justify-center gap-2">
<CheckCircle className="text-green-500" size={20} />
<span className="text-gray-700">{file.name}</span>
<button
onClick={() => {
setFile(null);
setResult(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}}
className="text-red-500 hover:text-red-700 ml-2"
>
<X size={16} />
</button>
</div>
) : (
<div>
<Upload className="mx-auto text-gray-400 mb-2" size={32} />
<p className="text-gray-600 mb-2">
Arrastra un archivo Excel aquí o
</p>
<button
onClick={() => fileInputRef.current?.click()}
className="text-blue-600 hover:text-blue-800 font-medium"
>
selecciona un archivo
</button>
</div>
)}
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4 flex items-start gap-2">
<AlertCircle className="text-red-500 shrink-0" size={20} />
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
{/* Upload Result */}
{result && (
<div
className={`border rounded-lg p-4 mb-4 ${
result.success
? "bg-green-50 border-green-200"
: "bg-yellow-50 border-yellow-200"
}`}
>
<h4 className="font-medium mb-2">
{result.success ? "Carga completada" : "Carga completada con errores"}
</h4>
<div className="text-sm space-y-1">
<p>Total de filas: {result.data.totalRows}</p>
<p className="text-green-600">Insertadas: {result.data.inserted}</p>
{result.data.failed > 0 && (
<p className="text-red-600">Fallidas: {result.data.failed}</p>
)}
</div>
{/* Error Details */}
{result.data.errors.length > 0 && (
<div className="mt-3">
<h5 className="font-medium text-sm mb-2">Errores:</h5>
<div className="max-h-40 overflow-y-auto bg-white rounded border p-2">
{result.data.errors.map((err, idx) => (
<div key={idx} className="text-xs text-red-600 py-1 border-b last:border-0">
Fila {err.row}: {err.error}
</div>
))}
</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"
>
{result ? "Cerrar" : "Cancelar"}
</button>
{!result && (
<button
onClick={handleUpload}
disabled={!file || uploading}
className="flex items-center gap-2 bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e] disabled:opacity-50 disabled:cursor-not-allowed"
>
{uploading ? (
<>
<span className="animate-spin"></span>
Cargando...
</>
) : (
<>
<Upload size={16} />
Cargar Lecturas
</>
)}
</button>
)}
</div>
</div>
</div>
);
}

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

@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
import type { Meter } from "../../api/meters";
import { Plus, Trash2, Pencil, RefreshCcw, Upload } from "lucide-react";
import type { Meter, MeterInput } from "../../api/meters";
import { createMeter, deleteMeter, updateMeter } from "../../api/meters";
import ConfirmModal from "../../components/layout/common/ConfirmModal";
@@ -8,16 +8,9 @@ import { useMeters } from "./useMeters";
import MetersSidebar from "./MetersSidebar";
import MetersTable from "./MetersTable";
import MetersModal from "./MetersModal";
import MetersBulkUploadModal from "./MetersBulkUploadModal";
/* ================= TYPES (exportables para otros componentes) ================= */
export interface DeviceData {
"Device ID": number;
"Device EUI": string;
"Join EUI": string;
AppKey: string;
meterId?: string;
}
/* ================= TYPES ================= */
export type ProjectStatus = "ACTIVO" | "INACTIVO";
@@ -34,20 +27,6 @@ export type ProjectCard = {
export type TakeType = "GENERAL" | "LORA" | "LORAWAN" | "GRANDES";
/* ================= MOCKS (sin backend) ================= */
const MOCK_PROJECTS_BY_TYPE: Record<
Exclude<TakeType, "GENERAL">,
Array<{ name: string; meters?: number }>
> = {
LORA: [
{ name: "LoRa - Demo 01", meters: 12 },
{ name: "LoRa - Demo 02", meters: 7 },
],
LORAWAN: [{ name: "LoRaWAN - Demo 01", meters: 4 }],
GRANDES: [{ name: "Grandes - Demo 01", meters: 2 }],
};
/* ================= COMPONENT ================= */
export default function MetersPage({
@@ -57,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("");
@@ -68,162 +46,96 @@ export default function MetersPage({
const [confirmOpen, setConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const emptyMeter: Omit<Meter, "id"> = useMemo(
const [showBulkUpload, setShowBulkUpload] = useState(false);
// Form state for creating/editing meters
const emptyForm: MeterInput = useMemo(
() => ({
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
areaName: "",
accountNumber: null,
userName: null,
userAddress: null,
meterSerialNumber: "",
meterName: "",
meterStatus: "Installed",
protocolType: "",
priceNo: null,
priceName: null,
dmaPartition: null,
supplyTypes: "",
deviceId: "",
deviceName: "",
deviceType: "",
usageAnalysisType: "",
installedTime: new Date().toISOString(),
serialNumber: "",
meterId: "",
name: "",
concentratorId: "",
location: "",
type: "LORA",
status: "ACTIVE",
installationDate: new Date().toISOString(),
}),
[]
);
const emptyDeviceData: DeviceData = useMemo(
() => ({
"Device ID": 0,
"Device EUI": "",
"Join EUI": "",
AppKey: "",
}),
[]
);
const [form, setForm] = useState<Omit<Meter, "id">>(emptyMeter);
const [deviceForm, setDeviceForm] = useState<DeviceData>(emptyDeviceData);
const [form, setForm] = useState<MeterInput>(emptyForm);
const [errors, setErrors] = useState<Record<string, boolean>>({});
// Projects cards (real)
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",
status: "ACTIVO" as ProjectStatus,
}));
}, [m.allProjects, m.projectsCounts]);
}, [m.filteredProjects, m.projectsCounts]);
// Projects cards (mock)
const projectsDataMock: ProjectCard[] = useMemo(() => {
const baseRegion = "Baja California";
const baseContact = "Operaciones";
const baseLastSync = "Hace 1 h";
const sidebarProjects = projectsDataReal;
const mocks = MOCK_PROJECTS_BY_TYPE[takeType as Exclude<TakeType, "GENERAL">] ?? [];
return mocks.map((x) => ({
name: x.name,
region: baseRegion,
projects: 1,
meters: x.meters ?? 0,
activeAlerts: 0,
lastSync: baseLastSync,
contact: baseContact,
status: "ACTIVO",
}));
}, [takeType]);
const sidebarProjects = isMockMode ? projectsDataMock : projectsDataReal;
// Search filtered
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.meterName ?? "").toLowerCase().includes(q) ||
(x.meterSerialNumber ?? "").toLowerCase().includes(q) ||
(x.deviceId ?? "").toLowerCase().includes(q) ||
(x.areaName ?? "").toLowerCase().includes(q)
(x.name ?? "").toLowerCase().includes(q) ||
(x.serialNumber ?? "").toLowerCase().includes(q) ||
(x.location ?? "").toLowerCase().includes(q) ||
(x.concentratorName ?? "").toLowerCase().includes(q)
);
});
}, [isMockMode, search, m.filteredMeters]);
// Device config mock
const createOrUpdateDevice = async (deviceData: DeviceData): Promise<void> => {
return new Promise((resolve) => {
setTimeout(() => {
console.log("Device data that would be sent to API:", deviceData);
resolve();
}, 500);
});
};
}, [takeType, search, m.filteredMeters]);
// Validation
const validateForm = (): boolean => {
const next: Record<string, boolean> = {};
if (!form.meterName.trim()) next["meterName"] = true;
if (!form.meterSerialNumber.trim()) next["meterSerialNumber"] = true;
if (!form.areaName.trim()) next["areaName"] = true;
if (!form.deviceName.trim()) next["deviceName"] = true;
if (!form.protocolType.trim()) next["protocolType"] = true;
if (!deviceForm["Device ID"] || deviceForm["Device ID"] === 0) next["Device ID"] = true;
if (!deviceForm["Device EUI"].trim()) next["Device EUI"] = true;
if (!deviceForm["Join EUI"].trim()) next["Join EUI"] = true;
if (!deviceForm["AppKey"].trim()) next["AppKey"] = true;
if (!form.name.trim()) next["name"] = true;
if (!form.serialNumber.trim()) next["serialNumber"] = true;
if (!form.concentratorId.trim()) next["concentratorId"] = true;
setErrors(next);
return Object.keys(next).length === 0;
};
// CRUD
// CRUD handlers
const handleSave = async () => {
if (isMockMode) return;
if (!validateForm()) return;
try {
let savedMeter: Meter;
if (editingId) {
const meterToUpdate = m.meters.find((x) => x.id === editingId);
if (!meterToUpdate) throw new Error("Meter to update not found");
const updatedMeter = await updateMeter(editingId, form);
m.setMeters((prev) => prev.map((x) => (x.id === editingId ? updatedMeter : x)));
savedMeter = updatedMeter;
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]);
savedMeter = newMeter;
}
try {
const deviceDataWithRef = { ...deviceForm, meterId: savedMeter.id };
await createOrUpdateDevice(deviceDataWithRef);
} catch (deviceError) {
console.error("Error saving device data:", deviceError);
alert("Meter saved, but there was an error saving device data.");
await createMeter(form);
// Reload meters to get the complete data with project info
await m.loadMeters();
}
setShowModal(false);
setEditingId(null);
setForm(emptyMeter);
setDeviceForm(emptyDeviceData);
setForm(emptyForm);
setErrors({});
setActiveMeter(null);
} catch (error) {
@@ -235,7 +147,6 @@ export default function MetersPage({
};
const handleDelete = async () => {
if (isMockMode) return;
if (!activeMeter) return;
try {
@@ -260,8 +171,43 @@ export default function MetersPage({
setSearch("");
};
const openEditModal = () => {
if (!activeMeter) return;
setEditingId(activeMeter.id);
setForm({
serialNumber: activeMeter.serialNumber,
meterId: activeMeter.meterId ?? "",
name: activeMeter.name,
concentratorId: activeMeter.concentratorId,
location: activeMeter.location ?? "",
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 = () => {
setForm(emptyForm);
setErrors({});
setEditingId(null);
setShowModal(true);
};
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}
@@ -269,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 */}
@@ -286,78 +241,40 @@ 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>
<div className="flex gap-3">
<button
onClick={() => {
if (isMockMode) return;
const base = { ...emptyMeter };
if (m.selectedProject) base.areaName = m.selectedProject;
setForm(base);
setDeviceForm(emptyDeviceData);
setErrors({});
setEditingId(null);
setShowModal(true);
}}
disabled={isMockMode || !m.selectedProject || 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"
onClick={openCreateModal}
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={() => {
if (isMockMode) return;
if (!activeMeter) return;
onClick={() => setShowBulkUpload(true)}
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
</button>
setEditingId(activeMeter.id);
setForm({
createdAt: activeMeter.createdAt,
updatedAt: activeMeter.updatedAt,
areaName: activeMeter.areaName,
accountNumber: activeMeter.accountNumber,
userName: activeMeter.userName,
userAddress: activeMeter.userAddress,
meterSerialNumber: activeMeter.meterSerialNumber,
meterName: activeMeter.meterName,
meterStatus: activeMeter.meterStatus,
protocolType: activeMeter.protocolType,
priceNo: activeMeter.priceNo,
priceName: activeMeter.priceName,
dmaPartition: activeMeter.dmaPartition,
supplyTypes: activeMeter.supplyTypes,
deviceId: activeMeter.deviceId,
deviceName: activeMeter.deviceName,
deviceType: activeMeter.deviceType,
usageAnalysisType: activeMeter.usageAnalysisType,
installedTime: activeMeter.installedTime,
});
setDeviceForm(emptyDeviceData);
setErrors({});
setShowModal(true);
}}
disabled={isMockMode || !activeMeter}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
<button
onClick={openEditModal}
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>
@@ -372,28 +289,29 @@ export default function MetersPage({
</div>
<input
className="bg-white rounded-lg shadow px-4 py-2 text-sm"
placeholder="Search by meter name, serial number, device ID, area, device type, or meter status..."
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
open={confirmOpen}
title="Eliminar medidor"
message={`¿Estás seguro que quieres eliminar "${
activeMeter?.meterName ?? "este medidor"
}" (${activeMeter?.meterSerialNumber ?? "—"})? Esta acción no se puede deshacer.`}
activeMeter?.name ?? "este medidor"
}" (${activeMeter?.serialNumber ?? "—"})? Esta acción no se puede deshacer.`}
confirmText="Eliminar"
cancelText="Cancelar"
danger
@@ -414,20 +332,30 @@ export default function MetersPage({
{showModal && (
<MetersModal
editingId={editingId}
selectedProject={m.selectedProject}
form={form}
setForm={setForm}
deviceForm={deviceForm}
setDeviceForm={setDeviceForm}
errors={errors}
setErrors={setErrors}
onClose={() => {
setShowModal(false);
setDeviceForm(emptyDeviceData);
setErrors({});
}}
onSave={handleSave}
/>
)}
{showBulkUpload && (
<MetersBulkUploadModal
onClose={() => {
m.loadMeters();
setShowBulkUpload(false);
}}
onSuccess={() => {
m.loadMeters();
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,210 @@
import { useState, useRef } from "react";
import { Upload, Download, X, AlertCircle, CheckCircle } from "lucide-react";
import { bulkUploadMeters, downloadMeterTemplate, type BulkUploadResult } from "../../api/meters";
type Props = {
onClose: () => void;
onSuccess: () => void;
};
export default function MetersBulkUploadModal({ onClose, onSuccess }: Props) {
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<BulkUploadResult | null>(null);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
// Validate file type
const validTypes = [
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel",
];
if (!validTypes.includes(selectedFile.type)) {
setError("Solo se permiten archivos Excel (.xlsx, .xls)");
return;
}
setFile(selectedFile);
setError(null);
setResult(null);
}
};
const handleUpload = async () => {
if (!file) return;
setUploading(true);
setError(null);
setResult(null);
try {
const uploadResult = await bulkUploadMeters(file);
setResult(uploadResult);
if (uploadResult.data.inserted > 0) {
onSuccess();
}
} catch (err) {
setError(err instanceof Error ? err.message : "Error en la carga");
} finally {
setUploading(false);
}
};
const handleDownloadTemplate = async () => {
try {
await downloadMeterTemplate();
} catch (err) {
setError(err instanceof Error ? err.message : "Error descargando plantilla");
}
};
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-[600px] max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Carga Masiva de Medidores</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
<X size={20} />
</button>
</div>
{/* Instructions */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<h3 className="font-medium text-blue-800 mb-2">Instrucciones:</h3>
<ol className="text-sm text-blue-700 space-y-1 list-decimal list-inside">
<li>Descarga la plantilla Excel con el formato correcto</li>
<li>Llena los datos de los medidores (serial_number, name y concentrator_serial son obligatorios)</li>
<li>El concentrator_serial debe coincidir con un concentrador existente</li>
<li>Sube el archivo Excel completado</li>
</ol>
</div>
{/* Download Template Button */}
<button
onClick={handleDownloadTemplate}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 mb-4"
>
<Download size={16} />
Descargar Plantilla Excel
</button>
{/* File Input */}
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 mb-4 text-center">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".xlsx,.xls"
className="hidden"
/>
{file ? (
<div className="flex items-center justify-center gap-2">
<CheckCircle className="text-green-500" size={20} />
<span className="text-gray-700">{file.name}</span>
<button
onClick={() => {
setFile(null);
setResult(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}}
className="text-red-500 hover:text-red-700 ml-2"
>
<X size={16} />
</button>
</div>
) : (
<div>
<Upload className="mx-auto text-gray-400 mb-2" size={32} />
<p className="text-gray-600 mb-2">
Arrastra un archivo Excel aquí o
</p>
<button
onClick={() => fileInputRef.current?.click()}
className="text-blue-600 hover:text-blue-800 font-medium"
>
selecciona un archivo
</button>
</div>
)}
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4 flex items-start gap-2">
<AlertCircle className="text-red-500 shrink-0" size={20} />
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
{/* Upload Result */}
{result && (
<div
className={`border rounded-lg p-4 mb-4 ${
result.success
? "bg-green-50 border-green-200"
: "bg-yellow-50 border-yellow-200"
}`}
>
<h4 className="font-medium mb-2">
{result.success ? "Carga completada" : "Carga completada con errores"}
</h4>
<div className="text-sm space-y-1">
<p>Total de filas: {result.data.totalRows}</p>
<p className="text-green-600">Insertados: {result.data.inserted}</p>
{result.data.failed > 0 && (
<p className="text-red-600">Fallidos: {result.data.failed}</p>
)}
</div>
{/* Error Details */}
{result.data.errors.length > 0 && (
<div className="mt-3">
<h5 className="font-medium text-sm mb-2">Errores:</h5>
<div className="max-h-40 overflow-y-auto bg-white rounded border p-2">
{result.data.errors.map((err, idx) => (
<div key={idx} className="text-xs text-red-600 py-1 border-b last:border-0">
Fila {err.row}: {err.error}
</div>
))}
</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"
>
{result ? "Cerrar" : "Cancelar"}
</button>
{!result && (
<button
onClick={handleUpload}
disabled={!file || uploading}
className="flex items-center gap-2 bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e] disabled:opacity-50 disabled:cursor-not-allowed"
>
{uploading ? (
<>
<span className="animate-spin"></span>
Cargando...
</>
) : (
<>
<Upload size={16} />
Cargar Medidores
</>
)}
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,15 +1,14 @@
import type React from "react";
import type { Meter } from "../../api/meters";
import type { DeviceData } from "./MeterPage";
import { useEffect, useState } from "react";
import type { MeterInput } from "../../api/meters";
import { fetchConcentrators, type Concentrator } from "../../api/concentrators";
type Props = {
editingId: string | null;
selectedProject?: string;
form: Omit<Meter, "id">;
setForm: React.Dispatch<React.SetStateAction<Omit<Meter, "id">>>;
deviceForm: DeviceData;
setDeviceForm: React.Dispatch<React.SetStateAction<DeviceData>>;
form: MeterInput;
setForm: React.Dispatch<React.SetStateAction<MeterInput>>;
errors: Record<string, boolean>;
setErrors: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
@@ -20,247 +19,358 @@ type Props = {
export default function MetersModal({
editingId,
selectedProject,
form,
setForm,
deviceForm,
setDeviceForm,
errors,
setErrors,
onClose,
onSave,
}: Props) {
const title = editingId ? "Edit Meter" : "Add Meter";
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(() => {
const load = async () => {
try {
const data = await fetchConcentrators();
setConcentrators(data);
} catch (error) {
console.error("Error loading concentrators:", error);
} finally {
setLoadingConcentrators(false);
}
};
load();
}, []);
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-[700px] 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">
Meter Information
<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 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["areaName"] ? "border-red-500" : ""
errors["serialNumber"] ? "border-red-500" : ""
}`}
placeholder="Area Name *"
value={form.areaName}
placeholder="Número de serie"
value={form.serialNumber}
onChange={(e) => {
setForm({ ...form, areaName: e.target.value });
if (errors["areaName"]) setErrors({ ...errors, areaName: false });
setForm({ ...form, serialNumber: e.target.value });
if (errors["serialNumber"]) setErrors({ ...errors, serialNumber: false });
}}
required
/>
{errors["areaName"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
</div>
<div>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Account Number (optional)"
value={form.accountNumber ?? ""}
onChange={(e) =>
setForm({ ...form, accountNumber: e.target.value || null })
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="User Name (optional)"
value={form.userName ?? ""}
onChange={(e) =>
setForm({ ...form, userName: e.target.value || null })
}
/>
</div>
<div>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="User Address (optional)"
value={form.userAddress ?? ""}
onChange={(e) =>
setForm({ ...form, userAddress: e.target.value || null })
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["meterSerialNumber"] ? "border-red-500" : ""
}`}
placeholder="Meter S/N *"
value={form.meterSerialNumber}
onChange={(e) => {
setForm({ ...form, meterSerialNumber: e.target.value });
if (errors["meterSerialNumber"])
setErrors({ ...errors, meterSerialNumber: false });
}}
required
/>
{errors["meterSerialNumber"] && (
<p className="text-red-500 text-xs mt-1">This field is required</p>
{errors["serialNumber"] && (
<p className="text-red-500 text-xs mt-1">Campo requerido</p>
)}
</div>
<div>
<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)"
value={form.meterId ?? ""}
onChange={(e) => setForm({ ...form, meterId: e.target.value || undefined })}
/>
</div>
</div>
<div>
<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["meterName"] ? "border-red-500" : ""
errors["name"] ? "border-red-500" : ""
}`}
placeholder="Meter Name *"
value={form.meterName}
placeholder="Nombre del medidor"
value={form.name}
onChange={(e) => {
setForm({ ...form, meterName: e.target.value });
if (errors["meterName"]) setErrors({ ...errors, meterName: false });
setForm({ ...form, name: e.target.value });
if (errors["name"]) setErrors({ ...errors, name: false });
}}
required
/>
{errors["meterName"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
{errors["name"] && <p className="text-red-500 text-xs mt-1">Campo requerido</p>}
</div>
<div>
<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" : ""
}`}
value={form.concentratorId}
onChange={(e) => {
setForm({ ...form, concentratorId: e.target.value });
if (errors["concentratorId"]) setErrors({ ...errors, concentratorId: false });
}}
disabled={loadingConcentrators}
required
>
<option value="">
{loadingConcentrators ? "Cargando..." : "Selecciona un concentrador"}
</option>
{concentrators.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.serialNumber})
</option>
))}
</select>
{errors["concentratorId"] && (
<p className="text-red-500 text-xs mt-1">Selecciona un concentrador</p>
)}
</div>
<div>
<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)"
value={form.location ?? ""}
onChange={(e) => setForm({ ...form, location: e.target.value || undefined })}
/>
</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 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>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["protocolType"] ? "border-red-500" : ""
}`}
placeholder="Protocol Type *"
value={form.protocolType}
onChange={(e) => {
setForm({ ...form, protocolType: e.target.value });
if (errors["protocolType"]) setErrors({ ...errors, protocolType: false });
}}
required
/>
{errors["protocolType"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
</div>
<div>
<input
<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"
placeholder="Device ID (optional)"
value={form.deviceId ?? ""}
onChange={(e) => setForm({ ...form, deviceId: e.target.value || "" })}
/>
value={form.type ?? "LORA"}
onChange={(e) => setForm({ ...form, type: e.target.value })}
>
<option value="LORA">LoRa</option>
<option value="LORAWAN">LoRaWAN</option>
<option value="GRANDES">Grandes Consumidores</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"}
onChange={(e) => setForm({ ...form, status: e.target.value })}
>
<option value="ACTIVE">Activo</option>
<option value="INACTIVE">Inactivo</option>
<option value="MAINTENANCE">Mantenimiento</option>
<option value="FAULTY">Averiado</option>
<option value="REPLACED">Reemplazado</option>
</select>
</div>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Fecha de Instalación</label>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["deviceName"] ? "border-red-500" : ""
}`}
placeholder="Device Name *"
value={form.deviceName}
onChange={(e) => {
setForm({ ...form, deviceName: e.target.value });
if (errors["deviceName"]) setErrors({ ...errors, deviceName: false });
}}
required
type="date"
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.installationDate?.split("T")[0] ?? ""}
onChange={(e) =>
setForm({
...form,
installationDate: e.target.value ? new Date(e.target.value).toISOString() : undefined,
})
}
/>
{errors["deviceName"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
</div>
</div>
{/* DEVICE CONFIG */}
<div className="space-y-3 pt-4">
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
Device Configuration
{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"
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Device ID"] ? "border-red-500" : ""
}`}
placeholder="Device ID *"
value={deviceForm["Device ID"] || ""}
onChange={(e) => {
setDeviceForm({ ...deviceForm, "Device ID": parseInt(e.target.value) || 0 });
if (errors["Device ID"]) setErrors({ ...errors, "Device ID": false });
}}
required
min={1}
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 })}
/>
{errors["Device ID"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
</div>
<div>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Device EUI"] ? "border-red-500" : ""
}`}
placeholder="Device EUI *"
value={deviceForm["Device EUI"]}
onChange={(e) => {
setDeviceForm({ ...deviceForm, "Device EUI": e.target.value });
if (errors["Device EUI"]) setErrors({ ...errors, "Device EUI": false });
}}
required
/>
{errors["Device EUI"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
</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
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["Join EUI"] ? "border-red-500" : ""
}`}
placeholder="Join EUI *"
value={deviceForm["Join EUI"]}
onChange={(e) => {
setDeviceForm({ ...deviceForm, "Join EUI": e.target.value });
if (errors["Join EUI"]) setErrors({ ...errors, "Join EUI": false });
}}
required
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 })}
/>
{errors["Join EUI"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Current Flow</label>
<input
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
errors["AppKey"] ? "border-red-500" : ""
}`}
placeholder="AppKey *"
value={deviceForm["AppKey"]}
onChange={(e) => {
setDeviceForm({ ...deviceForm, AppKey: e.target.value });
if (errors["AppKey"]) setErrors({ ...errors, AppKey: false });
}}
required
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 })}
/>
{errors["AppKey"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
</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">
Cancel
<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
onClick={onSave}
className="bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e]"
>
Save
Guardar
</button>
</div>
</div>

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,34 +17,133 @@ export default function MetersTable({
isLoading,
isMockMode,
selectedProject,
takeType,
activeMeter,
onRowClick,
}: Props) {
}: MetersTableProps) {
const disabled = isMockMode || !selectedProject;
const typeLabels: Record<TakeType, string> = {
GENERAL: "todos los tipos",
LORA: "LoRa",
LORAWAN: "LoRaWAN",
GRANDES: "Grandes consumidores",
};
const isPruebaProject = selectedProject === "PRUEBA";
const defaultColumns = [
{ title: "Serial", field: "serialNumber", render: (r: Meter) => r.serialNumber || "-" },
{ title: "Meter ID", field: "meterId", render: (r: Meter) => r.meterId || "-" },
{ title: "Nombre", field: "name", render: (r: Meter) => r.name || "-" },
{ title: "Ubicación", field: "location", render: (r: Meter) => r.location || "-" },
{
title: "Tipo",
field: "type",
render: (r: Meter) => {
const typeLabels: Record<string, string> = {
LORA: "LoRa",
LORAWAN: "LoRaWAN",
GRANDES: "Grandes Consumidores",
};
const typeColors: Record<string, string> = {
LORA: "text-green-600 border-green-600",
LORAWAN: "text-purple-600 border-purple-600",
GRANDES: "text-orange-600 border-orange-600",
};
const type = r.type || "LORA";
return (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${typeColors[type] || "text-gray-600 border-gray-600"}`}
>
{typeLabels[type] || type}
</span>
);
},
},
{
title: "Estado",
field: "status",
render: (r: Meter) => (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${
r.status === "ACTIVE"
? "text-blue-600 border-blue-600"
: "text-red-600 border-red-600"
}`}
>
{r.status || "-"}
</span>
),
},
{ title: "Concentrador", field: "concentratorName", render: (r: Meter) => r.concentratorName || "-" },
{ title: "Última Lectura", field: "lastReadingValue", render: (r: Meter) => r.lastReadingValue != null ? Number(r.lastReadingValue).toFixed(2) : "-" },
];
const pruebaColumns = [
{ title: "Meters No.", field: "meterId", render: (r: Meter) => r.meterId || r.serialNumber || "-" },
{ title: "Name", field: "name", render: (r: Meter) => r.name || "-" },
{ title: "Protocol", field: "protocol", render: (r: Meter) => r.protocol || "-" },
{
title: "Status",
field: "status",
render: (r: Meter) => (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${
r.status === "ACTIVE"
? "text-blue-600 border-blue-600"
: "text-red-600 border-red-600"
}`}
>
{r.status || "-"}
</span>
),
},
{ title: "Total Flow", field: "lastReadingValue", render: (r: Meter) => r.lastReadingValue != null ? Number(r.lastReadingValue).toFixed(2) : "-" },
{
title: "Last Contact",
field: "lastReadingAt",
render: (r: Meter) => r.lastReadingAt ? new Date(r.lastReadingAt).toLocaleString('es-MX', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}) : "-"
},
{ title: "Voltage", field: "voltage", render: (r: Meter) => r.voltage != null ? `${Number(r.voltage).toFixed(2)} V` : "-" },
{ title: "Signal", field: "signal", render: (r: Meter) => r.signal != null ? `${r.signal} dBm` : "-" },
{ title: "Leakage Status", field: "leakageStatus", render: (r: Meter) => r.leakageStatus || "-" },
{ title: "Burst Status", field: "burstStatus", render: (r: Meter) => r.burstStatus || "-" },
{ title: "Current Flow", field: "currentFlow", render: (r: Meter) => r.currentFlow != null ? Number(r.currentFlow).toFixed(4) : "-" },
{ title: "Total Flow Reverse", field: "totalFlowReverse", render: (r: Meter) => r.totalFlowReverse != null ? Number(r.totalFlowReverse).toFixed(4) : "-" },
];
const columns = isPruebaProject ? pruebaColumns : defaultColumns;
return (
<div className={disabled ? "opacity-60 pointer-events-none" : ""}>
<MaterialTable
title="Meters"
isLoading={isLoading}
columns={[
{ title: "Area Name", field: "areaName", render: (r: any) => r.areaName || "-" },
{ title: "Account Number", field: "accountNumber", render: (r: any) => r.accountNumber || "-" },
{ title: "User Name", field: "userName", render: (r: any) => r.userName || "-" },
{ title: "User Address", field: "userAddress", render: (r: any) => r.userAddress || "-" },
{ title: "Meter S/N", field: "meterSerialNumber", render: (r: any) => r.meterSerialNumber || "-" },
{ title: "Meter Name", field: "meterName", render: (r: any) => r.meterName || "-" },
{ title: "Protocol Type", field: "protocolType", render: (r: any) => r.protocolType || "-" },
{ title: "Device ID", field: "deviceId", render: (r: any) => r.deviceId || "-" },
{ title: "Device Name", field: "deviceName", render: (r: any) => r.deviceName || "-" },
]}
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",
@@ -55,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,12 +1,19 @@
import { useEffect, useMemo, useState } from "react";
import { fetchMeters, type Meter } from "../../api/meters";
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 || "");
@@ -15,38 +22,73 @@ export function useMeters({ initialProject }: UseMetersArgs) {
const [filteredMeters, setFilteredMeters] = useState<Meter[]>([]);
const [loadingMeters, setLoadingMeters] = useState(true);
const loadMeters = async () => {
setLoadingMeters(true);
const [meterTypes, setMeterTypes] = useState<MeterType[]>([]);
const [selectedMeterTypeId, setSelectedMeterTypeId] = useState<string>("");
const [loadingMeterTypes, setLoadingMeterTypes] = useState(true);
const loadProjects = async () => {
setLoadingProjects(true);
try {
const data = await fetchMeters();
const projects = await fetchProjects();
const projectsArray = [...new Set(data.map((r) => r.areaName))]
.filter(Boolean) as string[];
let visibleProjects = projects;
if (!isAdmin && userProjectId) {
visibleProjects = projects.filter(p => p.id === userProjectId);
}
setAllProjects(projectsArray);
setMeters(data);
setAllProjects(visibleProjects);
setSelectedProject((prev) => {
if (prev) return prev;
if (initialProject) return initialProject;
return projectsArray[0] ?? "";
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) {
console.error("Error loading meters:", error);
console.error("Error loading projects:", error);
setAllProjects([]);
setMeters([]);
setSelectedProject("");
} finally {
setLoadingMeters(false);
setLoadingProjects(false);
}
};
// init
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);
try {
const data = await fetchMeters();
setMeters(data);
} catch (error) {
console.error("Error loading meters:", error);
setMeters([]);
} finally {
setLoadingMeters(false);
}
};
useEffect(() => {
loadProjects();
loadMeters();
loadMeterTypes();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -55,19 +97,25 @@ export function useMeters({ initialProject }: UseMetersArgs) {
if (initialProject) setSelectedProject(initialProject);
}, [initialProject]);
// filter by project
useEffect(() => {
if (!selectedProject) {
setFilteredMeters([]);
return;
}
setFilteredMeters(meters.filter((m) => m.areaName === selectedProject));
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 area = m.areaName ?? "SIN PROYECTO";
acc[area] = (acc[area] ?? 0) + 1;
const project = m.projectName ?? "SIN PROYECTO";
acc[project] = (acc[project] ?? 0) + 1;
return acc;
}, {});
}, [meters]);
@@ -76,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,16 +1,26 @@
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 {
Project,
ProjectInput,
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";
/* ================= COMPONENT ================= */
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,20 +29,21 @@ export default function ProjectsPage() {
const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const emptyProject: Omit<Project, "id"> = {
const [meterTypes, setMeterTypes] = useState<MeterType[]>([]);
const [organismos, setOrganismos] = useState<OrganismoOperador[]>([]);
const emptyForm: ProjectInput = {
name: "",
description: "",
areaName: "",
deviceSN: "",
deviceName: "",
deviceType: "",
deviceStatus: "ACTIVE",
operator: "",
installedTime: "",
communicationTime: "",
location: "",
status: "ACTIVE",
meterTypeId: null,
organismoOperadorId: isOrganismo ? userOrganismoId : null,
};
const [form, setForm] = useState<Omit<Project, "id">>(emptyProject);
const [form, setForm] = useState<ProjectInput>(emptyForm);
/* ================= LOAD ================= */
const loadProjects = async () => {
setLoading(true);
try {
@@ -46,11 +57,52 @@ 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 () => {
try {
if (editingId) {
@@ -65,7 +117,7 @@ export default function ProjectsPage() {
setShowModal(false);
setEditingId(null);
setForm(emptyProject);
setForm(emptyForm);
setActiveProject(null);
} catch (error) {
console.error("Error saving project:", error);
@@ -81,35 +133,59 @@ export default function ProjectsPage() {
if (!activeProject) return;
const confirmDelete = window.confirm(
`Are you sure you want to delete the project "${activeProject.deviceName}"?`
`¿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."
}`
);
}
};
/* ================= FILTER ================= */
const filtered = projects.filter((p) =>
`${p.areaName} ${p.deviceName} ${p.deviceSN}`
const openEditModal = () => {
if (!activeProject) return;
setEditingId(activeProject.id);
setForm({
name: activeProject.name,
description: activeProject.description ?? "",
areaName: activeProject.areaName,
location: activeProject.location ?? "",
status: activeProject.status,
meterTypeId: activeProject.meterTypeId ?? null,
organismoOperadorId: activeProject.organismoOperadorId ?? null,
});
setShowModal(true);
};
const openCreateModal = () => {
setForm(emptyForm);
setEditingId(null);
setShowModal(true);
};
const filtered = visibleProjects.filter((p) =>
`${p.name} ${p.areaName} ${p.description ?? ""}`
.toLowerCase()
.includes(search.toLowerCase())
);
/* ================= UI ================= */
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
@@ -120,101 +196,119 @@ export default function ProjectsPage() {
>
<div>
<h1 className="text-2xl font-bold">Project Management</h1>
<p className="text-sm text-blue-100">Projects registered</p>
<p className="text-sm text-blue-100">Proyectos registrados</p>
</div>
<div className="flex gap-3">
{(isAdmin || isOrganismo) && (
<button
onClick={() => {
setForm(emptyProject);
setEditingId(null);
setShowModal(true);
}}
onClick={openCreateModal}
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg"
>
<Plus size={16} /> Add
<Plus size={16} /> Agregar
</button>
)}
{(isAdmin || isOrganismo) && (
<button
onClick={() => {
if (!activeProject) return;
setEditingId(activeProject.id);
setForm({
areaName: activeProject.areaName,
deviceSN: activeProject.deviceSN,
deviceName: activeProject.deviceName,
deviceType: activeProject.deviceType,
deviceStatus: activeProject.deviceStatus,
operator: activeProject.operator,
installedTime: activeProject.installedTime,
communicationTime: activeProject.communicationTime,
});
setShowModal(true);
}}
onClick={openEditModal}
disabled={!activeProject}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Pencil size={16} /> Edit
<Pencil size={16} /> Editar
</button>
)}
{(isAdmin || isOrganismo) && (
<button
onClick={handleDelete}
disabled={!activeProject}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Trash2 size={16} /> Delete
<Trash2 size={16} /> Eliminar
</button>
)}
<button
onClick={loadProjects}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg"
>
<RefreshCcw size={16} /> Refresh
<RefreshCcw size={16} /> Actualizar
</button>
</div>
</div>
{/* SEARCH */}
<input
className="bg-white rounded-lg shadow px-4 py-2 text-sm"
placeholder="Search project..."
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)}
/>
{/* TABLE */}
<MaterialTable
title="Projects"
title="Proyectos"
isLoading={loading}
columns={[
{ title: "Area Name", field: "areaName" },
{ title: "Device S/N", field: "deviceSN" },
{ title: "Device Name", field: "deviceName" },
{ title: "Device Type", field: "deviceType" },
{ 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: "Status",
field: "deviceStatus",
render: (rowData) => (
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 || "-" },
{
title: "Estado",
field: "status",
render: (rowData: Project) => (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${
rowData.deviceStatus === "ACTIVE"
rowData.status === "ACTIVE"
? "text-blue-600 border-blue-600"
: "text-red-600 border-red-600"
}`}
>
{rowData.deviceStatus}
{rowData.status}
</span>
),
},
{ title: "Operator", field: "operator" },
{ title: "Installed Time", field: "installedTime" },
{ title: "Communication Name", field: "communicationTime" },
{
title: "Creado",
field: "createdAt",
render: (rowData: Project) => new Date(rowData.createdAt).toLocaleDateString(),
},
]}
data={filtered}
onRowClick={(_, rowData) => setActiveProject(rowData as Project)}
options={{
search: false,
paging: true,
pageSize: 10,
pageSizeOptions: [10, 20, 50],
sorting: true,
rowStyle: (rowData) => ({
backgroundColor:
@@ -226,8 +320,8 @@ export default function ProjectsPage() {
localization={{
body: {
emptyDataSourceMessage: loading
? "Loading projects..."
: "No projects found. Click 'Add' to create your first project.",
? "Cargando proyectos..."
: "No hay proyectos. Haz clic en 'Agregar' para crear uno.",
},
}}
/>
@@ -235,85 +329,118 @@ export default function ProjectsPage() {
{/* 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 Project" : "Add Project"}
<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 Proyecto" : "Agregar Proyecto"}
</h2>
<div>
<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"
placeholder="Area Name"
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Nombre del proyecto"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div>
<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"
value={form.areaName}
onChange={(e) => setForm({ ...form, areaName: e.target.value })}
/>
</div>
<input
className="w-full border px-3 py-2 rounded"
placeholder="Device S/N"
value={form.deviceSN}
onChange={(e) => setForm({ ...form, deviceSN: e.target.value })}
<div>
<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)"
rows={3}
value={form.description ?? ""}
onChange={(e) => setForm({ ...form, description: e.target.value || undefined })}
/>
</div>
<div>
<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"
placeholder="Device Name"
value={form.deviceName}
onChange={(e) => setForm({ ...form, deviceName: e.target.value })}
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Ubicación (opcional)"
value={form.location ?? ""}
onChange={(e) => setForm({ ...form, location: e.target.value || undefined })}
/>
</div>
<input
className="w-full border px-3 py-2 rounded"
placeholder="Device Type"
value={form.deviceType}
onChange={(e) => setForm({ ...form, deviceType: e.target.value })}
/>
<input
className="w-full border px-3 py-2 rounded"
placeholder="Operator"
value={form.operator}
onChange={(e) => setForm({ ...form, operator: e.target.value })}
/>
<input
className="w-full border px-3 py-2 rounded"
placeholder="Installed Time"
value={form.installedTime}
onChange={(e) =>
setForm({ ...form, installedTime: e.target.value })
}
/>
<input
className="w-full border px-3 py-2 rounded"
placeholder="Communication Time"
value={form.communicationTime}
onChange={(e) =>
setForm({ ...form, communicationTime: e.target.value })
}
/>
<button
onClick={() =>
setForm({
...form,
deviceStatus:
form.deviceStatus === "ACTIVE" ? "INACTIVE" : "ACTIVE",
})
}
className="w-full border rounded px-3 py-2"
<div>
<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 })}
>
Status: {form.deviceStatus}
</button>
<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>
<div className="flex justify-end gap-2 pt-3">
<button onClick={() => setShowModal(false)}>Cancel</button>
{/* 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"}
onChange={(e) => setForm({ ...form, status: e.target.value })}
>
<option value="ACTIVE">Activo</option>
<option value="INACTIVE">Inactivo</option>
<option value="SUSPENDED">Suspendido</option>
</select>
</div>
<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"
>
Cancelar
</button>
<button
onClick={handleSave}
className="bg-[#4c5f9e] text-white px-4 py-2 rounded"
className="bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e]"
>
Save
Guardar
</button>
</div>
</div>

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"
],
},
});

26
water-api/.env.example Normal file
View File

@@ -0,0 +1,26 @@
# Server Configuration
PORT=3000
NODE_ENV=development
# Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_NAME=water_db
DB_USER=postgres
DB_PASSWORD=your_password_here
# JWT Configuration
JWT_ACCESS_SECRET=your_access_secret_key_here
JWT_REFRESH_SECRET=your_refresh_secret_key_here
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
# CORS Configuration
CORS_ORIGIN=http://localhost:5173
# TTS (Third-party Telemetry Service) Configuration
TTS_ENABLED=false
TTS_BASE_URL=https://api.tts-service.com
TTS_APPLICATION_ID=your_application_id_here
TTS_API_KEY=your_api_key_here
TTS_WEBHOOK_SECRET=your_webhook_secret_here

86
water-api/.gitignore vendored Normal file
View File

@@ -0,0 +1,86 @@
# Dependencies
node_modules/
package-lock.json
yarn.lock
pnpm-lock.yaml
# Build output
dist/
build/
*.js.map
# Environment variables
.env
.env.local
.env.development
.env.test
.env.production
.env*.local
# IDE and editors
.idea/
.vscode/
*.swp
*.swo
*.swn
*~
.project
.classpath
.settings/
*.sublime-workspace
*.sublime-project
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
desktop.ini
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Testing
coverage/
.nyc_output/
*.lcov
# TypeScript cache
*.tsbuildinfo
tsconfig.tsbuildinfo
# Temporary files
tmp/
temp/
.tmp/
.temp/
*.tmp
*.temp
# Debug
.npm
.eslintcache
.stylelintcache
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Docker
.docker/
# Miscellaneous
*.pid
*.seed
*.pid.lock

47
water-api/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "water-api",
"version": "1.0.0",
"description": "Water Management System API",
"main": "dist/index.js",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"watch": "nodemon --exec ts-node src/index.ts"
},
"keywords": [
"water",
"management",
"api",
"express"
],
"author": "",
"license": "ISC",
"dependencies": {
"@types/multer": "^2.0.0",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"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",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.17",
"@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",
"typescript": "^5.3.3"
}
}

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';

434
water-api/sql/schema.sql Normal file
View File

@@ -0,0 +1,434 @@
-- ============================================================================
-- Water Project Database Schema
-- PostgreSQL Migration Script
-- ============================================================================
-- Enable required extensions
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ============================================================================
-- TRIGGER FUNCTION: Auto-update updated_at timestamp
-- ============================================================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- ENUM TYPES
-- ============================================================================
CREATE TYPE role_name AS ENUM ('ADMIN', 'OPERATOR', 'VIEWER');
CREATE TYPE project_status AS ENUM ('ACTIVE', 'INACTIVE', 'COMPLETED');
CREATE TYPE device_status AS ENUM ('ACTIVE', 'INACTIVE', 'OFFLINE', 'MAINTENANCE', 'ERROR');
CREATE TYPE meter_type AS ENUM ('WATER', 'GAS', 'ELECTRIC');
CREATE TYPE reading_type AS ENUM ('AUTOMATIC', 'MANUAL', 'SCHEDULED');
-- ============================================================================
-- TABLE 1: roles
-- ============================================================================
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name role_name NOT NULL UNIQUE,
description TEXT,
permissions JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_roles_name ON roles(name);
CREATE TRIGGER trigger_roles_updated_at
BEFORE UPDATE ON roles
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE roles IS 'User roles with associated permissions';
-- ============================================================================
-- TABLE 2: users
-- ============================================================================
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
avatar_url TEXT,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_login TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_role_id ON users(role_id);
CREATE INDEX idx_users_is_active ON users(is_active);
CREATE TRIGGER trigger_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE users IS 'Application users with authentication credentials';
-- ============================================================================
-- TABLE 3: projects
-- ============================================================================
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
area_name VARCHAR(255),
location TEXT,
status project_status NOT NULL DEFAULT 'ACTIVE',
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_projects_status ON projects(status);
CREATE INDEX idx_projects_created_by ON projects(created_by);
CREATE INDEX idx_projects_name ON projects(name);
CREATE TRIGGER trigger_projects_updated_at
BEFORE UPDATE ON projects
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE projects IS 'Water monitoring projects';
-- ============================================================================
-- TABLE 4: concentrators
-- ============================================================================
CREATE TABLE concentrators (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
serial_number VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
location TEXT,
status device_status NOT NULL DEFAULT 'ACTIVE',
ip_address INET,
firmware_version VARCHAR(50),
last_communication TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_concentrators_serial_number ON concentrators(serial_number);
CREATE INDEX idx_concentrators_project_id ON concentrators(project_id);
CREATE INDEX idx_concentrators_status ON concentrators(status);
CREATE TRIGGER trigger_concentrators_updated_at
BEFORE UPDATE ON concentrators
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE concentrators IS 'Data concentrators that aggregate gateway communications';
-- ============================================================================
-- TABLE 5: gateways
-- ============================================================================
CREATE TABLE gateways (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gateway_id VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
concentrator_id UUID REFERENCES concentrators(id) ON DELETE SET NULL,
location TEXT,
status device_status NOT NULL DEFAULT 'ACTIVE',
tts_gateway_id VARCHAR(255),
tts_status VARCHAR(50),
tts_last_seen TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_gateways_gateway_id ON gateways(gateway_id);
CREATE INDEX idx_gateways_project_id ON gateways(project_id);
CREATE INDEX idx_gateways_concentrator_id ON gateways(concentrator_id);
CREATE INDEX idx_gateways_status ON gateways(status);
CREATE TRIGGER trigger_gateways_updated_at
BEFORE UPDATE ON gateways
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE gateways IS 'LoRaWAN gateways for device communication';
-- ============================================================================
-- TABLE 6: devices
-- ============================================================================
CREATE TABLE devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
dev_eui VARCHAR(16) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
device_type VARCHAR(100),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
gateway_id UUID REFERENCES gateways(id) ON DELETE SET NULL,
status device_status NOT NULL DEFAULT 'ACTIVE',
tts_device_id VARCHAR(255),
tts_status VARCHAR(50),
tts_last_seen TIMESTAMP WITH TIME ZONE,
app_key VARCHAR(32),
join_eui VARCHAR(16),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_devices_dev_eui ON devices(dev_eui);
CREATE INDEX idx_devices_project_id ON devices(project_id);
CREATE INDEX idx_devices_gateway_id ON devices(gateway_id);
CREATE INDEX idx_devices_status ON devices(status);
CREATE INDEX idx_devices_device_type ON devices(device_type);
CREATE TRIGGER trigger_devices_updated_at
BEFORE UPDATE ON devices
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE devices IS 'LoRaWAN end devices (sensors/transmitters)';
-- ============================================================================
-- TABLE 7: meters
-- ============================================================================
CREATE TABLE meters (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
serial_number VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
device_id UUID REFERENCES devices(id) ON DELETE SET NULL,
area_name VARCHAR(255),
location TEXT,
meter_type meter_type NOT NULL DEFAULT 'WATER',
status device_status NOT NULL DEFAULT 'ACTIVE',
last_reading_value NUMERIC(15, 4),
last_reading_at TIMESTAMP WITH TIME ZONE,
installation_date DATE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_meters_serial_number ON meters(serial_number);
CREATE INDEX idx_meters_project_id ON meters(project_id);
CREATE INDEX idx_meters_device_id ON meters(device_id);
CREATE INDEX idx_meters_status ON meters(status);
CREATE INDEX idx_meters_meter_type ON meters(meter_type);
CREATE INDEX idx_meters_area_name ON meters(area_name);
CREATE TRIGGER trigger_meters_updated_at
BEFORE UPDATE ON meters
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE meters IS 'Physical water meters associated with devices';
-- ============================================================================
-- TABLE 8: meter_readings
-- ============================================================================
CREATE TABLE meter_readings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
meter_id UUID NOT NULL REFERENCES meters(id) ON DELETE CASCADE,
device_id UUID REFERENCES devices(id) ON DELETE SET NULL,
reading_value NUMERIC(15, 4) NOT NULL,
reading_type reading_type NOT NULL DEFAULT 'AUTOMATIC',
battery_level SMALLINT,
signal_strength SMALLINT,
raw_payload TEXT,
received_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_meter_readings_meter_id ON meter_readings(meter_id);
CREATE INDEX idx_meter_readings_device_id ON meter_readings(device_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 DESC);
COMMENT ON TABLE meter_readings IS 'Historical meter reading values';
-- ============================================================================
-- TABLE 9: tts_uplink_logs
-- ============================================================================
CREATE TABLE tts_uplink_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
device_id UUID REFERENCES devices(id) ON DELETE SET NULL,
dev_eui VARCHAR(16) NOT NULL,
raw_payload JSONB NOT NULL,
decoded_payload JSONB,
gateway_ids TEXT[],
rssi SMALLINT,
snr NUMERIC(5, 2),
processed BOOLEAN NOT NULL DEFAULT FALSE,
error_message TEXT,
received_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_tts_uplink_logs_device_id ON tts_uplink_logs(device_id);
CREATE INDEX idx_tts_uplink_logs_dev_eui ON tts_uplink_logs(dev_eui);
CREATE INDEX idx_tts_uplink_logs_received_at ON tts_uplink_logs(received_at);
CREATE INDEX idx_tts_uplink_logs_processed ON tts_uplink_logs(processed);
CREATE INDEX idx_tts_uplink_logs_raw_payload ON tts_uplink_logs USING GIN (raw_payload);
COMMENT ON TABLE tts_uplink_logs IS 'The Things Stack uplink message logs';
-- ============================================================================
-- TABLE 10: refresh_tokens
-- ============================================================================
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);
COMMENT ON TABLE refresh_tokens IS 'JWT refresh tokens for user sessions';
-- ============================================================================
-- VIEW: meter_stats_by_project
-- ============================================================================
CREATE OR REPLACE VIEW meter_stats_by_project AS
SELECT
p.id AS project_id,
p.name AS project_name,
p.status AS project_status,
COUNT(m.id) AS total_meters,
COUNT(CASE WHEN m.status = 'ACTIVE' THEN 1 END) AS active_meters,
COUNT(CASE WHEN m.status = 'INACTIVE' THEN 1 END) AS inactive_meters,
COUNT(CASE WHEN m.status = 'OFFLINE' THEN 1 END) AS offline_meters,
COUNT(CASE WHEN m.status = 'MAINTENANCE' THEN 1 END) AS maintenance_meters,
COUNT(CASE WHEN m.status = 'ERROR' THEN 1 END) AS error_meters,
ROUND(AVG(m.last_reading_value)::NUMERIC, 2) AS avg_last_reading,
MAX(m.last_reading_at) AS most_recent_reading,
COUNT(DISTINCT m.area_name) AS unique_areas
FROM projects p
LEFT JOIN meters m ON p.id = m.project_id
GROUP BY p.id, p.name, p.status;
COMMENT ON VIEW meter_stats_by_project IS 'Aggregated meter statistics per project';
-- ============================================================================
-- VIEW: device_status_summary
-- ============================================================================
CREATE OR REPLACE VIEW device_status_summary AS
SELECT
p.id AS project_id,
p.name AS project_name,
'concentrator' AS device_category,
c.status,
COUNT(*) AS count
FROM projects p
LEFT JOIN concentrators c ON p.id = c.project_id
WHERE c.id IS NOT NULL
GROUP BY p.id, p.name, c.status
UNION ALL
SELECT
p.id AS project_id,
p.name AS project_name,
'gateway' AS device_category,
g.status,
COUNT(*) AS count
FROM projects p
LEFT JOIN gateways g ON p.id = g.project_id
WHERE g.id IS NOT NULL
GROUP BY p.id, p.name, g.status
UNION ALL
SELECT
p.id AS project_id,
p.name AS project_name,
'device' AS device_category,
d.status,
COUNT(*) AS count
FROM projects p
LEFT JOIN devices d ON p.id = d.project_id
WHERE d.id IS NOT NULL
GROUP BY p.id, p.name, d.status
UNION ALL
SELECT
p.id AS project_id,
p.name AS project_name,
'meter' AS device_category,
m.status,
COUNT(*) AS count
FROM projects p
LEFT JOIN meters m ON p.id = m.project_id
WHERE m.id IS NOT NULL
GROUP BY p.id, p.name, m.status;
COMMENT ON VIEW device_status_summary IS 'Summary of device statuses across all device types per project';
-- ============================================================================
-- SEED DATA: Default Roles
-- ============================================================================
INSERT INTO roles (name, description, permissions) VALUES
(
'ADMIN',
'Full system administrator with all permissions',
'{
"users": {"create": true, "read": true, "update": true, "delete": true},
"projects": {"create": true, "read": true, "update": true, "delete": true},
"devices": {"create": true, "read": true, "update": true, "delete": true},
"meters": {"create": true, "read": true, "update": true, "delete": true},
"readings": {"create": true, "read": true, "update": true, "delete": true},
"settings": {"create": true, "read": true, "update": true, "delete": true},
"reports": {"create": true, "read": true, "export": true}
}'::JSONB
),
(
'OPERATOR',
'Operator with management permissions but no system settings',
'{
"users": {"create": false, "read": true, "update": false, "delete": false},
"projects": {"create": true, "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
),
(
'VIEWER',
'Read-only access to view data and reports',
'{
"users": {"create": false, "read": false, "update": false, "delete": false},
"projects": {"create": false, "read": true, "update": false, "delete": false},
"devices": {"create": false, "read": true, "update": false, "delete": false},
"meters": {"create": false, "read": true, "update": false, "delete": false},
"readings": {"create": false, "read": true, "update": false, "delete": false},
"settings": {"create": false, "read": false, "update": false, "delete": false},
"reports": {"create": false, "read": true, "export": false}
}'::JSONB
);
-- ============================================================================
-- SEED DATA: Default Admin User
-- Password: admin123 (bcrypt hashed)
-- ============================================================================
INSERT INTO users (email, password_hash, name, role_id, is_active)
SELECT
'admin@waterproject.com',
'$2b$12$RrlEdRsUiiQYxtUmjOjX.uZU/IpXUFsXsWxDcMny1RUl6RFc.etDm',
'System Administrator',
r.id,
TRUE
FROM roles r
WHERE r.name = 'ADMIN';
-- ============================================================================
-- END OF SCHEMA
-- ============================================================================

View File

View File

@@ -0,0 +1,113 @@
import { Pool, PoolClient, QueryResult, QueryResultRow } from 'pg';
import config from './index';
import logger from '../utils/logger';
const pool = new Pool({
host: config.database.host,
port: config.database.port,
user: config.database.user,
password: config.database.password,
database: config.database.database,
ssl: config.database.ssl ? { rejectUnauthorized: false } : false,
max: config.database.maxConnections,
idleTimeoutMillis: config.database.idleTimeoutMs,
connectionTimeoutMillis: config.database.connectionTimeoutMs,
});
pool.on('connect', () => {
logger.debug('New client connected to the database pool');
});
pool.on('error', (err: Error) => {
logger.error('Unexpected error on idle database client', err);
});
pool.on('remove', () => {
logger.debug('Client removed from the database pool');
});
/**
* Execute a query on the database pool
* @param text - SQL query string
* @param params - Query parameters
* @returns Query result
*/
export const query = async <T extends QueryResultRow = QueryResultRow>(
text: string,
params?: unknown[]
): Promise<QueryResult<T>> => {
const start = Date.now();
try {
const result = await pool.query<T>(text, params);
const duration = Date.now() - start;
logger.debug(`Query executed in ${duration}ms`, {
query: text,
rows: result.rowCount,
});
return result;
} catch (error) {
logger.error('Database query error', {
query: text,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
};
/**
* Get a client from the pool for transactions
* @returns Pool client
*/
export const getClient = async (): Promise<PoolClient> => {
try {
const client = await pool.connect();
logger.debug('Database client acquired from pool');
return client;
} catch (error) {
logger.error('Failed to get database client', {
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
};
/**
* Test database connectivity
* @returns True if connection is successful, false otherwise
*/
export const testConnection = async (): Promise<boolean> => {
try {
const result = await pool.query('SELECT NOW()');
logger.info('Database connection successful', {
serverTime: result.rows[0]?.now,
});
return true;
} catch (error) {
logger.error('Database connection failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return false;
}
};
/**
* Close all pool connections (for graceful shutdown)
*/
export const closePool = async (): Promise<void> => {
try {
await pool.end();
logger.info('Database pool closed');
} catch (error) {
logger.error('Error closing database pool', {
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
};
export { pool };
export default pool;

View File

@@ -0,0 +1,128 @@
import dotenv from 'dotenv';
dotenv.config();
interface ServerConfig {
port: number;
env: string;
isProduction: boolean;
isDevelopment: boolean;
}
interface DatabaseConfig {
host: string;
port: number;
user: string;
password: string;
database: string;
ssl: boolean;
maxConnections: number;
idleTimeoutMs: number;
connectionTimeoutMs: number;
}
interface JwtConfig {
accessTokenSecret: string;
refreshTokenSecret: string;
accessTokenExpiresIn: string;
refreshTokenExpiresIn: string;
}
interface CorsConfig {
origin: string | string[];
credentials: boolean;
methods: string[];
allowedHeaders: string[];
}
interface TtsConfig {
enabled: boolean;
apiKey: string;
apiUrl: string;
applicationId: string;
webhookSecret: string;
requireWebhookVerification: boolean;
}
interface Config {
server: ServerConfig;
database: DatabaseConfig;
jwt: JwtConfig;
cors: CorsConfig;
tts: TtsConfig;
}
const requiredEnvVars = [
'DB_HOST',
'DB_USER',
'DB_PASSWORD',
'DB_NAME',
'JWT_ACCESS_SECRET',
'JWT_REFRESH_SECRET',
];
const validateEnvVars = (): void => {
const missing = requiredEnvVars.filter((varName) => !process.env[varName]);
if (missing.length > 0) {
throw new Error(
`Missing required environment variables: ${missing.join(', ')}`
);
}
};
const parseOrigin = (origin: string | undefined): string | string[] => {
if (!origin) return '*';
if (origin.includes(',')) {
return origin.split(',').map((o) => o.trim());
}
return origin;
};
const getConfig = (): Config => {
validateEnvVars();
return {
server: {
port: parseInt(process.env.PORT || '3000', 10),
env: process.env.NODE_ENV || 'development',
isProduction: process.env.NODE_ENV === 'production',
isDevelopment: process.env.NODE_ENV !== 'production',
},
database: {
host: process.env.DB_HOST!,
port: parseInt(process.env.DB_PORT || '5432', 10),
user: process.env.DB_USER!,
password: process.env.DB_PASSWORD!,
database: process.env.DB_NAME!,
ssl: process.env.DB_SSL === 'true',
maxConnections: parseInt(process.env.DB_MAX_CONNECTIONS || '20', 10),
idleTimeoutMs: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
connectionTimeoutMs: parseInt(process.env.DB_CONNECTION_TIMEOUT || '5000', 10),
},
jwt: {
accessTokenSecret: process.env.JWT_ACCESS_SECRET!,
refreshTokenSecret: process.env.JWT_REFRESH_SECRET!,
accessTokenExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '15m',
refreshTokenExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
},
cors: {
origin: parseOrigin(process.env.CORS_ORIGIN),
credentials: process.env.CORS_CREDENTIALS === 'true',
methods: (process.env.CORS_METHODS || 'GET,POST,PUT,DELETE,PATCH,OPTIONS').split(','),
allowedHeaders: (process.env.CORS_ALLOWED_HEADERS || 'Content-Type,Authorization').split(','),
},
tts: {
enabled: process.env.TTS_ENABLED === 'true',
apiKey: process.env.TTS_API_KEY || '',
apiUrl: process.env.TTS_API_URL || '',
applicationId: process.env.TTS_APPLICATION_ID || '',
webhookSecret: process.env.TTS_WEBHOOK_SECRET || '',
requireWebhookVerification: process.env.TTS_REQUIRE_WEBHOOK_VERIFICATION !== 'false',
},
};
};
export const config = getConfig();
export default config;

View File

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