Compare commits
74 Commits
fab39f099d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da976b9003 | ||
|
|
613fb2d787 | ||
|
|
61dafa83ac | ||
|
|
e1d4db96fe | ||
|
|
14e7f8d743 | ||
|
|
a79dcc82ea | ||
|
|
9f1ab4115e | ||
|
|
6487e9105e | ||
|
|
27494e7868 | ||
|
|
3087af11e1 | ||
|
|
0142ba740f | ||
|
|
c741b697d9 | ||
|
|
9b8d6c4e45 | ||
|
|
71623d667f | ||
|
|
6118ec2813 | ||
|
|
71db4219ca | ||
| 7301be7544 | |||
| ddf98af78a | |||
| 34e6ef14df | |||
| 8e1eefd400 | |||
| e99d9c1a7d | |||
| 4b977f3eae | |||
| dda69a59ba | |||
| e6df3acad5 | |||
| 0f8f73ea2a | |||
| 5f51d25bc1 | |||
| 35c44ed13c | |||
| 040f3f97dd | |||
| 5529739749 | |||
| 31ab977f97 | |||
| d1770b550a | |||
| 23c3a19209 | |||
| 6124bedb8a | |||
| 5a062ce3a1 | |||
| 9ab1beeef7 | |||
| b273003366 | |||
| f921b20e15 | |||
| 6cc4ee0901 | |||
| 6c5323906d | |||
| e06941fd02 | |||
| 4f484779d8 | |||
| 46aab5fbba | |||
| 1d278936b1 | |||
| 01aadcf2f3 | |||
| d25ec6ebe7 | |||
| 203e6069c8 | |||
| 1330421ddd | |||
| 0ec9338ac8 | |||
| 58a3efa55c | |||
| 8ca10d0b35 | |||
| 48e0884bf7 | |||
| 6c02bd5448 | |||
| b5ea12dd27 | |||
| 33b072436d | |||
| 13cc4528ff | |||
| 936471542a | |||
| 6b9f6810ab | |||
| 196f7a53b3 | |||
| 2d977b13b4 | |||
| c910ce8996 | |||
| 6d25f5103b | |||
|
|
6c7d448b2f | ||
|
|
ab97987c6a | ||
|
|
c81a18987f | ||
|
|
2b5735d78d | ||
|
|
143cd77cee | ||
|
|
c63e6085bd | ||
|
|
9af06addad | ||
|
|
16f1f68499 | ||
|
|
3681725b8f | ||
|
|
48df8a2bfd | ||
|
|
f8c75c3c9c | ||
|
|
dd3997a3a8 | ||
|
|
59067b387c |
202
CAMBIOS_SESION.md
Normal file
202
CAMBIOS_SESION.md
Normal 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
|
||||
667
DOCUMENTATION.md
Normal file
667
DOCUMENTATION.md
Normal file
@@ -0,0 +1,667 @@
|
||||
# Documentacion Tecnica - GRH (Gestion de Recursos Hidricos)
|
||||
|
||||
Documentacion tecnica detallada de la arquitectura, componentes, API y patrones del sistema.
|
||||
|
||||
---
|
||||
|
||||
## Tabla de Contenidos
|
||||
|
||||
1. [Arquitectura del Sistema](#arquitectura-del-sistema)
|
||||
2. [Frontend - Componentes y Paginas](#frontend---componentes-y-paginas)
|
||||
3. [Backend - API REST](#backend---api-rest)
|
||||
4. [Base de Datos](#base-de-datos)
|
||||
5. [Autenticacion y Autorizacion](#autenticacion-y-autorizacion)
|
||||
6. [Capa de API del Frontend](#capa-de-api-del-frontend)
|
||||
7. [Hooks Personalizados](#hooks-personalizados)
|
||||
8. [Sistema de Temas](#sistema-de-temas)
|
||||
9. [Conectores Externos](#conectores-externos)
|
||||
10. [Tareas Programadas](#tareas-programadas)
|
||||
11. [Guia de Desarrollo](#guia-de-desarrollo)
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura del Sistema
|
||||
|
||||
### Vision General
|
||||
|
||||
El proyecto es una aplicacion **full-stack** con tres componentes principales:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Capa de Presentacion │
|
||||
│ React SPA: Pages, Components, Layout │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Capa de Logica (Frontend) │
|
||||
│ Custom Hooks, API Client con JWT, Estado Local │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Capa de API (Backend) │
|
||||
│ Express Routes → Controllers → Services → PostgreSQL │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Capa de Datos │
|
||||
│ PostgreSQL: 11 tablas, 2 vistas, triggers, indices │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Integraciones Externas │
|
||||
│ The Things Stack (LoRaWAN), SH-Meters, XMeters │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Patrones de Diseno
|
||||
|
||||
1. **Container/Presentational** - Paginas manejan logica, componentes renderizan UI
|
||||
2. **Custom Hooks** - Logica reutilizable en hooks (`useMeters`, `useConcentrators`, `useNotifications`)
|
||||
3. **Module Pattern** - Codigo organizado por dominio (`meters/`, `concentrators/`, `analytics/`)
|
||||
4. **Service Layer (Backend)** - Routes → Controllers → Services → Database
|
||||
5. **Repository Pattern** - Cada servicio encapsula las queries SQL de su dominio
|
||||
6. **Middleware Pipeline** - Auth, audit logging, webhook verification
|
||||
|
||||
---
|
||||
|
||||
## Frontend - Componentes y Paginas
|
||||
|
||||
### App.tsx - Componente Raiz
|
||||
|
||||
Maneja autenticacion global, routing por estado interno y modales globales.
|
||||
|
||||
```typescript
|
||||
export type Page =
|
||||
| "home" | "projects" | "meters" | "concentrators"
|
||||
| "consumption" | "auditoria" | "users" | "roles"
|
||||
| "sh-meters" | "xmeters" | "tts"
|
||||
| "analytics-map" | "analytics-reports" | "analytics-server"
|
||||
| "organismos" | "historico";
|
||||
```
|
||||
|
||||
La navegacion es **state-based** (sin React Router). El estado `currentPage` determina que pagina se renderiza.
|
||||
|
||||
### Paginas Principales
|
||||
|
||||
| Pagina | Archivo | Descripcion |
|
||||
|--------|---------|-------------|
|
||||
| Dashboard | `Home.tsx` | KPIs, graficos Recharts, selector de organismos, alertas |
|
||||
| Login | `LoginPage.tsx` | Formulario de autenticacion |
|
||||
| Medidores | `meters/MeterPage.tsx` | CRUD con tabla, sidebar, filtros, carga masiva |
|
||||
| Concentradores | `concentrators/ConcentratorsPage.tsx` | CRUD con tabla y sidebar |
|
||||
| Proyectos | `projects/ProjectsPage.tsx` | Tabla de proyectos con estados |
|
||||
| Consumo | `consumption/ConsumptionPage.tsx` | Lecturas con filtros y estadisticas |
|
||||
| Usuarios | `UsersPage.tsx` | Gestion de usuarios (admin) |
|
||||
| Roles | `RolesPage.tsx` | Gestion de roles y permisos |
|
||||
| Auditoria | `AuditoriaPage.tsx` | Visor de logs de actividad |
|
||||
| Mapa | `analytics/AnalyticsMapPage.tsx` | Mapa Leaflet con ubicaciones de medidores |
|
||||
| Reportes | `analytics/AnalyticsReportsPage.tsx` | Reportes de consumo |
|
||||
| Servidor | `analytics/AnalyticsServerPage.tsx` | Metricas CPU, memoria, requests |
|
||||
| SH-Meters | `conectores/SHMetersPage.tsx` | Conector SH-Meters |
|
||||
| XMeters | `conectores/XMetersPage.tsx` | Conector XMeters |
|
||||
| TTS | `conectores/TTSPage.tsx` | Conector The Things Stack |
|
||||
| Organismos | `OrganismosPage.tsx` | Gestion de organismos operadores (ADMIN) |
|
||||
| Historico | `historico/HistoricoPage.tsx` | Historico de lecturas por medidor con grafica, estadisticas y tabla |
|
||||
|
||||
### Componentes de Layout
|
||||
|
||||
**Sidebar.tsx**
|
||||
- Menu lateral colapsable con hover expansion
|
||||
- Soporte para pin/unpin
|
||||
- Menu jerarquico con submenus (Analytics, Conectores)
|
||||
- Visibilidad por rol de 3 niveles:
|
||||
- ADMIN: ve todo (incluye Organismos, Conectores, Auditoria)
|
||||
- ORGANISMO_OPERADOR: ve Dashboard, Project Management, Users, Analytics
|
||||
- OPERATOR: ve Dashboard y Project Management
|
||||
|
||||
**TopMenu.tsx**
|
||||
- Breadcrumb de navegacion
|
||||
- Dropdown de notificaciones con conteo de no leidas
|
||||
- Menu de usuario: perfil, configuracion, logout
|
||||
|
||||
**Componentes Comunes**
|
||||
- `ProfileModal.tsx` - Edicion de perfil con avatar local
|
||||
- `ConfirmModal.tsx` - Confirmacion de acciones destructivas
|
||||
- `Watermark.tsx` - Marca de agua GRH
|
||||
- `ProjectBadge.tsx` - Badge visual de proyecto
|
||||
- `SettingsModals.tsx` - Configuracion de tema y modo compacto
|
||||
- `NotificationDropdown.tsx` - Panel de notificaciones
|
||||
|
||||
---
|
||||
|
||||
## Backend - API REST
|
||||
|
||||
### Estructura de Archivos
|
||||
|
||||
```
|
||||
water-api/src/
|
||||
├── index.ts # Setup Express: CORS, Helmet, body-parser, rutas
|
||||
├── config/
|
||||
│ ├── index.ts # Variables de entorno centralizadas
|
||||
│ └── database.ts # Pool de conexiones pg
|
||||
├── routes/ # Definicion de rutas (18 archivos)
|
||||
│ └── organismo-operador.routes.ts # Rutas CRUD organismos
|
||||
├── controllers/ # Controladores HTTP
|
||||
│ └── organismo-operador.controller.ts
|
||||
├── services/ # Logica de negocio (19 modulos)
|
||||
│ └── organismo-operador.service.ts
|
||||
├── middleware/
|
||||
│ ├── auth.middleware.ts # Verificacion JWT + extraccion de rol + requireRole
|
||||
│ ├── audit.middleware.ts # Auto-logging de acciones
|
||||
│ └── ttsWebhook.middleware.ts # Verificacion de secreto webhook
|
||||
├── validators/ # Schemas de validacion Zod
|
||||
├── utils/
|
||||
│ ├── jwt.ts # sign/verify de tokens (incluye organismoOperadorId)
|
||||
│ ├── password.ts # hash/compare con bcrypt
|
||||
│ ├── logger.ts # Winston con formato timestamp
|
||||
│ └── scope.ts # Filtrado por scope de rol (ADMIN/ORGANISMO/OPERATOR)
|
||||
├── jobs/
|
||||
│ └── negativeFlowDetection.ts # Cron de deteccion de flujo negativo
|
||||
└── types/ # Interfaces TypeScript
|
||||
```
|
||||
|
||||
### Endpoints Detallados
|
||||
|
||||
#### Autenticacion (`/api/auth`)
|
||||
| Metodo | Ruta | Descripcion | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| POST | `/login` | Autenticar usuario | No |
|
||||
| POST | `/refresh` | Renovar access token | No (usa refresh token) |
|
||||
| POST | `/logout` | Cerrar sesion | Si |
|
||||
| GET | `/me` | Obtener perfil actual | Si |
|
||||
| PATCH | `/me` | Actualizar perfil | Si |
|
||||
|
||||
#### Proyectos (`/api/projects`)
|
||||
| Metodo | Ruta | Descripcion | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/` | Listar proyectos | Si |
|
||||
| GET | `/:id` | Obtener proyecto | Si |
|
||||
| GET | `/:id/stats` | Estadisticas del proyecto | Si |
|
||||
| POST | `/` | Crear proyecto | Si (ADMIN/OPERATOR) |
|
||||
| PUT | `/:id` | Actualizar proyecto | Si (ADMIN/OPERATOR) |
|
||||
| DELETE | `/:id` | Eliminar proyecto | Si (ADMIN) |
|
||||
|
||||
#### Medidores (`/api/meters`)
|
||||
| Metodo | Ruta | Descripcion | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/` | Listar medidores (filtros: project, status, type) | Si |
|
||||
| GET | `/:id` | Obtener medidor | Si |
|
||||
| GET | `/:id/readings` | Historial de lecturas del medidor | Si |
|
||||
| POST | `/` | Crear medidor | Si (ADMIN/OPERATOR) |
|
||||
| PUT | `/:id` | Actualizar medidor | Si (ADMIN/OPERATOR) |
|
||||
| DELETE | `/:id` | Eliminar medidor | Si (ADMIN) |
|
||||
|
||||
#### Concentradores (`/api/concentrators`)
|
||||
| Metodo | Ruta | Descripcion | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/` | Listar concentradores | Si |
|
||||
| GET | `/:id` | Obtener concentrador | Si |
|
||||
| POST | `/` | Crear concentrador | Si (ADMIN/OPERATOR) |
|
||||
| PUT | `/:id` | Actualizar concentrador | Si (ADMIN/OPERATOR) |
|
||||
| DELETE | `/:id` | Eliminar concentrador | Si (ADMIN) |
|
||||
|
||||
#### Gateways (`/api/gateways`)
|
||||
| Metodo | Ruta | Descripcion | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/` | Listar gateways | Si |
|
||||
| GET | `/:id` | Obtener gateway | Si |
|
||||
| GET | `/:id/devices` | Dispositivos del gateway | Si |
|
||||
| POST | `/` | Crear gateway | Si (ADMIN/OPERATOR) |
|
||||
| PUT | `/:id` | Actualizar gateway | Si (ADMIN/OPERATOR) |
|
||||
| DELETE | `/:id` | Eliminar gateway | Si (ADMIN) |
|
||||
|
||||
#### Dispositivos (`/api/devices`)
|
||||
| Metodo | Ruta | Descripcion | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/` | Listar dispositivos | Si |
|
||||
| GET | `/:id` | Obtener dispositivo | Si |
|
||||
| GET | `/dev-eui/:devEui` | Buscar por DevEUI | Si |
|
||||
| POST | `/` | Crear dispositivo | Si (ADMIN/OPERATOR) |
|
||||
| PUT | `/:id` | Actualizar dispositivo | Si (ADMIN/OPERATOR) |
|
||||
| DELETE | `/:id` | Eliminar dispositivo | Si (ADMIN) |
|
||||
|
||||
#### Organismos Operadores (`/api/organismos-operadores`) - Solo ADMIN
|
||||
| Metodo | Ruta | Descripcion | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/` | Listar organismos operadores | Si (ADMIN) |
|
||||
| GET | `/:id` | Obtener organismo operador | Si (ADMIN) |
|
||||
| POST | `/` | Crear organismo operador | Si (ADMIN) |
|
||||
| PUT | `/:id` | Actualizar organismo operador | Si (ADMIN) |
|
||||
| DELETE | `/:id` | Eliminar organismo operador | Si (ADMIN) |
|
||||
|
||||
#### Usuarios (`/api/users`) - ADMIN y ORGANISMO_OPERADOR
|
||||
| Metodo | Ruta | Descripcion | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/` | Listar usuarios (filtrado por scope) | Si (ADMIN/ORGANISMO_OPERADOR) |
|
||||
| GET | `/:id` | Obtener usuario | Si (ADMIN o self) |
|
||||
| POST | `/` | Crear usuario | Si (ADMIN/ORGANISMO_OPERADOR) |
|
||||
| PUT | `/:id` | Actualizar usuario | Si (ADMIN o self) |
|
||||
| DELETE | `/:id` | Desactivar usuario | Si (ADMIN) |
|
||||
| PUT | `/:id/password` | Cambiar contrasena | Si (self) |
|
||||
|
||||
#### Roles (`/api/roles`)
|
||||
| Metodo | Ruta | Descripcion | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/` | Listar roles | Si |
|
||||
| GET | `/:id` | Obtener rol con conteo de usuarios | Si |
|
||||
| POST | `/` | Crear rol | Si (ADMIN) |
|
||||
| PUT | `/:id` | Actualizar rol | Si (ADMIN) |
|
||||
| DELETE | `/:id` | Eliminar rol | Si (ADMIN) |
|
||||
|
||||
#### Tipos de Medidor (`/api/meter-types`)
|
||||
| Metodo | Ruta | Descripcion | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/` | Listar tipos | Si |
|
||||
| GET | `/:id` | Obtener por ID | Si |
|
||||
| GET | `/code/:code` | Obtener por codigo | Si |
|
||||
| POST | `/` | Crear tipo | Si (ADMIN) |
|
||||
| PUT | `/:id` | Actualizar tipo | Si (ADMIN) |
|
||||
| DELETE | `/:id` | Eliminar tipo | Si (ADMIN) |
|
||||
|
||||
#### Lecturas (`/api/readings`)
|
||||
| Metodo | Ruta | Descripcion | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/` | Listar lecturas (filtros: proyecto, fecha, medidor) | Si |
|
||||
| GET | `/summary` | Resumen de consumo | Si |
|
||||
| GET | `/:id` | Obtener lectura | Si |
|
||||
| POST | `/` | Crear lectura | Si |
|
||||
| DELETE | `/:id` | Eliminar lectura | Si (ADMIN) |
|
||||
|
||||
#### Notificaciones (`/api/notifications`)
|
||||
| Metodo | Ruta | Descripcion | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/` | Listar notificaciones del usuario | Si |
|
||||
| GET | `/unread-count` | Conteo de no leidas | Si |
|
||||
| GET | `/:id` | Obtener notificacion | Si |
|
||||
| PATCH | `/:id/read` | Marcar como leida | Si |
|
||||
| PATCH | `/read-all` | Marcar todas como leidas | Si |
|
||||
| DELETE | `/:id` | Eliminar notificacion | Si |
|
||||
|
||||
#### Auditoria (`/api/audit-logs`) - Solo ADMIN
|
||||
| Metodo | Ruta | Descripcion | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/` | Listar logs | Si (ADMIN) |
|
||||
| GET | `/my-activity` | Actividad del usuario actual | Si |
|
||||
| GET | `/statistics` | Estadisticas de auditoria | Si (ADMIN) |
|
||||
| GET | `/:id` | Detalle de un log | Si (ADMIN) |
|
||||
| GET | `/record/:tableName/:recordId` | Logs de un registro especifico | Si (ADMIN) |
|
||||
|
||||
#### Carga Masiva
|
||||
| Metodo | Ruta | Descripcion | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| POST | `/bulk-upload/meters` | Subir Excel de medidores | Si |
|
||||
| GET | `/bulk-upload/meters/template` | Descargar plantilla Excel | Si |
|
||||
| POST | `/csv-upload/meters` | Subir CSV de medidores (upsert) | No |
|
||||
| POST | `/csv-upload/readings` | Subir CSV de lecturas | No |
|
||||
| GET | `/csv-upload/meters/template` | Plantilla CSV medidores | No |
|
||||
| GET | `/csv-upload/readings/template` | Plantilla CSV lecturas | No |
|
||||
|
||||
#### TTS Webhooks (`/api/webhooks/tts`)
|
||||
| Metodo | Ruta | Descripcion | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/health` | Health check del webhook | No |
|
||||
| POST | `/uplink` | Recibir mensajes uplink | Webhook secret |
|
||||
| POST | `/join` | Recibir eventos de join | Webhook secret |
|
||||
| POST | `/downlink/ack` | Recibir acks de downlink | Webhook secret |
|
||||
|
||||
#### Sistema (`/api/system`) - Solo ADMIN
|
||||
| Metodo | Ruta | Descripcion | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/metrics` | Metricas del servidor (CPU, memoria, requests) | Si (ADMIN) |
|
||||
| GET | `/health` | Health check detallado | Si (ADMIN) |
|
||||
| GET | `/meters-locations` | Coordenadas de medidores para mapa | Si (ADMIN) |
|
||||
| GET | `/report-stats` | Estadisticas para reportes | Si (ADMIN) |
|
||||
|
||||
---
|
||||
|
||||
## Base de Datos
|
||||
|
||||
### Esquema Relacional
|
||||
|
||||
```
|
||||
roles ←─── users ←─── refresh_tokens
|
||||
│
|
||||
▼
|
||||
projects
|
||||
╱ │ ╲
|
||||
╱ │ ╲
|
||||
▼ ▼ ▼
|
||||
concentrators gateways meters ──→ meter_readings
|
||||
│ │
|
||||
▼ │
|
||||
devices ─────┘
|
||||
│
|
||||
▼
|
||||
tts_uplink_logs
|
||||
```
|
||||
|
||||
### ENUMs de PostgreSQL
|
||||
- `role_name`: ADMIN, ORGANISMO_OPERADOR, OPERATOR
|
||||
- `project_status`: ACTIVE, INACTIVE, COMPLETED
|
||||
- `device_status`: ACTIVE, INACTIVE, OFFLINE, MAINTENANCE, ERROR
|
||||
- `meter_type`: WATER, GAS, ELECTRIC
|
||||
- `reading_type`: AUTOMATIC, MANUAL, SCHEDULED
|
||||
|
||||
### Migraciones SQL
|
||||
|
||||
Ejecutar en orden despues del schema principal:
|
||||
|
||||
1. `schema.sql` - Schema principal con tablas base, 2 vistas, seed de roles y admin
|
||||
2. `add_audit_logs.sql` - Tabla de logs de auditoria
|
||||
3. `add_notifications.sql` - Tabla de notificaciones
|
||||
4. `add_meter_extended_fields.sql` - Campos extendidos para medidores
|
||||
5. `create_meter_types.sql` - Tabla de tipos de medidor
|
||||
6. `add_meter_project_relation.sql` - Relacion meter-proyecto
|
||||
7. `add_user_project_relation.sql` - Relacion user-proyecto
|
||||
8. `add_organismos_operadores.sql` - Tabla organismos_operadores, FK en projects y users, rol ORGANISMO_OPERADOR
|
||||
9. `add_user_meter_fields.sql` - Campos adicionales en users y meters (cespt_account, cadastral_key)
|
||||
|
||||
---
|
||||
|
||||
## Autenticacion y Autorizacion
|
||||
|
||||
### Flujo JWT
|
||||
|
||||
```
|
||||
┌──────────┐ POST /auth/login ┌──────────┐
|
||||
│ Cliente │ ───────────────────→ │ Backend │
|
||||
│ │ ←─────────────────── │ │
|
||||
└──────────┘ {accessToken, └──────────┘
|
||||
refreshToken} │
|
||||
│ │ bcrypt.compare()
|
||||
│ Authorization: Bearer <token> │ jwt.sign()
|
||||
│ ───────────────────────────→ │
|
||||
│ │ jwt.verify()
|
||||
│ │
|
||||
│ 401 Unauthorized │
|
||||
│ ←─────────────────────────── │
|
||||
│ │
|
||||
│ POST /auth/refresh │
|
||||
│ {refreshToken} │
|
||||
│ ───────────────────────────→ │
|
||||
│ ←─────────────────────────── │
|
||||
│ {newAccessToken} │
|
||||
```
|
||||
|
||||
### Tokens
|
||||
- **Access Token** - JWT con userId, roleId, roleName, projectId, organismoOperadorId. Expira en 15 minutos.
|
||||
- **Refresh Token** - JWT almacenado hasheado en BD. Expira en 7 dias. Revocable.
|
||||
|
||||
### Token Refresh Automatico (Frontend)
|
||||
|
||||
El cliente HTTP (`src/api/client.ts`) intercepta respuestas 401 y automaticamente:
|
||||
1. Pone en cola las peticiones pendientes
|
||||
2. Llama a `/auth/refresh` con el refresh token
|
||||
3. Reintenta las peticiones con el nuevo access token
|
||||
|
||||
### Almacenamiento en localStorage
|
||||
|
||||
```
|
||||
grh_access_token → JWT access token
|
||||
grh_refresh_token → JWT refresh token
|
||||
water_project_settings_v1 → {theme, compactMode}
|
||||
mock_avatar → Avatar en base64
|
||||
```
|
||||
|
||||
### Control de Acceso por Rol (Jerarquia de 3 niveles)
|
||||
|
||||
| Recurso | ADMIN | ORGANISMO_OPERADOR | OPERATOR |
|
||||
|---------|-------|-------------------|----------|
|
||||
| Organismos | CRUD completo | Sin acceso | Sin acceso |
|
||||
| Usuarios | CRUD completo | CRUD (su organismo) | Sin acceso |
|
||||
| Proyectos | CRUD completo | Leer (su organismo) | Leer (su proyecto) |
|
||||
| Medidores | CRUD completo | Leer (su organismo) | Leer (su proyecto) |
|
||||
| Lecturas | CRUD + eliminar | Leer (su organismo) | Leer (su proyecto) |
|
||||
| Historico | Todos los medidores | Medidores de su organismo | Medidores de su proyecto |
|
||||
| Analytics | Completo | Completo | Sin acceso |
|
||||
| Conectores | Completo | Sin acceso | Sin acceso |
|
||||
| Auditoria | Completa | Sin acceso | Sin acceso |
|
||||
|
||||
### Scope Filtering (Backend)
|
||||
|
||||
Todos los servicios aplican filtrado automatico basado en el rol del usuario:
|
||||
|
||||
```
|
||||
ADMIN → Sin filtro (ve todos los datos)
|
||||
ORGANISMO_OP. → project_id IN (SELECT id FROM projects WHERE organismo_operador_id = $N)
|
||||
OPERATOR → project_id = $N (solo su proyecto asignado)
|
||||
```
|
||||
|
||||
El utility `water-api/src/utils/scope.ts` centraliza esta logica.
|
||||
|
||||
### Helpers del Frontend
|
||||
|
||||
```typescript
|
||||
getCurrentUserRole() // → "ADMIN" | "ORGANISMO_OPERADOR" | "OPERATOR"
|
||||
getCurrentUserId() // → UUID string
|
||||
getCurrentUserProjectId() // → UUID string | undefined
|
||||
getCurrentUserOrganismoId() // → UUID string | undefined
|
||||
isCurrentUserAdmin() // → boolean
|
||||
isCurrentUserOrganismo() // → boolean
|
||||
isCurrentUserOperator() // → boolean
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Capa de API del Frontend
|
||||
|
||||
### Cliente HTTP (`src/api/client.ts`)
|
||||
|
||||
Wrapper de `fetch` con:
|
||||
- Inyeccion automatica de `Authorization: Bearer <token>`
|
||||
- Refresh automatico de tokens en 401
|
||||
- Cola de peticiones durante el refresh (previene race conditions)
|
||||
- Buffer de 30 segundos antes de expirar
|
||||
- Transformacion de snake_case (backend) a camelCase (frontend)
|
||||
|
||||
### Modulos de API
|
||||
|
||||
| Archivo | Descripcion |
|
||||
|---------|-------------|
|
||||
| `auth.ts` | Login, logout, refresh, getMe, helpers de roles (3 niveles) |
|
||||
| `client.ts` | Cliente HTTP base con JWT |
|
||||
| `meters.ts` | CRUD medidores + lecturas historicas paginadas |
|
||||
| `readings.ts` | Lecturas y resumen de consumo |
|
||||
| `projects.ts` | CRUD proyectos + nombres |
|
||||
| `concentrators.ts` | CRUD concentradores |
|
||||
| `users.ts` | CRUD usuarios |
|
||||
| `roles.ts` | CRUD roles |
|
||||
| `organismos.ts` | CRUD organismos operadores |
|
||||
| `analytics.ts` | Metricas, ubicaciones, reportes |
|
||||
| `notifications.ts` | Notificaciones del usuario |
|
||||
| `audit.ts` | Logs de auditoria |
|
||||
| `me.ts` | Perfil de usuario actual |
|
||||
| `meterTypes.ts` | Tipos de medidor |
|
||||
| `types.ts` | Interfaces compartidas |
|
||||
|
||||
---
|
||||
|
||||
## Hooks Personalizados
|
||||
|
||||
### useMeters
|
||||
|
||||
Gestiona el estado completo del modulo de medidores.
|
||||
|
||||
```typescript
|
||||
interface UseMetersReturn {
|
||||
// Datos
|
||||
meters: Meter[];
|
||||
filteredMeters: Meter[];
|
||||
projectNames: string[];
|
||||
|
||||
// Estado
|
||||
loading: boolean;
|
||||
selectedMeter: Meter | null;
|
||||
selectedProject: string | null;
|
||||
searchTerm: string;
|
||||
|
||||
// Acciones
|
||||
setSelectedMeter: (meter: Meter | null) => void;
|
||||
setSelectedProject: (project: string | null) => void;
|
||||
setSearchTerm: (term: string) => void;
|
||||
refreshData: () => Promise<void>;
|
||||
loadMeters: () => Promise<void>;
|
||||
|
||||
// CRUD
|
||||
handleCreate: (data: Partial<Meter>) => Promise<void>;
|
||||
handleUpdate: (id: string, data: Partial<Meter>) => Promise<void>;
|
||||
handleDelete: (id: string) => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### useConcentrators
|
||||
|
||||
Estructura equivalente a `useMeters` para el modulo de concentradores.
|
||||
|
||||
### useNotifications
|
||||
|
||||
Gestiona notificaciones del usuario con polling periodico.
|
||||
|
||||
```typescript
|
||||
interface UseNotificationsReturn {
|
||||
notifications: Notification[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
markAsRead: (id: string) => Promise<void>;
|
||||
markAllAsRead: () => Promise<void>;
|
||||
deleteNotification: (id: string) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### Flujo de Datos
|
||||
|
||||
```
|
||||
API Layer (fetch + JWT)
|
||||
│
|
||||
▼
|
||||
Custom Hook (useMeters, etc.)
|
||||
│
|
||||
▼
|
||||
Page Component (MeterPage, etc.)
|
||||
│
|
||||
├──────────────┬──────────────┐
|
||||
▼ ▼ ▼
|
||||
Sidebar Table Modal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sistema de Temas
|
||||
|
||||
### Configuracion
|
||||
|
||||
Tres modos disponibles: `system`, `light`, `dark`.
|
||||
|
||||
```typescript
|
||||
type Theme = "system" | "light" | "dark";
|
||||
|
||||
// Aplicacion del tema
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
```
|
||||
|
||||
### Paleta de Colores (Dark Mode)
|
||||
|
||||
El dark mode usa la paleta **Zinc** de Tailwind:
|
||||
- Fondo principal: `bg-zinc-900`
|
||||
- Fondo de tarjetas: `bg-zinc-800`
|
||||
- Bordes: `border-zinc-700`
|
||||
- Texto primario: `text-zinc-100`
|
||||
- Texto secundario: `text-zinc-400`
|
||||
|
||||
### Persistencia
|
||||
|
||||
```typescript
|
||||
const SETTINGS_KEY = "water_project_settings_v1";
|
||||
|
||||
interface Settings {
|
||||
theme: "system" | "light" | "dark";
|
||||
compactMode: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conectores Externos
|
||||
|
||||
### SH-Meters
|
||||
Conector para sistema de medidores SH. Muestra estado de conexion, ultima sincronizacion y datos sincronizados.
|
||||
|
||||
### XMeters
|
||||
Conector para sistema XMeters con funcionalidad similar a SH-Meters.
|
||||
|
||||
### The Things Stack (TTS)
|
||||
Integracion con LoRaWAN via webhooks:
|
||||
|
||||
- **Uplink** (`POST /api/webhooks/tts/uplink`): Recibe lecturas de sensores, decodifica payload, crea readings automaticamente
|
||||
- **Join** (`POST /api/webhooks/tts/join`): Registra eventos de conexion de dispositivos
|
||||
- **Downlink Ack** (`POST /api/webhooks/tts/downlink/ack`): Confirma envio de comandos a dispositivos
|
||||
|
||||
Los webhooks usan verificacion por secreto en lugar de JWT.
|
||||
|
||||
### Sincronizacion
|
||||
Los conectores tienen sincronizacion programada a las **9:00 AM**.
|
||||
|
||||
---
|
||||
|
||||
## Tareas Programadas
|
||||
|
||||
### Deteccion de Flujo Negativo
|
||||
|
||||
Job de `node-cron` que:
|
||||
1. Revisa lecturas recientes de medidores
|
||||
2. Detecta valores negativos o anomalias
|
||||
3. Genera notificaciones automaticas para los usuarios
|
||||
|
||||
---
|
||||
|
||||
## Guia de Desarrollo
|
||||
|
||||
### Agregar una Nueva Pagina
|
||||
|
||||
1. **Crear el componente** en `src/pages/nueva/NuevaPage.tsx`
|
||||
2. **Agregar al tipo Page** en `App.tsx`
|
||||
3. **Agregar al Sidebar** en `Sidebar.tsx` (menuItems)
|
||||
4. **Agregar al renderizado** en `App.tsx` (switch en `renderPage()`)
|
||||
|
||||
### Agregar un Nuevo Endpoint en el Backend
|
||||
|
||||
1. **Crear servicio** en `water-api/src/services/nuevo.service.ts`
|
||||
2. **Crear controlador** en `water-api/src/controllers/nuevo.controller.ts`
|
||||
3. **Crear validador** (opcional) en `water-api/src/validators/nuevo.validator.ts`
|
||||
4. **Crear rutas** en `water-api/src/routes/nuevo.routes.ts`
|
||||
5. **Registrar rutas** en `water-api/src/routes/index.ts`
|
||||
|
||||
### Agregar un Modulo de API en el Frontend
|
||||
|
||||
1. **Crear archivo** en `src/api/nuevo.ts`
|
||||
2. **Usar el cliente HTTP** importando de `./client.ts`
|
||||
3. **Crear hook** (opcional) en `src/pages/nueva/useNuevo.ts`
|
||||
|
||||
### Convenciones de Codigo
|
||||
|
||||
| Elemento | Convencion | Ejemplo |
|
||||
|----------|-----------|---------|
|
||||
| Componentes React | PascalCase.tsx | `MeterPage.tsx` |
|
||||
| Hooks | camelCase con prefijo `use` | `useMeters.ts` |
|
||||
| Servicios backend | camelCase.service.ts | `meter.service.ts` |
|
||||
| Rutas backend | camelCase.routes.ts | `meter.routes.ts` |
|
||||
| Constantes | UPPER_SNAKE_CASE | `API_BASE_URL` |
|
||||
| DB columnas | snake_case | `serial_number` |
|
||||
| API response → Frontend | snake_case → camelCase | `serial_number` → `serialNumber` |
|
||||
| CSS | Tailwind utility classes | `bg-zinc-800 dark:text-white` |
|
||||
|
||||
### Seguridad
|
||||
|
||||
- **Helmet.js** para headers HTTP seguros
|
||||
- **CORS** configurado para origenes especificos
|
||||
- **Bcrypt** (12 rounds) para hash de contrasenas
|
||||
- **JWT** con access + refresh tokens
|
||||
- **Queries parametrizadas** para prevenir SQL injection
|
||||
- **Zod** para validacion de inputs
|
||||
- **Audit logging** automatico de acciones
|
||||
|
||||
### Testing
|
||||
|
||||
El proyecto actualmente no tiene suite de tests. Para agregar:
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
npm install -D vitest @testing-library/react @testing-library/jest-dom
|
||||
|
||||
# Backend
|
||||
cd water-api
|
||||
npm install -D vitest supertest @types/supertest
|
||||
```
|
||||
329
ESTADO_ACTUAL.md
Normal file
329
ESTADO_ACTUAL.md
Normal 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
|
||||
468
README.md
468
README.md
@@ -1,30 +1,456 @@
|
||||
# React + TypeScript + Vite
|
||||
# GRH - Sistema de Gestion de Recursos Hidricos
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
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).
|
||||
|
||||
Currently, two official plugins are available:
|
||||
---
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
## Descripcion General
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
El **Sistema GRH** es una aplicacion web completa para organismos operadores de agua (CESPT Tijuana, Tecate, Mexicali, etc.) que incluye:
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
- **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
|
||||
- **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
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
---
|
||||
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
## 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 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||
Adicionalmente existe un **Upload Panel** (`upload-panel/`) como aplicacion separada para carga masiva de datos via CSV.
|
||||
|
||||
---
|
||||
|
||||
## Stack Tecnologico
|
||||
|
||||
### Frontend
|
||||
| Tecnologia | Version | Proposito |
|
||||
|------------|---------|-----------|
|
||||
| React | 18.2.0 | Framework UI |
|
||||
| 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 (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 |
|
||||
|
||||
### 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 |
|
||||
|
||||
---
|
||||
|
||||
## Instalacion
|
||||
|
||||
### Prerrequisitos
|
||||
|
||||
- Node.js >= 18.x
|
||||
- npm >= 9.x
|
||||
- PostgreSQL >= 14.x
|
||||
|
||||
### 1. Clonar el repositorio
|
||||
|
||||
```bash
|
||||
git clone https://git.consultoria-as.com/consultoria-as/GRH.git
|
||||
cd GRH
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### 4. Configurar el frontend
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
cp .env.example .env
|
||||
# Editar .env con la URL del backend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 5. Iniciar en desarrollo
|
||||
|
||||
```bash
|
||||
# Terminal 1 - Backend
|
||||
cd water-api
|
||||
npm run dev
|
||||
|
||||
# Terminal 2 - Frontend
|
||||
cd ..
|
||||
npm run dev
|
||||
```
|
||||
|
||||
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` | 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
|
||||
|
||||
```
|
||||
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/ # 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/
|
||||
│ │ ├── 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
|
||||
│ │ │ ├── MetersTable.tsx
|
||||
│ │ │ ├── MetersModal.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
|
||||
│ │
|
||||
│ ├── hooks/
|
||||
│ │ └── useNotifications.ts
|
||||
│ ├── App.tsx # Componente raiz (routing + auth)
|
||||
│ ├── main.tsx # Punto de entrada React
|
||||
│ └── index.css # Estilos globales (Tailwind)
|
||||
│
|
||||
├── 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Base de Datos
|
||||
|
||||
### Jerarquia de datos
|
||||
```
|
||||
Projects → Concentrators → Meters → Readings
|
||||
→ Gateways → Devices ↗
|
||||
```
|
||||
|
||||
### Tablas principales
|
||||
|
||||
| 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 |
|
||||
|
||||
### Vistas
|
||||
- `meter_stats_by_project` - Estadisticas agregadas de medidores por proyecto
|
||||
- `device_status_summary` - Resumen de estados de dispositivos por proyecto
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Todos los endpoints estan bajo el prefijo `/api/`. La mayoria requieren autenticacion JWT.
|
||||
|
||||
| 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
|
||||
|
||||
El sistema usa **JWT con refresh tokens**:
|
||||
|
||||
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`
|
||||
|
||||
### 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
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
npm run build # Genera dist/
|
||||
|
||||
# Backend
|
||||
cd water-api
|
||||
npm run build # Genera dist/
|
||||
npm run start # Ejecuta el build
|
||||
```
|
||||
|
||||
### URLs de produccion
|
||||
- **Frontend:** `https://sistema.gestionrecursoshidricos.com`
|
||||
- **Backend:** `https://api.gestionrecursoshidricos.com`
|
||||
|
||||
---
|
||||
|
||||
## Repositorios
|
||||
|
||||
| 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 / Consultoria AS.
|
||||
|
||||
652
docs/API.md
Normal file
652
docs/API.md
Normal 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
556
docs/ARQUITECTURA.md
Normal 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
394
docs/INSTALACION.md
Normal 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
449
docs/MANUAL_USUARIO.md
Normal 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
126
docs/README.md
Normal 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
297
docs/UPLOAD_PANEL.md
Normal 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/`
|
||||
170
docs/plans/PLAN_ROL_ORGANISMOS_OPERADORES.md
Normal file
170
docs/plans/PLAN_ROL_ORGANISMOS_OPERADORES.md
Normal 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
|
||||
@@ -2,9 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<title>GRH</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
104
package-lock.json
generated
104
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
3611
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
214
src/App.tsx
214
src/App.tsx
@@ -8,58 +8,136 @@ 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 { uploadMyAvatar, updateMyProfile } from "./api/me";
|
||||
import { updateMyProfile } from "./api/me";
|
||||
|
||||
import SettingsModal, {
|
||||
type AppSettings,
|
||||
loadSettings,
|
||||
} from "./components/SettingsModals";
|
||||
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
|
||||
// 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";
|
||||
|
||||
export type Page =
|
||||
| "home"
|
||||
| "projects"
|
||||
| "meters"
|
||||
| "concentrators"
|
||||
| "consumption"
|
||||
| "auditoria"
|
||||
| "users"
|
||||
| "roles";
|
||||
| "roles"
|
||||
| "sh-meters"
|
||||
| "xmeters"
|
||||
| "tts"
|
||||
| "analytics-map"
|
||||
| "analytics-reports"
|
||||
| "analytics-server"
|
||||
| "organismos"
|
||||
| "historico";
|
||||
|
||||
export default function App() {
|
||||
const [isAuth, setIsAuth] = useState<boolean>(false);
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
|
||||
// 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 = async () => {
|
||||
try {
|
||||
await authLogout();
|
||||
} catch {
|
||||
// Ignore logout errors
|
||||
}
|
||||
clearAuth();
|
||||
setUser(null);
|
||||
setIsAuth(false);
|
||||
// Reset navigation
|
||||
setPage("home");
|
||||
setSubPage("default");
|
||||
setSelectedProject("");
|
||||
};
|
||||
|
||||
// ✅ confirm logout modal state
|
||||
const [logoutOpen, setLogoutOpen] = useState(false);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
|
||||
const [page, setPage] = useState<Page>("home");
|
||||
const [subPage, setSubPage] = useState<string>("default");
|
||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||
|
||||
// ✅ perfil usuario + modal
|
||||
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", // ✅ NUEVO: Empresa/Organismo
|
||||
});
|
||||
|
||||
// Settings state
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settings, setSettings] = useState<AppSettings>(() => loadSettings());
|
||||
|
||||
const navigateToMetersWithProject = (projectName: string) => {
|
||||
setSelectedProject(projectName);
|
||||
setSubPage(projectName); // útil para breadcrumb si lo usas
|
||||
setSubPage(projectName);
|
||||
setPage("meters");
|
||||
};
|
||||
|
||||
// ✅ handlers
|
||||
const handleUploadAvatar = async (file: File) => {
|
||||
// 1) Guardar como base64 en localStorage (demo)
|
||||
const base64 = await fileToBase64(file);
|
||||
localStorage.setItem("mock_avatar", base64 as string);
|
||||
|
||||
// 2) Guardar en state para que se vea inmediato
|
||||
setUser((prev) => ({ ...prev, avatarUrl: base64 as string }));
|
||||
localStorage.setItem("mock_avatar", base64);
|
||||
setUser((prev) => prev ? { ...prev, avatar_url: base64 } : null);
|
||||
};
|
||||
|
||||
|
||||
function fileToBase64(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -69,24 +147,22 @@ export default function App() {
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ ahora también recibe organismName
|
||||
const handleSaveProfile = async (next: {
|
||||
name: string;
|
||||
email: string;
|
||||
organismName?: string;
|
||||
}) => {
|
||||
if (!user) return;
|
||||
setSavingProfile(true);
|
||||
try {
|
||||
const updated = await updateMyProfile(next);
|
||||
|
||||
setUser((prev) => ({
|
||||
setUser((prev) => prev ? ({
|
||||
...prev,
|
||||
// si backend regresa valores, los usamos; si no, usamos "next" o lo anterior
|
||||
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 {
|
||||
@@ -94,7 +170,6 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// Aplica theme al cargar / cambiar (para cubrir refresh)
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("dark");
|
||||
@@ -116,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 (
|
||||
@@ -134,15 +229,26 @@ 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} />;
|
||||
}
|
||||
|
||||
return (
|
||||
// Blindaje global del layout
|
||||
<div
|
||||
className={[
|
||||
"flex h-screen w-full overflow-hidden",
|
||||
settings.compactMode ? "text-sm" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{/* Sidebar no debe encogerse */}
|
||||
<div className="shrink-0">
|
||||
<Sidebar
|
||||
setPage={(p) => {
|
||||
@@ -153,29 +259,27 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* min-w-0: evita que páginas anchas (tablas) empujen el layout */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="shrink-0">
|
||||
<TopMenu
|
||||
page={page}
|
||||
subPage={subPage}
|
||||
setSubPage={setSubPage}
|
||||
setPage={setPage}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
// props de perfil
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
avatarUrl={user.avatarUrl}
|
||||
userName={user?.name ?? "Usuario"}
|
||||
userEmail={user?.email ?? ""}
|
||||
avatarUrl={user?.avatar_url ?? null}
|
||||
onOpenProfile={() => setProfileOpen(true)}
|
||||
onUploadAvatar={handleUploadAvatar}
|
||||
onRequestLogout={() => setLogoutOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scroll solo aquí */}
|
||||
<main className="min-w-0 flex-1 overflow-auto">{renderPage()}</main>
|
||||
{/* ✅ AQUÍ VA LA MARCA DE AGUA */}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Settings modal */}
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
@@ -183,19 +287,39 @@ export default function App() {
|
||||
setSettings={setSettings}
|
||||
/>
|
||||
|
||||
{/* ✅ Profile modal (con avatar + cambiar img + empresa) */}
|
||||
<ProfileModal
|
||||
open={profileOpen}
|
||||
loading={savingProfile}
|
||||
avatarUrl={user.avatarUrl} // ✅ NUEVO
|
||||
avatarUrl={user?.avatar_url ?? null}
|
||||
initial={{
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
organismName: user.organismName, // ✅ NUEVO
|
||||
name: user?.name ?? "",
|
||||
email: user?.email ?? "",
|
||||
organismName: "",
|
||||
}}
|
||||
onClose={() => setProfileOpen(false)}
|
||||
onSave={handleSaveProfile}
|
||||
onUploadAvatar={handleUploadAvatar} // ✅ NUEVO (botón Cambiar img en modal)
|
||||
onUploadAvatar={handleUploadAvatar}
|
||||
/>
|
||||
|
||||
{/* ✅ ConfirmModal: Cerrar sesión */}
|
||||
<ConfirmModal
|
||||
open={logoutOpen}
|
||||
title="Cerrar sesión"
|
||||
message="¿Estás seguro que deseas cerrar sesión?"
|
||||
confirmText="Cerrar sesión"
|
||||
cancelText="Cancelar"
|
||||
danger
|
||||
loading={loggingOut}
|
||||
onClose={() => setLogoutOpen(false)}
|
||||
onConfirm={async () => {
|
||||
setLoggingOut(true);
|
||||
try {
|
||||
await handleLogout();
|
||||
setLogoutOpen(false);
|
||||
} finally {
|
||||
setLoggingOut(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
86
src/api/analytics.ts
Normal file
86
src/api/analytics.ts
Normal 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
151
src/api/audit.ts
Normal 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
435
src/api/auth.ts
Normal 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
404
src/api/client.ts
Normal 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;
|
||||
@@ -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');
|
||||
/**
|
||||
* 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 response = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
/**
|
||||
* 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 });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch concentrators");
|
||||
}
|
||||
|
||||
const data: ConcentratorsResponse = await response.json();
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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"],
|
||||
},
|
||||
}),
|
||||
});
|
||||
// Handle array response (fallback)
|
||||
return transformArray<Concentrator>(response as Record<string, unknown>[]);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create concentrator: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const createdRecord = data.records?.[0];
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
const response = await apiClient.post<Record<string, unknown>>('/api/concentrators', backendData);
|
||||
return transformKeys<Concentrator>(response);
|
||||
}
|
||||
|
||||
if (!createdRecord) {
|
||||
throw new Error("Invalid response format: no record returned");
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
const response = await apiClient.patch<Record<string, unknown>>(`/api/concentrators/${id}`, backendData);
|
||||
return transformKeys<Concentrator>(response);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
|
||||
122
src/api/me.ts
122
src/api/me.ts
@@ -1,45 +1,83 @@
|
||||
/**
|
||||
* User Profile API
|
||||
* Handles all user profile-related API operations using the backend API client
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
/**
|
||||
* User entity from the backend
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
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 }> {
|
||||
const form = new FormData();
|
||||
form.append("avatar", file);
|
||||
|
||||
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") ?? ""}`,
|
||||
},
|
||||
});
|
||||
|
||||
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 };
|
||||
// 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: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => '');
|
||||
throw new Error(`Upload avatar failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
export async function updateMyProfile(input: {
|
||||
name: string;
|
||||
email: string;
|
||||
}): Promise<{ name?: string; email?: string; avatarUrl?: string | null }> {
|
||||
const res = await fetch("/api/me", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Authorization: `Bearer ${localStorage.getItem("token") ?? ""}`,
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Update profile failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
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
77
src/api/meterTypes.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
// 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 entity from the backend
|
||||
* Meters belong to Concentrators (LORA protocol)
|
||||
*/
|
||||
export interface Meter {
|
||||
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;
|
||||
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;
|
||||
// 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;
|
||||
meterLocation: string | null;
|
||||
concentratorId: string;
|
||||
concentratorName: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
export interface MeterReadingFilters {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedMeterReadings {
|
||||
data: MeterReading[];
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MetersResponse {
|
||||
records: MeterRecord[];
|
||||
next?: string;
|
||||
prev?: string;
|
||||
nestedNext?: string;
|
||||
nestedPrev?: string;
|
||||
/**
|
||||
* 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
|
||||
});
|
||||
|
||||
// Handle paginated response
|
||||
if (response && typeof response === 'object' && 'data' in response) {
|
||||
return transformArray<Meter>(response.data);
|
||||
}
|
||||
|
||||
// Fallback for non-paginated response
|
||||
return transformArray<Meter>(response as unknown as Record<string, unknown>[]);
|
||||
}
|
||||
|
||||
export 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;
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
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());
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
/**
|
||||
* 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;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch meters");
|
||||
}
|
||||
const response = await apiClient.patch<Record<string, unknown>>(`/api/meters/${id}`, backendData);
|
||||
return transformKeys<Meter>(response);
|
||||
}
|
||||
|
||||
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"] || "",
|
||||
}));
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
|
||||
return ans;
|
||||
} catch (error) {
|
||||
console.error("Error fetching meters:", error);
|
||||
throw error;
|
||||
/**
|
||||
* 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 {
|
||||
data: transformArray<MeterReading>(response.data),
|
||||
pagination: response.pagination,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.');
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to create meter: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
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,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const createdRecord = data.records?.[0];
|
||||
|
||||
if (!createdRecord) {
|
||||
throw new Error("Invalid response format: no record returned");
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error creating meter:", error);
|
||||
throw error;
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Error en la carga masiva');
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
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}`
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Download meter template Excel file
|
||||
*/
|
||||
export async function downloadMeterTemplate(): Promise<void> {
|
||||
const token = localStorage.getItem('grh_access_token');
|
||||
|
||||
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,
|
||||
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;
|
||||
if (!token) {
|
||||
throw new Error('No hay sesión activa. Por favor inicia sesión nuevamente.');
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
}),
|
||||
});
|
||||
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) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
`Bad Request: ${errorData.msg || "Invalid data provided"}`
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to delete meter: ${response.status} ${response.statusText}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
// 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(errorData.error || `Error ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting meter:", error);
|
||||
throw error;
|
||||
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_medidores.xlsx';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
101
src/api/notifications.ts
Normal file
101
src/api/notifications.ts
Normal 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
99
src/api/organismos.ts
Normal 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}`);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch projects");
|
||||
}
|
||||
/**
|
||||
* 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');
|
||||
|
||||
const data: ProjectsResponse = await response.json();
|
||||
|
||||
if (!data.records || data.records.length === 0) {
|
||||
console.warn("No project records found from API");
|
||||
return [];
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
};
|
||||
|
||||
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 data: ProjectsResponse = await response.json();
|
||||
|
||||
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}`
|
||||
);
|
||||
// Handle paginated response
|
||||
if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) {
|
||||
return transformArray<Project>(response.data);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// Handle array response (fallback)
|
||||
return transformArray<Project>(response as Record<string, unknown>[]);
|
||||
}
|
||||
|
||||
const createdRecord = data.records?.[0];
|
||||
if (!createdRecord) {
|
||||
throw new Error("Invalid response format: no record returned");
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
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,
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
};
|
||||
const response = await apiClient.post<Record<string, unknown>>('/api/projects', backendData);
|
||||
return transformKeys<Project>(response);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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}`
|
||||
);
|
||||
}
|
||||
const response = await apiClient.patch<Record<string, unknown>>(`/api/projects/${id}`, backendData);
|
||||
return transformKeys<Project>(response);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
|
||||
const updatedRecord = data.records?.[0];
|
||||
if (!updatedRecord) {
|
||||
throw new Error("Invalid response format: no record returned");
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
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}`
|
||||
);
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
271
src/api/readings.ts
Normal file
271
src/api/readings.ts
Normal 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
68
src/api/roles.ts
Normal 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
133
src/api/types.ts
Normal 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
128
src/api/users.ts
Normal 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);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
BIN
src/assets/images/grhWatermark.png
Normal file
BIN
src/assets/images/grhWatermark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
257
src/components/NotificationDropdown.tsx
Normal file
257
src/components/NotificationDropdown.tsx
Normal 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;
|
||||
@@ -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,48 +116,192 @@ 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 */}
|
||||
<li>
|
||||
<button
|
||||
onClick={() => isExpanded && setUsersOpen(!usersOpen)}
|
||||
className="flex items-center w-full px-2 py-2 rounded-md hover:bg-white/10 font-bold"
|
||||
>
|
||||
<People className="w-5 h-5 shrink-0" />
|
||||
{isExpanded && (
|
||||
<>
|
||||
<span className="ml-3 flex-1 text-left">
|
||||
Users Management
|
||||
</span>
|
||||
{usersOpen ? <ExpandLess /> : <ExpandMore />}
|
||||
</>
|
||||
{/* USERS MANAGEMENT - ADMIN and ORGANISMO_OPERADOR */}
|
||||
{(isAdmin || isOrganismo) && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => isExpanded && setUsersOpen(!usersOpen)}
|
||||
className="flex items-center w-full px-2 py-2 rounded-md hover:bg-white/10 font-bold"
|
||||
>
|
||||
<People className="w-5 h-5 shrink-0" />
|
||||
{isExpanded && (
|
||||
<>
|
||||
<span className="ml-3 flex-1 text-left">
|
||||
Users Management
|
||||
</span>
|
||||
{usersOpen ? <ExpandLess /> : <ExpandMore />}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && usersOpen && (
|
||||
<ul className="mt-1 space-y-1 text-xs">
|
||||
<li>
|
||||
<button
|
||||
onClick={() => setPage("users")}
|
||||
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
|
||||
>
|
||||
Users
|
||||
</button>
|
||||
</li>
|
||||
{/* Roles - ADMIN only */}
|
||||
{isAdmin && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => setPage("roles")}
|
||||
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
|
||||
>
|
||||
Roles
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{isExpanded && usersOpen && (
|
||||
<ul className="mt-1 space-y-1 text-xs">
|
||||
<li>
|
||||
<button
|
||||
onClick={() => setPage("users")}
|
||||
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
|
||||
>
|
||||
Users
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onClick={() => setPage("roles")}
|
||||
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
|
||||
>
|
||||
Roles
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
{/* ORGANISMOS OPERADORES - ADMIN only */}
|
||||
{isAdmin && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => setPage("organismos")}
|
||||
className="flex items-center w-full px-2 py-2 rounded-md hover:bg-white/10 font-bold"
|
||||
>
|
||||
<Business className="w-5 h-5 shrink-0" />
|
||||
{isExpanded && <span className="ml-3">Organismos Operadores</span>}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{/* CONECTORES - ADMIN only */}
|
||||
{isAdmin && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => isExpanded && setConectoresOpen(!conectoresOpen)}
|
||||
className="flex items-center w-full px-2 py-2 rounded-md hover:bg-white/10 font-bold"
|
||||
>
|
||||
<Cable className="w-5 h-5 shrink-0" />
|
||||
{isExpanded && (
|
||||
<>
|
||||
<span className="ml-3 flex-1 text-left">
|
||||
Conectores
|
||||
</span>
|
||||
{conectoresOpen ? <ExpandLess /> : <ExpandMore />}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && conectoresOpen && (
|
||||
<ul className="mt-1 space-y-1 text-xs">
|
||||
<li>
|
||||
<button
|
||||
onClick={() => setPage("sh-meters")}
|
||||
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
|
||||
>
|
||||
SH-METERS
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onClick={() => setPage("xmeters")}
|
||||
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
|
||||
>
|
||||
XMETERS
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onClick={() => setPage("tts")}
|
||||
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
|
||||
>
|
||||
TTS
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
|
||||
{/* ANALYTICS - ADMIN and ORGANISMO_OPERADOR */}
|
||||
{(isAdmin || isOrganismo) && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => isExpanded && setAnalyticsOpen(!analyticsOpen)}
|
||||
className="flex items-center w-full px-2 py-2 rounded-md hover:bg-white/10 font-bold"
|
||||
>
|
||||
<BarChart className="w-5 h-5 shrink-0" />
|
||||
{isExpanded && (
|
||||
<>
|
||||
<span className="ml-3 flex-1 text-left">Analytics</span>
|
||||
{analyticsOpen ? <ExpandLess /> : <ExpandMore />}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && analyticsOpen && (
|
||||
<ul className="mt-1 space-y-1 text-xs">
|
||||
<li>
|
||||
<button
|
||||
onClick={() => setPage("analytics-map")}
|
||||
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
|
||||
>
|
||||
Mapa
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onClick={() => setPage("analytics-reports")}
|
||||
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
|
||||
>
|
||||
Reportes
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onClick={() => setPage("analytics-server")}
|
||||
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
|
||||
>
|
||||
Carga de Server
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -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;
|
||||
@@ -10,8 +13,10 @@ interface TopMenuProps {
|
||||
userEmail?: string;
|
||||
avatarUrl?: string | null;
|
||||
|
||||
onLogout?: () => void;
|
||||
onOpenProfile?: () => void;
|
||||
|
||||
// ✅ NUEVO: en vez de cerrar, pedimos confirmación desde App
|
||||
onRequestLogout?: () => void;
|
||||
}
|
||||
|
||||
const TopMenu: React.FC<TopMenuProps> = ({
|
||||
@@ -23,12 +28,36 @@ const TopMenu: React.FC<TopMenuProps> = ({
|
||||
userEmail,
|
||||
avatarUrl = null,
|
||||
|
||||
onLogout,
|
||||
onOpenProfile,
|
||||
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);
|
||||
@@ -37,7 +66,6 @@ const TopMenu: React.FC<TopMenuProps> = ({
|
||||
return (a + b).toUpperCase();
|
||||
}, [userName]);
|
||||
|
||||
// Cerrar al click afuera
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (!openUserMenu) return;
|
||||
@@ -48,7 +76,16 @@ const TopMenu: React.FC<TopMenuProps> = ({
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [openUserMenu]);
|
||||
|
||||
// Cerrar con ESC
|
||||
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);
|
||||
@@ -59,14 +96,14 @@ const TopMenu: React.FC<TopMenuProps> = ({
|
||||
|
||||
return (
|
||||
<header
|
||||
className="relative z-40 h-14 shrink-0 flex items-center justify-between px-4 text-white"
|
||||
className="relative z-20 h-14 shrink-0 flex items-center justify-between px-4 text-white"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, #4c5f9e, #2a355d, #566bb8, #3d4e87)",
|
||||
}}
|
||||
>
|
||||
{/* 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>
|
||||
@@ -78,18 +115,44 @@ const TopMenu: React.FC<TopMenuProps> = ({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ProjectBadge />
|
||||
</div>
|
||||
|
||||
{/* DERECHA */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
aria-label="Notificaciones"
|
||||
aria-label={isDarkMode ? "Cambiar a modo claro" : "Cambiar a modo oscuro"}
|
||||
className="p-2 rounded-full hover:bg-white/10 transition"
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
title={isDarkMode ? "Modo claro" : "Modo oscuro"}
|
||||
>
|
||||
<Bell size={20} />
|
||||
{isDarkMode ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</button>
|
||||
|
||||
<div className="relative" ref={notificationRef}>
|
||||
<button
|
||||
aria-label="Notificaciones"
|
||||
className="relative p-2 rounded-full hover:bg-white/10 transition"
|
||||
type="button"
|
||||
onClick={() => setOpenNotifications(!openNotifications)}
|
||||
>
|
||||
<Bell size={20} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<NotificationDropdown
|
||||
isOpen={openNotifications}
|
||||
onClose={() => setOpenNotifications(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* USER MENU */}
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
@@ -117,18 +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
|
||||
shadow-xl
|
||||
overflow-hidden
|
||||
z-50
|
||||
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}
|
||||
@@ -136,28 +195,27 @@ 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>
|
||||
</div>
|
||||
|
||||
{/* Items (solo 2) */}
|
||||
<MenuItem
|
||||
label="Ver / editar perfil"
|
||||
onClick={() => {
|
||||
@@ -167,18 +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);
|
||||
if (onLogout) onLogout();
|
||||
else {
|
||||
localStorage.removeItem("token");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
onRequestLogout?.();
|
||||
}}
|
||||
left={<LogOut size={16} />}
|
||||
/>
|
||||
@@ -212,17 +266,16 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopMenu;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -54,12 +54,17 @@ export default function ProfileModal({
|
||||
// Limpieza de object URLs
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (lastPreviewUrlRef.current) URL.revokeObjectURL(lastPreviewUrlRef.current);
|
||||
if (lastPreviewUrlRef.current) {
|
||||
URL.revokeObjectURL(lastPreviewUrlRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const initials = useMemo(() => {
|
||||
const parts = (name || "").trim().split(/\s+/).filter(Boolean);
|
||||
const parts = (name || "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
const a = parts[0]?.[0] ?? "U";
|
||||
const b = parts[1]?.[0] ?? "";
|
||||
return (a + b).toUpperCase();
|
||||
@@ -145,19 +150,21 @@ 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">Editar perfil</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">
|
||||
Editar perfil
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<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}
|
||||
@@ -165,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>
|
||||
@@ -186,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(" ")}
|
||||
>
|
||||
@@ -205,9 +212,8 @@ export default function ProfileModal({
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Form */}
|
||||
<div className="rounded-2xl border border-slate-200 p-5">
|
||||
{/* “correo electronico” como en tu dibujo */}
|
||||
<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>
|
||||
|
||||
@@ -216,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>
|
||||
@@ -225,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>
|
||||
@@ -234,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>
|
||||
@@ -244,29 +250,29 @@ 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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className={[
|
||||
"rounded-xl px-4 py-2 text-sm font-semibold",
|
||||
"bg-blue-600 text-white hover:bg-blue-700",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
|
||||
loading ? "opacity-60 cursor-not-allowed" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{loading ? "Guardando..." : "Guardar"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className={[
|
||||
"rounded-xl px-4 py-2 text-sm font-semibold",
|
||||
"bg-blue-600 text-white hover:bg-blue-700",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
|
||||
loading ? "opacity-60 cursor-not-allowed" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{loading ? "Guardando..." : "Guardar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,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>
|
||||
);
|
||||
|
||||
46
src/components/layout/common/ProjectBadge.tsx
Normal file
46
src/components/layout/common/ProjectBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/components/layout/common/Watermark.tsx
Normal file
51
src/components/layout/common/Watermark.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
import grhWatermark from "../../../assets/images/grhWatermark.png";
|
||||
|
||||
export default function Watermark({
|
||||
opacity = 0.08,
|
||||
size = 520,
|
||||
}: {
|
||||
opacity?: number;
|
||||
size?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 z-20 overflow-hidden">
|
||||
{/* Marca centrada (SIN rotación) */}
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2"
|
||||
style={{
|
||||
transform: "translate(-50%, -50%)",
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={grhWatermark}
|
||||
alt="GRH Watermark"
|
||||
width={size}
|
||||
height={size}
|
||||
className="select-none object-contain"
|
||||
draggable={false}
|
||||
style={{ filter: "grayscale(100%)" }} // opcional
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Marca secundaria (SIN rotación) */}
|
||||
<div
|
||||
className="absolute right-[-140px] bottom-[-180px]"
|
||||
style={{
|
||||
opacity: Math.max(0.04, opacity * 0.55),
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={grhWatermark}
|
||||
alt="GRH Watermark"
|
||||
width={Math.round(size * 0.75)}
|
||||
height={Math.round(size * 0.75)}
|
||||
className="select-none object-contain"
|
||||
draggable={false}
|
||||
style={{ filter: "grayscale(100%)" }} // opcional
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
src/hooks/useNotifications.ts
Normal file
183
src/hooks/useNotifications.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
153
src/index.css
153
src/index.css
@@ -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
502
src/pages/AuditoriaPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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.jpg";
|
||||
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) => ({
|
||||
name: projectName,
|
||||
meterCount: filteredMeters.filter((m) => m.areaName === projectName)
|
||||
.length,
|
||||
})),
|
||||
[filteredProjects, filteredMeters]
|
||||
);
|
||||
const selectedOrganismoName = useMemo(() => {
|
||||
if (selectedOrganism === "Todos") return null;
|
||||
const org = organismos.find(o => o.id === selectedOrganism);
|
||||
return org?.name || null;
|
||||
}, [selectedOrganism, organismos]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
// If user is OPERATOR, show only their project
|
||||
if (isOperator && userProjectId) {
|
||||
const project = projects.find(p => p.id === userProjectId);
|
||||
return [{
|
||||
name: project?.name || "Mi Proyecto",
|
||||
meterCount: filteredMeters.length,
|
||||
}];
|
||||
}
|
||||
|
||||
// Show meters grouped by project name
|
||||
return filteredProjects.map((projectName) => ({
|
||||
name: projectName,
|
||||
meterCount: filteredMeters.filter((m) => m.projectName === projectName).length,
|
||||
}));
|
||||
}, [filteredProjects, filteredMeters, isOperator, userProjectId, projects]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleBarClick = (data: any) => {
|
||||
@@ -142,50 +196,131 @@ export default function Home({
|
||||
|
||||
const filteredOrganisms = useMemo(() => {
|
||||
const q = organismQuery.trim().toLowerCase();
|
||||
if (!q) return organismsData;
|
||||
return organismsData.filter((o) => o.name.toLowerCase().includes(q));
|
||||
}, [organismQuery]);
|
||||
if (!q) return organismos;
|
||||
return organismos.filter((o) => o.name.toLowerCase().includes(q));
|
||||
}, [organismQuery, organismos]);
|
||||
|
||||
/* ================= MOCK ALERTS / HISTORY ================= */
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [loadingNotifications, setLoadingNotifications] = useState(false);
|
||||
|
||||
const alerts: AlertItem[] = [
|
||||
{ company: "Empresa A", type: "Fuga", time: "Hace 2 horas" },
|
||||
{ company: "Empresa C", type: "Consumo alto", time: "Hace 5 horas" },
|
||||
{ company: "Empresa B", type: "Inactividad", time: "Hace 8 horas" },
|
||||
];
|
||||
const loadNotifications = async () => {
|
||||
setLoadingNotifications(true);
|
||||
try {
|
||||
const response = await fetchNotifications({ limit: 10, page: 1 });
|
||||
setNotifications(response.data);
|
||||
} catch (err) {
|
||||
console.error("Error loading notifications:", err);
|
||||
setNotifications([]);
|
||||
} finally {
|
||||
setLoadingNotifications(false);
|
||||
}
|
||||
};
|
||||
|
||||
const history: HistoryItem[] = [
|
||||
{
|
||||
user: "GRH",
|
||||
action: "Creó un nuevo medidor",
|
||||
target: "SN001",
|
||||
time: "Hace 5 minutos",
|
||||
},
|
||||
{
|
||||
user: "CESPT",
|
||||
action: "Actualizó concentrador",
|
||||
target: "Planta 1",
|
||||
time: "Hace 20 minutos",
|
||||
},
|
||||
{
|
||||
user: "GRH",
|
||||
action: "Eliminó un usuario",
|
||||
target: "Juan Pérez",
|
||||
time: "Hace 1 hora",
|
||||
},
|
||||
{
|
||||
user: "CESPT",
|
||||
action: "Creó un payload",
|
||||
target: "Payload 12",
|
||||
time: "Hace 2 horas",
|
||||
},
|
||||
{
|
||||
user: "GRH",
|
||||
action: "Actualizó medidor",
|
||||
target: "SN002",
|
||||
time: "Hace 3 horas",
|
||||
},
|
||||
];
|
||||
useEffect(() => {
|
||||
if (isAdmin || isOrganismo) {
|
||||
loadNotifications();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const formatNotificationType = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
NEGATIVE_FLOW: "Flujo Negativo",
|
||||
SYSTEM_ALERT: "Alerta del Sistema",
|
||||
MAINTENANCE: "Mantenimiento",
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
const formatNotificationTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "Hace un momento";
|
||||
if (diffMins < 60) return `Hace ${diffMins} minuto${diffMins > 1 ? "s" : ""}`;
|
||||
if (diffHours < 24) return `Hace ${diffHours} hora${diffHours > 1 ? "s" : ""}`;
|
||||
if (diffDays < 7) return `Hace ${diffDays} día${diffDays > 1 ? "s" : ""}`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const alerts: AlertItem[] = useMemo(
|
||||
() =>
|
||||
notifications.map((n) => ({
|
||||
company: n.meter_serial_number || "Sistema",
|
||||
type: formatNotificationType(n.notification_type),
|
||||
time: formatNotificationTime(n.created_at),
|
||||
})),
|
||||
[notifications]
|
||||
);
|
||||
|
||||
const formatAuditAction = (action: string): string => {
|
||||
const actionMap: Record<string, string> = {
|
||||
CREATE: "creó",
|
||||
UPDATE: "actualizó",
|
||||
DELETE: "eliminó",
|
||||
READ: "consultó",
|
||||
LOGIN: "inició sesión",
|
||||
LOGOUT: "cerró sesión",
|
||||
EXPORT: "exportó",
|
||||
BULK_UPLOAD: "cargó masivamente",
|
||||
STATUS_CHANGE: "cambió estado de",
|
||||
PERMISSION_CHANGE: "cambió permisos de",
|
||||
};
|
||||
return actionMap[action] || action.toLowerCase();
|
||||
};
|
||||
|
||||
const formatTableName = (tableName: string): string => {
|
||||
const tableMap: Record<string, string> = {
|
||||
meters: "medidor",
|
||||
concentrators: "concentrador",
|
||||
projects: "proyecto",
|
||||
users: "usuario",
|
||||
roles: "rol",
|
||||
gateways: "gateway",
|
||||
devices: "dispositivo",
|
||||
readings: "lectura",
|
||||
webhooks: "webhook",
|
||||
};
|
||||
return tableMap[tableName] || tableName;
|
||||
};
|
||||
|
||||
const formatRelativeTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "Hace un momento";
|
||||
if (diffMins < 60) return `Hace ${diffMins} minuto${diffMins > 1 ? "s" : ""}`;
|
||||
if (diffHours < 24) return `Hace ${diffHours} hora${diffHours > 1 ? "s" : ""}`;
|
||||
if (diffDays < 7) return `Hace ${diffDays} día${diffDays > 1 ? "s" : ""}`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const formatAuditLog = (log: AuditLog): HistoryItem => {
|
||||
const action = formatAuditAction(log.action);
|
||||
const target = formatTableName(log.table_name);
|
||||
const recordInfo = log.description || log.record_id || target;
|
||||
|
||||
return {
|
||||
user: log.user_name || log.user_email || "Sistema",
|
||||
action: action,
|
||||
target: recordInfo,
|
||||
time: formatRelativeTime(log.created_at),
|
||||
};
|
||||
};
|
||||
|
||||
const history: HistoryItem[] = useMemo(
|
||||
() => auditLogs.map(formatAuditLog),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[auditLogs]
|
||||
);
|
||||
|
||||
/* ================= KPIs (Optional) ================= */
|
||||
|
||||
@@ -204,73 +339,89 @@ export default function Home({
|
||||
{/* Título + Selector */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* ✅ Título + logo a la derecha */}
|
||||
<div className="relative flex items-start justify-between gap-6">
|
||||
<div className="relative z-10">
|
||||
<h1 className="text-3xl font-bold text-gray-800">
|
||||
Sistema de Tomas de Agua
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Monitorea, administra y controla tus operaciones en un solo lugar.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative flex items-start justify-between gap-6">
|
||||
<div className="relative z-10">
|
||||
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">
|
||||
Sistema de Tomas de Agua
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-zinc-300 mt-2">
|
||||
Monitorea, administra y controla tus operaciones en un solo lugar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ✅ Logo con z-index bajo para NO tapar menús */}
|
||||
<img
|
||||
src={grhWatermark}
|
||||
alt="Gestión de Recursos Hídricos"
|
||||
className="relative z-0 h-10 w-auto opacity-80 select-none pointer-events-none shrink-0"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
{/* ✅ Logo con z-index bajo para NO tapar menús */}
|
||||
<img
|
||||
src={grhWatermark}
|
||||
alt="Gestión de Recursos Hídricos"
|
||||
className="relative z-0 h-20 w-auto opacity-80 select-none pointer-events-none shrink-0"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cards de Secciones */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div
|
||||
className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-blue-50 transition cursor-pointer"
|
||||
className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-blue-50 dark:hover:bg-zinc-800 transition cursor-pointer"
|
||||
onClick={() => setPage("meters")}
|
||||
>
|
||||
<Cpu size={40} className="text-blue-600" />
|
||||
<span className="font-semibold text-gray-700">Tomas</span>
|
||||
<span className="font-semibold text-gray-700 dark:text-zinc-200">Tomas</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-red-50 transition">
|
||||
<div
|
||||
className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-red-50 dark:hover:bg-zinc-800 transition cursor-pointer"
|
||||
onClick={() => setPage("auditoria")}
|
||||
>
|
||||
<Bell size={40} className="text-red-600" />
|
||||
<span className="font-semibold text-gray-700">Alertas</span>
|
||||
<span className="font-semibold text-gray-700 dark:text-zinc-200">Alertas</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-yellow-50 transition">
|
||||
<div className="cursor-pointer bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-yellow-50 dark:hover:bg-zinc-800 transition"
|
||||
onClick={() => setPage("projects")}
|
||||
>
|
||||
<Settings size={40} className="text-yellow-600" />
|
||||
<span className="font-semibold text-gray-700">Mantenimiento</span>
|
||||
<span className="font-semibold text-gray-700 dark:text-zinc-200">Proyectos</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-green-50 transition">
|
||||
<div
|
||||
className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-green-50 dark:hover:bg-zinc-800 transition cursor-pointer"
|
||||
onClick={() => setPage("analytics-reports")}
|
||||
>
|
||||
<BarChart3 size={40} className="text-green-600" />
|
||||
<span className="font-semibold text-gray-700">Reportes</span>
|
||||
<span className="font-semibold text-gray-700 dark:text-zinc-200">Reportes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organismos Operadores */}
|
||||
<div className="bg-white rounded-xl shadow p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Organismos Operadores</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Seleccionado:{" "}
|
||||
<span className="font-semibold">{selectedOrganism}</span>
|
||||
</p>
|
||||
{/* Organismo selector - ADMIN can pick any, ORGANISMO_OPERADOR sees their own */}
|
||||
{(isAdmin || isOrganismo) && (
|
||||
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-zinc-400">Organismos Operadores</p>
|
||||
<p className="text-xs text-gray-400 dark:text-zinc-500">
|
||||
Seleccionado:{" "}
|
||||
<span className="font-semibold dark:text-zinc-300">
|
||||
{selectedOrganism === "Todos"
|
||||
? "Todos"
|
||||
: selectedOrganismoName || "Ninguno"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Only ADMIN can change the selector */}
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-700 transition"
|
||||
onClick={() => setShowOrganisms(true)}
|
||||
>
|
||||
Organismos Operadores
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-700 transition"
|
||||
onClick={() => setShowOrganisms(true)}
|
||||
>
|
||||
Organismos Operadores
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showOrganisms && (
|
||||
<div className="fixed inset-0 z-50">
|
||||
{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,86 +606,148 @@ export default function Home({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredOrganisms.length === 0 && (
|
||||
<div className="text-sm text-gray-500 text-center py-10">
|
||||
{!loadingOrganismos && filteredOrganisms.length === 0 && (
|
||||
<div className="text-sm text-gray-500 dark:text-zinc-400 text-center py-10">
|
||||
No se encontraron organismos.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-5 border-t text-xs text-gray-500">
|
||||
Nota: Las propiedades están en modo demostración hasta integrar
|
||||
backend.
|
||||
<div className="p-5 border-t dark:border-zinc-800 text-xs text-gray-500 dark:text-zinc-400">
|
||||
Mostrando {filteredOrganisms.length} organismo{filteredOrganisms.length !== 1 ? 's' : ''} de {organismos.length} total{organismos.length !== 1 ? 'es' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gráfica */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6">
|
||||
<div className="flex items-center justify-between gap-4 mb-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
Número de Medidores por Proyecto
|
||||
<h2 className="text-lg font-semibold dark:text-white">
|
||||
Numero de Medidores por Proyecto
|
||||
</h2>
|
||||
<span className="text-xs text-gray-400">
|
||||
Click en barra para ver tomas
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-60">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
|
||||
onClick={handleBarClick}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="meterCount" fill="#4c5f9e" cursor="pointer" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Historial */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Historial Reciente</h2>
|
||||
<ul className="divide-y divide-gray-200 max-h-60 overflow-y-auto">
|
||||
{history.map((h, i) => (
|
||||
<li key={i} className="py-2 flex items-start gap-3">
|
||||
<span className="text-gray-400 mt-1">•</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-700">
|
||||
<span className="font-semibold">{h.user}</span> {h.action}{" "}
|
||||
<span className="font-medium">{h.target}</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{h.time}</p>
|
||||
{chartData.length === 0 && selectedOrganism !== "Todos" ? (
|
||||
<div className="h-60 flex flex-col items-center justify-center">
|
||||
<p className="text-sm text-gray-500 dark:text-zinc-400 mb-2">
|
||||
Este organismo no tiene medidores registrados
|
||||
</p>
|
||||
{selectedOrganismoName && (
|
||||
<p className="text-xs text-gray-400 dark:text-zinc-500">
|
||||
Organismo: <span className="font-semibold dark:text-zinc-300">{selectedOrganismoName}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-60 flex items-center justify-center">
|
||||
<p className="text-sm text-gray-500 dark:text-zinc-400">No hay datos disponibles</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-60">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
|
||||
onClick={handleBarClick}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="meterCount" fill="#4c5f9e" cursor="pointer" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{selectedOrganism !== "Todos" && selectedOrganismoName && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-zinc-800">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-zinc-400">Organismo:</span>
|
||||
<span className="ml-2 font-semibold text-gray-800 dark:text-white">{selectedOrganismoName}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-zinc-400">Total de medidores:</span>
|
||||
<span className="ml-2 font-semibold text-blue-600">{filteredMeters.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Últimas alertas */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Últimas Alertas</h2>
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{alerts.map((a, i) => (
|
||||
<li key={i} className="py-2 flex justify-between">
|
||||
<span>
|
||||
{a.company} - {a.type}
|
||||
</span>
|
||||
<span className="text-red-500 font-medium">{a.time}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 dark:text-white">Historial Reciente de Auditoria</h2>
|
||||
{loadingAuditLogs ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-8">
|
||||
No hay registros de auditoría disponibles
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200 dark:divide-zinc-800 max-h-60 overflow-y-auto">
|
||||
{history.map((h, i) => (
|
||||
<li key={i} className="py-2 flex items-start gap-3">
|
||||
<span className="text-gray-400 mt-1">•</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-700 dark:text-zinc-300">
|
||||
<span className="font-semibold">{h.user}</span> {h.action}{" "}
|
||||
<span className="font-medium">{h.target}</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{h.time}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isAdmin || isOrganismo) && (
|
||||
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 dark:text-white">Ultimas Alertas</h2>
|
||||
{loadingNotifications ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : alerts.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-8">
|
||||
No hay alertas disponibles
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200 dark:divide-zinc-800">
|
||||
{alerts.map((a, i) => (
|
||||
<li key={i} className="py-2 flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-gray-700 dark:text-zinc-300">
|
||||
<span className="font-semibold">{a.company}</span> - {a.type}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-red-500 font-medium whitespace-nowrap ml-4">
|
||||
{a.time}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
197
src/pages/LoginPage.tsx
Normal file
197
src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Lock, Mail, Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
import grhWatermark from "../assets/images/grhWatermark.png";
|
||||
import { login } from "../api/auth";
|
||||
|
||||
type Form = { email: string; password: string };
|
||||
|
||||
type LoginPageProps = {
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
export default function LoginPage({ onSuccess }: LoginPageProps) {
|
||||
const [form, setForm] = useState<Form>({ email: "", password: "" });
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [serverError, setServerError] = useState("");
|
||||
|
||||
const errors = useMemo(() => {
|
||||
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.email, form.password]);
|
||||
|
||||
const canSubmit = Object.keys(errors).length === 0 && !loading;
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setServerError("");
|
||||
if (!canSubmit) return;
|
||||
|
||||
setLoading(true);
|
||||
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="fixed inset-0 w-screen h-screen font-sans">
|
||||
{/* Imagen de fondo - agua */}
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: `url('https://images.unsplash.com/photo-1500375592092-40eb2168fd21?q=80&w=2088&auto=format&fit=crop')`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Overlay oscuro */}
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
|
||||
{/* Layout dividido */}
|
||||
<div className="relative z-10 w-full h-full flex">
|
||||
{/* Lado izquierdo - Branding */}
|
||||
<div className="hidden lg:flex lg:w-1/2 h-full flex-col justify-between p-12">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-24 w-24 rounded-2xl bg-white/90 backdrop-blur-sm shadow-xl flex items-center justify-center">
|
||||
<img
|
||||
src={grhWatermark}
|
||||
alt="GRH"
|
||||
className="h-18 w-18 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-white text-4xl font-bold tracking-tight drop-shadow-lg">
|
||||
GRH
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xl">
|
||||
<h1 className="text-6xl font-bold text-white leading-tight drop-shadow-lg">
|
||||
Gestión de
|
||||
<br />
|
||||
Recursos Hídricos
|
||||
</h1>
|
||||
<p className="mt-6 text-xl text-white/90 leading-relaxed drop-shadow">
|
||||
Llevando agua potable a comunidades que más lo necesitan.
|
||||
Monitoreo inteligente de infraestructura hídrica.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-white/60 text-sm">
|
||||
© 2026 GRH. Todos los derechos reservados.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Lado derecho - Formulario */}
|
||||
<div className="w-full lg:w-1/2 h-full flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-md bg-white rounded-2xl shadow-2xl p-8 md:p-10">
|
||||
{/* Logo móvil */}
|
||||
<div className="flex lg:hidden items-center gap-3 mb-8">
|
||||
<img
|
||||
src={grhWatermark}
|
||||
alt="GRH"
|
||||
className="h-10 w-10 object-contain"
|
||||
/>
|
||||
<span className="text-slate-800 text-lg font-semibold">GRH</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-slate-900">
|
||||
Iniciar sesión
|
||||
</h2>
|
||||
<p className="mt-2 text-slate-500 text-sm">
|
||||
Ingresa tus credenciales para acceder al sistema
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-5">
|
||||
{serverError && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
|
||||
{serverError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
Correo electrónico
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) =>
|
||||
setForm((s) => ({ ...s, email: e.target.value }))
|
||||
}
|
||||
placeholder="usuario@ejemplo.com"
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-4 py-3 pr-11 text-slate-900 placeholder-slate-400 outline-none transition-all focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
|
||||
/>
|
||||
<Mail
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400"
|
||||
size={18}
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="mt-1.5 text-xs text-red-600">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contraseña */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
Contraseña
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={form.password}
|
||||
onChange={(e) =>
|
||||
setForm((s) => ({ ...s, password: e.target.value }))
|
||||
}
|
||||
type={showPass ? "text" : "password"}
|
||||
placeholder="••••••••"
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-4 py-3 pr-20 text-slate-900 placeholder-slate-400 outline-none transition-all focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPass((v) => !v)}
|
||||
className="absolute right-11 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
|
||||
>
|
||||
{showPass ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
<Lock
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400"
|
||||
size={18}
|
||||
/>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1.5 text-xs text-red-600">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Botón */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="w-full rounded-lg bg-blue-600 py-3 font-semibold text-white shadow-lg transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-blue-700 active:scale-[0.98]"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
Ingresando...
|
||||
</>
|
||||
) : (
|
||||
"Ingresar"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
372
src/pages/OrganismosPage.tsx
Normal file
372
src/pages/OrganismosPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +1,148 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
|
||||
import { Plus, Trash2, Pencil, RefreshCcw, AlertCircle, Loader2 } from "lucide-react";
|
||||
import MaterialTable from "@material-table/core";
|
||||
import { getAllRoles, createRole, updateRole, deleteRole, type Role } from "../api/roles";
|
||||
import ConfirmModal from "../components/layout/common/ConfirmModal";
|
||||
|
||||
export interface Role {
|
||||
id: string;
|
||||
interface RoleForm {
|
||||
name: string;
|
||||
description: string;
|
||||
status: "ACTIVE" | "INACTIVE";
|
||||
createdAt: string;
|
||||
permissions?: Record<string, Record<string, boolean>>;
|
||||
}
|
||||
|
||||
export default function RolesPage() {
|
||||
const initialRoles: Role[] = [
|
||||
{ id: "1", name: "SUPER_ADMIN", description: "Full access", status: "ACTIVE", createdAt: "2025-12-17" },
|
||||
{ id: "2", name: "USER", description: "Regular user", status: "ACTIVE", createdAt: "2025-12-16" },
|
||||
];
|
||||
|
||||
const [roles, setRoles] = useState<Role[]>(initialRoles);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [activeRole, setActiveRole] = useState<Role | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const emptyRole: Omit<Role, "id"> = {
|
||||
const emptyForm: RoleForm = {
|
||||
name: "",
|
||||
description: "",
|
||||
status: "ACTIVE",
|
||||
createdAt: new Date().toISOString().slice(0, 10),
|
||||
permissions: {},
|
||||
};
|
||||
|
||||
const [form, setForm] = useState<Omit<Role, "id">>(emptyRole);
|
||||
const [form, setForm] = useState<RoleForm>(emptyForm);
|
||||
|
||||
const handleSave = () => {
|
||||
if (editingId) {
|
||||
setRoles(prev => prev.map(r => r.id === editingId ? { id: editingId, ...form } : r));
|
||||
} else {
|
||||
const newId = Date.now().toString();
|
||||
setRoles(prev => [...prev, { id: newId, ...form }]);
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
}, []);
|
||||
|
||||
const fetchRoles = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getAllRoles();
|
||||
setRoles(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load roles");
|
||||
console.error("Error fetching roles:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingId(null);
|
||||
setForm(emptyRole);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) {
|
||||
setError("Role name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (editingId) {
|
||||
const updated = await updateRole(editingId, form);
|
||||
setRoles(prev => prev.map(r => r.id === editingId ? updated : r));
|
||||
} else {
|
||||
const created = await createRole(form);
|
||||
setRoles(prev => [...prev, created]);
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
setActiveRole(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save role");
|
||||
console.error("Error saving role:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!activeRole) return;
|
||||
setRoles(prev => prev.filter(r => r.id !== activeRole.id));
|
||||
setActiveRole(null);
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteRole(activeRole.id);
|
||||
setRoles(prev => prev.filter(r => r.id !== activeRole.id));
|
||||
setActiveRole(null);
|
||||
setShowDeleteConfirm(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete role");
|
||||
console.error("Error deleting role:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = roles.filter(r => r.name.toLowerCase().includes(search.toLowerCase()));
|
||||
const openEditModal = () => {
|
||||
if (!activeRole) return;
|
||||
setEditingId(activeRole.id);
|
||||
setForm({
|
||||
name: activeRole.name,
|
||||
description: activeRole.description,
|
||||
permissions: activeRole.permissions,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const filtered = roles.filter(r =>
|
||||
r.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
r.description?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex gap-6 p-6 w-full bg-gray-100">
|
||||
<div className="flex gap-6 p-6 w-full bg-gray-100 dark:bg-zinc-950">
|
||||
{/* LEFT INFO SIDEBAR */}
|
||||
<div className="w-72 bg-white rounded-xl shadow p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 mb-3">Role Information</h3>
|
||||
<p className="text-sm text-gray-700">Aquí se listan los roles disponibles en el sistema.</p>
|
||||
<div className="w-72 bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-zinc-400 mb-3">Role Information</h3>
|
||||
<p className="text-sm text-gray-700 dark:text-zinc-300 mb-4">
|
||||
Manage system roles and their permissions. Roles define what actions users can perform.
|
||||
</p>
|
||||
|
||||
{activeRole && (
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-zinc-400 mb-2">Selected Role</h4>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-zinc-400">Name</p>
|
||||
<p className="text-sm font-medium dark:text-zinc-200">{activeRole.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-zinc-400">Description</p>
|
||||
<p className="text-sm dark:text-zinc-300">{activeRole.description || "No description"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-zinc-400">Created</p>
|
||||
<p className="text-sm dark:text-zinc-300">{new Date(activeRole.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
{activeRole.permissions && Object.keys(activeRole.permissions).length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-zinc-400">Permissions</p>
|
||||
<p className="text-sm dark:text-zinc-300">{Object.keys(activeRole.permissions).length} permission groups</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* MAIN */}
|
||||
@@ -67,83 +152,213 @@ export default function RolesPage() {
|
||||
style={{ background: "linear-gradient(135deg, #4c5f9e, #2a355d, #566bb8)" }}>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Role Management</h1>
|
||||
<p className="text-sm text-blue-100">Roles registrados</p>
|
||||
<p className="text-sm text-blue-100">{roles.length} roles registered</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => { setForm(emptyRole); setEditingId(null); setShowModal(true); }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg">
|
||||
<button
|
||||
onClick={() => { setForm(emptyForm); setEditingId(null); setShowModal(true); }}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg hover:bg-gray-100 transition disabled:opacity-50"
|
||||
>
|
||||
<Plus size={16} /> Add
|
||||
</button>
|
||||
<button onClick={() => { if (!activeRole) return; setEditingId(activeRole.id); setForm({...activeRole}); setShowModal(true); }}
|
||||
disabled={!activeRole}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60">
|
||||
<button
|
||||
onClick={openEditModal}
|
||||
disabled={!activeRole || loading}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg hover:bg-white/10 transition disabled:opacity-50"
|
||||
>
|
||||
<Pencil size={16} /> Edit
|
||||
</button>
|
||||
<button onClick={handleDelete}
|
||||
disabled={!activeRole}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={!activeRole || loading}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg hover:bg-red-500/20 transition disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={16} /> Delete
|
||||
</button>
|
||||
<button onClick={() => setRoles([...roles])}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg">
|
||||
<RefreshCcw size={16} /> Refresh
|
||||
<button
|
||||
onClick={fetchRoles}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg hover:bg-white/10 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : <RefreshCcw size={16} />}
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ERROR ALERT */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
|
||||
<AlertCircle className="text-red-600" size={20} />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-800">Error</p>
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-auto text-red-600 hover:text-red-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SEARCH */}
|
||||
<input className="bg-white rounded-lg shadow px-4 py-2 text-sm"
|
||||
placeholder="Search role..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)} />
|
||||
<input
|
||||
className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 dark:text-zinc-100 rounded-lg shadow px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:placeholder-zinc-500"
|
||||
placeholder="Search role by name or description..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* TABLE */}
|
||||
<MaterialTable
|
||||
title="Roles"
|
||||
columns={[
|
||||
{ title: "Name", field: "name" },
|
||||
{ title: "Description", field: "description" },
|
||||
{
|
||||
title: "Status",
|
||||
field: "status",
|
||||
render: (rowData) => (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${rowData.status === "ACTIVE" ? "text-blue-600 border-blue-600" : "text-red-600 border-red-600"}`}>
|
||||
{rowData.status}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{ title: "Created", field: "createdAt", type: "date" }
|
||||
]}
|
||||
data={filtered}
|
||||
onRowClick={(_, rowData) => setActiveRole(rowData as Role)}
|
||||
options={{
|
||||
actionsColumnIndex: -1,
|
||||
search: false,
|
||||
paging: true,
|
||||
sorting: true,
|
||||
rowStyle: (rowData) => ({ backgroundColor: activeRole?.id === (rowData as Role).id ? "#EEF2FF" : "#FFFFFF" })
|
||||
}}
|
||||
/>
|
||||
<div className="bg-white rounded-xl shadow">
|
||||
{loading && roles.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="animate-spin text-blue-600" size={32} />
|
||||
</div>
|
||||
) : (
|
||||
<MaterialTable
|
||||
title="Roles"
|
||||
columns={[
|
||||
{
|
||||
title: "Name",
|
||||
field: "name",
|
||||
render: (rowData) => (
|
||||
<span className="font-medium text-gray-900">{rowData.name}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "Description",
|
||||
field: "description",
|
||||
render: (rowData) => (
|
||||
<span className="text-gray-600">{rowData.description || "—"}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "Permissions",
|
||||
field: "permissions",
|
||||
render: (rowData) => {
|
||||
const count = rowData.permissions ? Object.keys(rowData.permissions).length : 0;
|
||||
return (
|
||||
<span className="text-sm text-gray-600">
|
||||
{count > 0 ? `${count} groups` : "No permissions"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Created",
|
||||
field: "created_at",
|
||||
type: "date",
|
||||
render: (rowData) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{new Date(rowData.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
]}
|
||||
data={filtered}
|
||||
onRowClick={(_, rowData) => setActiveRole(rowData as Role)}
|
||||
options={{
|
||||
actionsColumnIndex: -1,
|
||||
search: false,
|
||||
paging: true,
|
||||
pageSize: 10,
|
||||
pageSizeOptions: [10, 20, 50],
|
||||
sorting: true,
|
||||
rowStyle: (rowData) => ({
|
||||
backgroundColor: activeRole?.id === (rowData as Role).id ? "#EEF2FF" : "#FFFFFF",
|
||||
cursor: "pointer"
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MODAL */}
|
||||
{/* ADD/EDIT MODAL */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center">
|
||||
<div className="bg-white rounded-xl p-6 w-96 space-y-3">
|
||||
<h2 className="text-lg font-semibold">{editingId ? "Edit Role" : "Add Role"}</h2>
|
||||
<input className="w-full border px-3 py-2 rounded" placeholder="Name" value={form.name} onChange={e => setForm({...form, name: e.target.value})} />
|
||||
<input className="w-full border px-3 py-2 rounded" placeholder="Description" value={form.description} onChange={e => setForm({...form, description: e.target.value})} />
|
||||
<button onClick={() => setForm({...form, status: form.status === "ACTIVE" ? "INACTIVE" : "ACTIVE"})} className="w-full border rounded px-3 py-2">
|
||||
Status: {form.status}
|
||||
</button>
|
||||
<input type="date" className="w-full border px-3 py-2 rounded" value={form.createdAt} onChange={e => setForm({...form, createdAt: e.target.value})} />
|
||||
<div className="flex justify-end gap-2 pt-3">
|
||||
<button onClick={() => setShowModal(false)}>Cancel</button>
|
||||
<button onClick={handleSave} className="bg-[#4c5f9e] text-white px-4 py-2 rounded">Save</button>
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-700 rounded-xl p-6 w-96 space-y-4 shadow-xl">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{editingId ? "Edit Role" : "Add New Role"}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-1">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
className="w-full border border-gray-300 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g., ADMIN, USER, MANAGER"
|
||||
value={form.name}
|
||||
onChange={e => setForm({...form, name: e.target.value})}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full border border-gray-300 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
placeholder="Describe the role's purpose..."
|
||||
rows={3}
|
||||
value={form.description}
|
||||
onChange={e => setForm({...form, description: e.target.value})}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Note about permissions */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
<p className="text-xs text-blue-800 dark:text-blue-300">
|
||||
<strong>Note:</strong> Permissions can be configured after creating the role.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-3 border-t">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={loading || !form.name.trim()}
|
||||
className="bg-[#4c5f9e] text-white px-4 py-2 rounded-lg hover:bg-[#3d4c7d] transition disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{loading && <Loader2 size={16} className="animate-spin" />}
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DELETE CONFIRMATION MODAL */}
|
||||
<ConfirmModal
|
||||
open={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Role"
|
||||
message={`Are you sure you want to delete the role "${activeRole?.name}"? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
danger={true}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingId(null);
|
||||
setForm(emptyUser);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
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 handleRefresh = async () => {
|
||||
await fetchUsers();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!activeUser) return;
|
||||
setUsers(prev => prev.filter(u => u.id !== activeUser.id));
|
||||
setActiveUser(null);
|
||||
|
||||
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 */}
|
||||
<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: "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" }) }}
|
||||
/>
|
||||
{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: "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,
|
||||
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>
|
||||
|
||||
364
src/pages/analytics/AnalyticsMapPage.tsx
Normal file
364
src/pages/analytics/AnalyticsMapPage.tsx
Normal 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: '© <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)} m³`
|
||||
: "—"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
381
src/pages/analytics/AnalyticsReportsPage.tsx
Normal file
381
src/pages/analytics/AnalyticsReportsPage.tsx
Normal 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")} m³`,
|
||||
"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")} m³`,
|
||||
"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>
|
||||
);
|
||||
}
|
||||
452
src/pages/analytics/AnalyticsServerPage.tsx
Normal file
452
src/pages/analytics/AnalyticsServerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/pages/analytics/MapComponents.tsx
Normal file
80
src/pages/analytics/MapComponents.tsx
Normal 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='© <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>
|
||||
);
|
||||
}
|
||||
194
src/pages/concentrators/ConcentratorsModal.tsx
Normal file
194
src/pages/concentrators/ConcentratorsModal.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ConcentratorInput } from "../../api/concentrators";
|
||||
import { fetchProjects, type Project } from "../../api/projects";
|
||||
|
||||
type Props = {
|
||||
editingId: string | null;
|
||||
form: ConcentratorInput;
|
||||
setForm: React.Dispatch<React.SetStateAction<ConcentratorInput>>;
|
||||
errors: Record<string, boolean>;
|
||||
setErrors: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||
allProjects: string[];
|
||||
onClose: () => void;
|
||||
onSave: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export default function ConcentratorsModal({
|
||||
editingId,
|
||||
form,
|
||||
setForm,
|
||||
errors,
|
||||
setErrors,
|
||||
onClose,
|
||||
onSave,
|
||||
}: Props) {
|
||||
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 dark:bg-zinc-900 dark:border dark:border-zinc-700 rounded-xl p-6 w-[500px] max-h-[90vh] overflow-y-auto space-y-4">
|
||||
<h2 className="text-lg font-semibold dark:text-white">{title}</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-zinc-200 border-b dark:border-zinc-700 pb-2">
|
||||
Información del Concentrador
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Serial *</label>
|
||||
<input
|
||||
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||
errors["serialNumber"] ? "border-red-500" : ""
|
||||
}`}
|
||||
placeholder="Número de serie"
|
||||
value={form.serialNumber}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, serialNumber: e.target.value });
|
||||
if (errors["serialNumber"]) setErrors({ ...errors, serialNumber: false });
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{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>
|
||||
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Tipo *</label>
|
||||
<select
|
||||
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
value={form.type ?? "LORA"}
|
||||
onChange={(e) => setForm({ ...form, type: e.target.value as "LORA" | "LORAWAN" | "GRANDES" })}
|
||||
>
|
||||
<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="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"
|
||||
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 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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]"
|
||||
>
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
246
src/pages/concentrators/ConcentratorsSidebar.tsx
Normal file
246
src/pages/concentrators/ConcentratorsSidebar.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
// src/pages/concentrators/ConcentratorsSidebar.tsx
|
||||
import { useMemo } from "react";
|
||||
import { Check, RefreshCcw } from "lucide-react";
|
||||
import type { ProjectCard, SampleView } from "./ConcentratorsPage";
|
||||
import type { MeterType } from "../../api/meterTypes";
|
||||
|
||||
type Props = {
|
||||
loadingProjects: boolean;
|
||||
|
||||
sampleView: SampleView;
|
||||
sampleViewLabel: string;
|
||||
|
||||
// ✅ ahora lo controla el Page
|
||||
typesMenuOpen: boolean;
|
||||
setTypesMenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
onChangeSampleView: (next: SampleView) => void;
|
||||
|
||||
selectedProject: string;
|
||||
onSelectProject: (name: string) => void;
|
||||
|
||||
// ✅ el Page manda projects={c.projectsData}
|
||||
projects: ProjectCard[];
|
||||
|
||||
onRefresh: () => void;
|
||||
refreshDisabled: boolean;
|
||||
|
||||
meterTypes: MeterType[];
|
||||
selectedMeterTypeId: string;
|
||||
onSelectMeterTypeId: (id: string) => void;
|
||||
loadingMeterTypes: boolean;
|
||||
};
|
||||
|
||||
export default function ConcentratorsSidebar({
|
||||
loadingProjects,
|
||||
sampleView,
|
||||
sampleViewLabel,
|
||||
typesMenuOpen,
|
||||
onChangeSampleView,
|
||||
selectedProject,
|
||||
onSelectProject,
|
||||
projects,
|
||||
onRefresh,
|
||||
refreshDisabled,
|
||||
meterTypes,
|
||||
selectedMeterTypeId,
|
||||
onSelectMeterTypeId,
|
||||
loadingMeterTypes,
|
||||
}: Props) {
|
||||
const options = useMemo(
|
||||
() =>
|
||||
[
|
||||
{ key: "GENERAL", label: "General" },
|
||||
{ key: "LORA", label: "LoRa" },
|
||||
{ key: "LORAWAN", label: "LoRaWAN" },
|
||||
{ key: "GRANDES", label: "Grandes consumidores" },
|
||||
] as Array<{ key: SampleView; label: string }>,
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<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 dark:text-zinc-400">Proyectos</p>
|
||||
<p className="text-xs text-gray-400 dark:text-zinc-500">
|
||||
Seleccionado:{" "}
|
||||
<span className="font-semibold">
|
||||
{projects.find((p) => p.id === selectedProject)?.name || "—"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-semibold text-white shadow hover:bg-blue-700 transition disabled:opacity-60"
|
||||
onClick={onRefresh}
|
||||
disabled={loadingProjects || refreshDisabled}
|
||||
title="Actualizar"
|
||||
>
|
||||
<RefreshCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 relative">
|
||||
|
||||
{typesMenuOpen && (
|
||||
<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 (
|
||||
<button
|
||||
key={opt.key}
|
||||
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 dark:hover:bg-zinc-700",
|
||||
active ? "bg-blue-50/60" : "bg-white",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={`font-semibold ${
|
||||
active ? "text-blue-700" : "text-gray-700 dark:text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
{active && <Check size={16} className="text-blue-700" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</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 ? (
|
||||
<div className="text-sm text-gray-500 dark:text-zinc-400">Loading projects...</div>
|
||||
) : projects.length === 0 ? (
|
||||
<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.id === selectedProject;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => onSelectProject(p.id)}
|
||||
className={[
|
||||
"rounded-xl border p-4 transition cursor-pointer",
|
||||
active
|
||||
? "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 dark:text-zinc-100">
|
||||
{p.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-zinc-400">{p.region}</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={[
|
||||
"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 dark:text-zinc-200",
|
||||
].join(" ")}
|
||||
>
|
||||
{p.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex justify-between gap-2">
|
||||
<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 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 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 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 dark:text-zinc-400">Responsable</span>
|
||||
<span className="font-medium text-gray-800 dark:text-zinc-100">
|
||||
{p.contact}
|
||||
</span>
|
||||
</div>
|
||||
</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",
|
||||
active
|
||||
? "bg-blue-600 text-white hover:bg-blue-700"
|
||||
: "bg-gray-900 text-white hover:bg-gray-800",
|
||||
].join(" ")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectProject(p.id);
|
||||
}}
|
||||
>
|
||||
{active ? "Seleccionado" : "Seleccionar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/pages/concentrators/ConcentratorsTable.tsx
Normal file
112
src/pages/concentrators/ConcentratorsTable.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
// src/pages/concentrators/ConcentratorsTable.tsx
|
||||
import MaterialTable from "@material-table/core";
|
||||
import type { Concentrator } from "../../api/concentrators";
|
||||
|
||||
type Props = {
|
||||
isLoading: boolean; // ✅ ahora se llama así (como en Page)
|
||||
data: Concentrator[];
|
||||
activeRowId?: string;
|
||||
onRowClick: (row: Concentrator) => void;
|
||||
emptyMessage: string; // ✅ mensaje ya viene resuelto desde Page
|
||||
};
|
||||
|
||||
export default function ConcentratorsTable({
|
||||
isLoading,
|
||||
data,
|
||||
activeRowId,
|
||||
onRowClick,
|
||||
emptyMessage,
|
||||
}: Props) {
|
||||
return (
|
||||
<MaterialTable
|
||||
title="Concentrators"
|
||||
isLoading={isLoading}
|
||||
columns={[
|
||||
{
|
||||
title: "Serial",
|
||||
field: "serialNumber",
|
||||
render: (rowData: Concentrator) => rowData.serialNumber || "-",
|
||||
},
|
||||
{
|
||||
title: "Nombre",
|
||||
field: "name",
|
||||
render: (rowData: Concentrator) => rowData.name || "-",
|
||||
},
|
||||
{
|
||||
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.status === "ACTIVE"
|
||||
? "text-blue-600 border-blue-600"
|
||||
: "text-red-600 border-red-600"
|
||||
}`}
|
||||
>
|
||||
{rowData.status || "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Ubicación",
|
||||
field: "location",
|
||||
render: (rowData: Concentrator) => rowData.location || "-",
|
||||
},
|
||||
{
|
||||
title: "IP",
|
||||
field: "ipAddress",
|
||||
render: (rowData: Concentrator) => rowData.ipAddress || "-",
|
||||
},
|
||||
{
|
||||
title: "Última Comunicación",
|
||||
field: "lastCommunication",
|
||||
type: "datetime",
|
||||
render: (rowData: Concentrator) => rowData.lastCommunication ? new Date(rowData.lastCommunication).toLocaleString() : "-",
|
||||
},
|
||||
]}
|
||||
data={data}
|
||||
onRowClick={(_, rowData) => onRowClick(rowData as Concentrator)}
|
||||
options={{
|
||||
actionsColumnIndex: -1,
|
||||
search: false,
|
||||
paging: true,
|
||||
pageSize: 10,
|
||||
pageSizeOptions: [10, 20, 50],
|
||||
sorting: true,
|
||||
rowStyle: (rowData) => ({
|
||||
backgroundColor:
|
||||
activeRowId === (rowData as Concentrator).id
|
||||
? "#EEF2FF"
|
||||
: "#FFFFFF",
|
||||
}),
|
||||
}}
|
||||
localization={{
|
||||
body: { emptyDataSourceMessage: emptyMessage },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
256
src/pages/concentrators/useConcentrators.ts
Normal file
256
src/pages/concentrators/useConcentrators.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
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";
|
||||
|
||||
export function useConcentrators() {
|
||||
|
||||
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[]
|
||||
>([]);
|
||||
|
||||
const isGeneral = sampleView === "GENERAL";
|
||||
|
||||
const sampleViewLabel = useMemo(() => {
|
||||
switch (sampleView) {
|
||||
case "GENERAL":
|
||||
return "General";
|
||||
case "LORA":
|
||||
return "LoRa";
|
||||
case "LORAWAN":
|
||||
return "LoRaWAN";
|
||||
case "GRANDES":
|
||||
return "Grandes consumidores";
|
||||
default:
|
||||
return "General";
|
||||
}
|
||||
}, [sampleView]);
|
||||
|
||||
const visibleProjects = useMemo(
|
||||
() =>
|
||||
isAdmin
|
||||
? allProjects
|
||||
: userProjectId
|
||||
? [userProjectId]
|
||||
: [],
|
||||
[allProjects, isAdmin, userProjectId]
|
||||
);
|
||||
|
||||
const loadMeterTypes = async () => {
|
||||
setLoadingMeterTypes(true);
|
||||
try {
|
||||
const meterTypesData = await fetchMeterTypes();
|
||||
setMeterTypes(meterTypesData);
|
||||
} catch (err) {
|
||||
console.error("Error loading meter types:", err);
|
||||
setMeterTypes([]);
|
||||
} finally {
|
||||
setLoadingMeterTypes(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadProjects = async () => {
|
||||
setLoadingProjects(true);
|
||||
try {
|
||||
const projectsData = await fetchProjects();
|
||||
setProjects(projectsData);
|
||||
const projectIds = projectsData.map((p) => p.id);
|
||||
setAllProjects(projectIds);
|
||||
|
||||
setSelectedProject((prev) => {
|
||||
if (prev) return prev;
|
||||
if (!isAdmin && userProjectId) {
|
||||
return userProjectId;
|
||||
}
|
||||
return projectIds[0] ?? "";
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error loading projects:", err);
|
||||
setProjects([]);
|
||||
setAllProjects([]);
|
||||
} finally {
|
||||
setLoadingProjects(false);
|
||||
}
|
||||
};
|
||||
|
||||
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(() => {
|
||||
loadProjects();
|
||||
loadConcentrators();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sampleView]);
|
||||
|
||||
// auto select single visible project
|
||||
useEffect(() => {
|
||||
if (!isGeneral) return;
|
||||
if (!selectedProject && visibleProjects.length === 1) {
|
||||
setSelectedProject(visibleProjects[0]);
|
||||
}
|
||||
}, [visibleProjects, selectedProject, isGeneral]);
|
||||
|
||||
useEffect(() => {
|
||||
let filtered = concentrators;
|
||||
|
||||
if (selectedProject) {
|
||||
filtered = filtered.filter((c) => c.projectId === selectedProject);
|
||||
}
|
||||
|
||||
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(() => {
|
||||
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;
|
||||
}, {});
|
||||
|
||||
const baseRegion = "Baja California";
|
||||
const baseContact = "Operaciones";
|
||||
const baseLastSync = "Hace 1 h";
|
||||
|
||||
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[projectId] ?? 0,
|
||||
activeAlerts: 0,
|
||||
lastSync: baseLastSync,
|
||||
contact: baseContact,
|
||||
status: "ACTIVO" as const,
|
||||
}));
|
||||
}, [concentrators, visibleProjects, projects, isGeneral, sampleView, selectedMeterTypeId]);
|
||||
|
||||
const projectsData: ProjectCard[] = useMemo(() => {
|
||||
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
|
||||
sampleView,
|
||||
setSampleView,
|
||||
sampleViewLabel,
|
||||
isGeneral,
|
||||
|
||||
// loading
|
||||
loadingProjects,
|
||||
loadingConcentrators,
|
||||
loadingMeterTypes,
|
||||
|
||||
// projects
|
||||
allProjects,
|
||||
visibleProjects,
|
||||
projectsData,
|
||||
selectedProject,
|
||||
setSelectedProject,
|
||||
|
||||
meterTypes,
|
||||
selectedMeterTypeId,
|
||||
setSelectedMeterTypeId,
|
||||
|
||||
// data
|
||||
concentrators,
|
||||
setConcentrators,
|
||||
filteredConcentrators,
|
||||
|
||||
// actions
|
||||
loadConcentrators,
|
||||
};
|
||||
}
|
||||
188
src/pages/conectores/SHMetersPage.tsx
Normal file
188
src/pages/conectores/SHMetersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/pages/conectores/TTSPage.tsx
Normal file
41
src/pages/conectores/TTSPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
src/pages/conectores/XMetersPage.tsx
Normal file
190
src/pages/conectores/XMetersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
727
src/pages/consumption/ConsumptionPage.tsx
Normal file
727
src/pages/consumption/ConsumptionPage.tsx
Normal 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"} m³`}
|
||||
loading={loadingSummary}
|
||||
gradient="from-violet-500 to-purple-600"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Droplets />}
|
||||
label="Consumo Acumulado"
|
||||
value={`${currentMonthAverage.toFixed(1)} m³`}
|
||||
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>
|
||||
);
|
||||
}
|
||||
210
src/pages/consumption/ReadingsBulkUploadModal.tsx
Normal file
210
src/pages/consumption/ReadingsBulkUploadModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
990
src/pages/historico/HistoricoPage.tsx
Normal file
990
src/pages/historico/HistoricoPage.tsx
Normal 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)} m³`
|
||||
: "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)} m³`,
|
||||
"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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
210
src/pages/meters/MetersBulkUploadModal.tsx
Normal file
210
src/pages/meters/MetersBulkUploadModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
379
src/pages/meters/MetersModal.tsx
Normal file
379
src/pages/meters/MetersModal.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import type React from "react";
|
||||
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: MeterInput;
|
||||
setForm: React.Dispatch<React.SetStateAction<MeterInput>>;
|
||||
|
||||
errors: Record<string, boolean>;
|
||||
setErrors: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||
|
||||
onClose: () => void;
|
||||
onSave: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export default function MetersModal({
|
||||
editingId,
|
||||
selectedProject,
|
||||
form,
|
||||
setForm,
|
||||
errors,
|
||||
setErrors,
|
||||
onClose,
|
||||
onSave,
|
||||
}: Props) {
|
||||
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 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 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["serialNumber"] ? "border-red-500" : ""
|
||||
}`}
|
||||
placeholder="Número de serie"
|
||||
value={form.serialNumber}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, serialNumber: e.target.value });
|
||||
if (errors["serialNumber"]) setErrors({ ...errors, serialNumber: false });
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{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["name"] ? "border-red-500" : ""
|
||||
}`}
|
||||
placeholder="Nombre del medidor"
|
||||
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>
|
||||
<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>
|
||||
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Tipo</label>
|
||||
<select
|
||||
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
value={form.type ?? "LORA"}
|
||||
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
|
||||
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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPruebaProject && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-zinc-200 border-b dark:border-zinc-700 pb-2">
|
||||
Información Técnica (Proyecto PRUEBA)
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Protocol</label>
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="ej: LoRaWAN"
|
||||
value={form.protocol ?? ""}
|
||||
onChange={(e) => setForm({ ...form, protocol: e.target.value || undefined })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Voltage (V)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="ej: 3.6"
|
||||
value={form.voltage ?? ""}
|
||||
onChange={(e) => setForm({ ...form, voltage: e.target.value ? parseFloat(e.target.value) : undefined })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Signal (dBm)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="ej: -85"
|
||||
value={form.signal ?? ""}
|
||||
onChange={(e) => setForm({ ...form, signal: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Current Flow</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="ej: 12.5"
|
||||
value={form.currentFlow ?? ""}
|
||||
onChange={(e) => setForm({ ...form, currentFlow: e.target.value ? parseFloat(e.target.value) : undefined })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Total Flow Reverse</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="ej: 0.0"
|
||||
value={form.totalFlowReverse ?? ""}
|
||||
onChange={(e) => setForm({ ...form, totalFlowReverse: e.target.value ? parseFloat(e.target.value) : undefined })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Leakage Status</label>
|
||||
<select
|
||||
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
value={form.leakageStatus ?? ""}
|
||||
onChange={(e) => setForm({ ...form, leakageStatus: e.target.value || undefined })}
|
||||
>
|
||||
<option value="">Selecciona...</option>
|
||||
<option value="OK">OK</option>
|
||||
<option value="WARNING">Warning</option>
|
||||
<option value="ALERT">Alert</option>
|
||||
<option value="CRITICAL">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Burst Status</label>
|
||||
<select
|
||||
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
value={form.burstStatus ?? ""}
|
||||
onChange={(e) => setForm({ ...form, burstStatus: e.target.value || undefined })}
|
||||
>
|
||||
<option value="">Selecciona...</option>
|
||||
<option value="OK">OK</option>
|
||||
<option value="WARNING">Warning</option>
|
||||
<option value="ALERT">Alert</option>
|
||||
<option value="CRITICAL">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Manufacturer</label>
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="ej: Kamstrup"
|
||||
value={form.manufacturer ?? ""}
|
||||
onChange={(e) => setForm({ ...form, manufacturer: e.target.value || undefined })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Latitude</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.00000001"
|
||||
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="ej: 20.659698"
|
||||
value={form.latitude ?? ""}
|
||||
onChange={(e) => setForm({ ...form, latitude: e.target.value ? parseFloat(e.target.value) : undefined })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Longitude</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.00000001"
|
||||
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="ej: -103.349609"
|
||||
value={form.longitude ?? ""}
|
||||
onChange={(e) => setForm({ ...form, longitude: e.target.value ? parseFloat(e.target.value) : undefined })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ACTIONS */}
|
||||
<div className="flex justify-end gap-2 pt-3 border-t 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]"
|
||||
>
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
298
src/pages/meters/MetersSidebar.tsx
Normal file
298
src/pages/meters/MetersSidebar.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
// src/pages/meters/MetersSidebar.tsx
|
||||
import { useEffect, useMemo, useRef, useState } from "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;
|
||||
|
||||
takeType: TakeType;
|
||||
setTakeType: (t: TakeType) => void;
|
||||
|
||||
selectedProject: string;
|
||||
setSelectedProject: React.Dispatch<React.SetStateAction<string>>;
|
||||
|
||||
isMockMode: boolean;
|
||||
projects: ProjectCard[];
|
||||
|
||||
onRefresh: () => void;
|
||||
refreshDisabled?: boolean;
|
||||
|
||||
allProjects: string[];
|
||||
onResetSelection?: () => void;
|
||||
|
||||
meterTypes: MeterType[];
|
||||
selectedMeterTypeId: string;
|
||||
onSelectMeterTypeId: (id: string) => void;
|
||||
loadingMeterTypes: boolean;
|
||||
};
|
||||
|
||||
type TakeTypeOption = { key: TakeType; label: string };
|
||||
|
||||
const TAKE_TYPE_OPTIONS: TakeTypeOption[] = [
|
||||
{ key: "GENERAL", label: "General" },
|
||||
{ key: "LORA", label: "LoRa" },
|
||||
{ key: "LORAWAN", label: "LoRaWAN" },
|
||||
{ key: "GRANDES", label: "Grandes consumidores" },
|
||||
];
|
||||
|
||||
export default function MetersSidebar({
|
||||
loadingProjects,
|
||||
takeType,
|
||||
setTakeType,
|
||||
selectedProject,
|
||||
setSelectedProject,
|
||||
isMockMode,
|
||||
projects,
|
||||
onRefresh,
|
||||
refreshDisabled,
|
||||
allProjects,
|
||||
onResetSelection,
|
||||
meterTypes,
|
||||
selectedMeterTypeId,
|
||||
onSelectMeterTypeId,
|
||||
loadingMeterTypes,
|
||||
}: Props) {
|
||||
const [typesMenuOpen, setTypesMenuOpen] = useState(false);
|
||||
|
||||
// para detectar click fuera (igual a tu implementación)
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (!menuRef.current) return;
|
||||
if (!menuRef.current.contains(e.target as Node)) {
|
||||
setTypesMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onClickOutside);
|
||||
return () => document.removeEventListener("mousedown", onClickOutside);
|
||||
}, []);
|
||||
|
||||
const takeTypeLabel = useMemo(
|
||||
() => TAKE_TYPE_OPTIONS.find((o) => o.key === takeType)?.label ?? "General",
|
||||
[takeType]
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="w-[420px] shrink-0">
|
||||
<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 dark:text-zinc-400">Proyectos</p>
|
||||
<p className="text-xs text-gray-400 dark:text-zinc-500">
|
||||
Seleccionado:{" "}
|
||||
<span className="font-semibold">
|
||||
{selectedProject || "—"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-semibold text-white shadow hover:bg-blue-700 transition disabled:opacity-60"
|
||||
onClick={onRefresh}
|
||||
disabled={loadingProjects || !!refreshDisabled}
|
||||
title="Actualizar"
|
||||
>
|
||||
<RefreshCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ✅ Tipos de tomas (dropdown) — mismo UI que Concentrators */}
|
||||
<div className="mt-4 relative" ref={menuRef}>
|
||||
|
||||
{typesMenuOpen && (
|
||||
<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;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTakeType(opt.key);
|
||||
setTypesMenuOpen(false);
|
||||
|
||||
onResetSelection?.();
|
||||
|
||||
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 dark:hover:bg-zinc-700",
|
||||
active ? "bg-blue-50/60" : "bg-white",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={`font-semibold ${
|
||||
active ? "text-blue-700" : "text-gray-700 dark:text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
|
||||
{active && <Check size={16} className="text-blue-700" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</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 dark:text-zinc-400">Cargando proyectos...</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 text-center py-10">
|
||||
{selectedMeterTypeId
|
||||
? "No hay proyectos con el tipo de toma seleccionado."
|
||||
: "No se encontraron proyectos."
|
||||
}
|
||||
</div>
|
||||
) : (
|
||||
projects.map((p) => {
|
||||
const active = !isMockMode && p.name === selectedProject;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={p.name}
|
||||
onClick={() => {
|
||||
if (isMockMode) return;
|
||||
setSelectedProject(p.name);
|
||||
onResetSelection?.();
|
||||
}}
|
||||
className={[
|
||||
"rounded-xl border p-4 transition cursor-pointer",
|
||||
active
|
||||
? "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={
|
||||
isMockMode
|
||||
? "Modo demo: estas tarjetas son mocks"
|
||||
: "Seleccionar proyecto"
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-800 dark:text-zinc-100">
|
||||
{p.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-zinc-400">{p.region}</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={[
|
||||
"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 dark:text-zinc-200",
|
||||
].join(" ")}
|
||||
>
|
||||
{p.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex justify-between gap-2">
|
||||
<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 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 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 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 dark:text-zinc-400">Responsable</span>
|
||||
<span className="font-medium text-gray-800 dark:text-zinc-100">
|
||||
{p.contact}
|
||||
</span>
|
||||
</div>
|
||||
</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",
|
||||
active
|
||||
? "bg-blue-600 text-white hover:bg-blue-700"
|
||||
: "bg-gray-900 text-white hover:bg-gray-800",
|
||||
isMockMode ? "opacity-50 cursor-not-allowed" : "",
|
||||
].join(" ")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isMockMode) return;
|
||||
setSelectedProject(p.name);
|
||||
onResetSelection?.();
|
||||
}}
|
||||
disabled={isMockMode}
|
||||
>
|
||||
{isMockMode
|
||||
? "Demo"
|
||||
: active
|
||||
? "Seleccionado"
|
||||
: "Seleccionar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
168
src/pages/meters/MetersTable.tsx
Normal file
168
src/pages/meters/MetersTable.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import MaterialTable from "@material-table/core";
|
||||
import type { Meter } from "../../api/meters";
|
||||
import type { TakeType } from "./MeterPage";
|
||||
|
||||
export type MetersTableProps = {
|
||||
data: Meter[];
|
||||
isLoading: boolean;
|
||||
isMockMode: boolean;
|
||||
selectedProject: string;
|
||||
takeType: TakeType;
|
||||
activeMeter: Meter | null;
|
||||
onRowClick: (row: Meter) => void;
|
||||
};
|
||||
|
||||
export default function MetersTable({
|
||||
data,
|
||||
isLoading,
|
||||
isMockMode,
|
||||
selectedProject,
|
||||
takeType,
|
||||
activeMeter,
|
||||
onRowClick,
|
||||
}: 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={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",
|
||||
}),
|
||||
}}
|
||||
localization={{
|
||||
body: {
|
||||
emptyDataSourceMessage: isMockMode
|
||||
? "Modo demo: selecciona 'General' para ver datos reales."
|
||||
: !selectedProject
|
||||
? "Selecciona un proyecto para ver medidores."
|
||||
: isLoading
|
||||
? "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.",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
src/pages/meters/useMeters.ts
Normal file
148
src/pages/meters/useMeters.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
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 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 || "");
|
||||
|
||||
const [meters, setMeters] = useState<Meter[]>([]);
|
||||
const [filteredMeters, setFilteredMeters] = useState<Meter[]>([]);
|
||||
const [loadingMeters, setLoadingMeters] = useState(true);
|
||||
|
||||
const [meterTypes, setMeterTypes] = useState<MeterType[]>([]);
|
||||
const [selectedMeterTypeId, setSelectedMeterTypeId] = useState<string>("");
|
||||
const [loadingMeterTypes, setLoadingMeterTypes] = useState(true);
|
||||
|
||||
const loadProjects = async () => {
|
||||
setLoadingProjects(true);
|
||||
try {
|
||||
const projects = await fetchProjects();
|
||||
|
||||
let visibleProjects = projects;
|
||||
if (!isAdmin && userProjectId) {
|
||||
visibleProjects = projects.filter(p => p.id === userProjectId);
|
||||
}
|
||||
|
||||
setAllProjects(visibleProjects);
|
||||
|
||||
setSelectedProject((prev) => {
|
||||
if (prev) return prev;
|
||||
if (initialProject) return initialProject;
|
||||
|
||||
if (!isAdmin && userProjectId) {
|
||||
const userProject = projects.find(p => p.id === userProjectId);
|
||||
if (userProject) return userProject.name;
|
||||
}
|
||||
|
||||
const projectNames = visibleProjects.map((p) => p.name);
|
||||
return projectNames[0] ?? "";
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error loading projects:", error);
|
||||
setAllProjects([]);
|
||||
} finally {
|
||||
setLoadingProjects(false);
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
}, []);
|
||||
|
||||
// keep selectedProject synced if parent changes initialProject
|
||||
useEffect(() => {
|
||||
if (initialProject) setSelectedProject(initialProject);
|
||||
}, [initialProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setFilteredMeters([]);
|
||||
return;
|
||||
}
|
||||
setFilteredMeters(meters.filter((m) => m.projectName === selectedProject));
|
||||
}, [selectedProject, meters]);
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (!selectedMeterTypeId) {
|
||||
return allProjects;
|
||||
}
|
||||
return allProjects.filter(p => p.meterTypeId === selectedMeterTypeId);
|
||||
}, [allProjects, selectedMeterTypeId]);
|
||||
|
||||
const projectsCounts = useMemo(() => {
|
||||
return meters.reduce<Record<string, number>>((acc, m) => {
|
||||
const project = m.projectName ?? "SIN PROYECTO";
|
||||
acc[project] = (acc[project] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
}, [meters]);
|
||||
|
||||
return {
|
||||
// loading
|
||||
loadingProjects,
|
||||
loadingMeters,
|
||||
loadingMeterTypes,
|
||||
|
||||
// projects
|
||||
allProjects,
|
||||
filteredProjects,
|
||||
projectsCounts,
|
||||
selectedProject,
|
||||
setSelectedProject,
|
||||
|
||||
meterTypes,
|
||||
selectedMeterTypeId,
|
||||
setSelectedMeterTypeId,
|
||||
|
||||
// data
|
||||
meters,
|
||||
setMeters,
|
||||
filteredMeters,
|
||||
|
||||
// actions
|
||||
loadMeters,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -18,21 +28,22 @@ export default function ProjectsPage() {
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const [meterTypes, setMeterTypes] = useState<MeterType[]>([]);
|
||||
const [organismos, setOrganismos] = useState<OrganismoOperador[]>([]);
|
||||
|
||||
const emptyProject: Omit<Project, "id"> = {
|
||||
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">
|
||||
<button
|
||||
onClick={() => {
|
||||
setForm(emptyProject);
|
||||
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>
|
||||
{(isAdmin || isOrganismo) && (
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg"
|
||||
>
|
||||
<Plus size={16} /> Agregar
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
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);
|
||||
}}
|
||||
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
|
||||
</button>
|
||||
{(isAdmin || isOrganismo) && (
|
||||
<button
|
||||
onClick={openEditModal}
|
||||
disabled={!activeProject}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
|
||||
>
|
||||
<Pencil size={16} /> Editar
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={!activeProject}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
|
||||
>
|
||||
<Trash2 size={16} /> Delete
|
||||
</button>
|
||||
{(isAdmin || isOrganismo) && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={!activeProject}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
|
||||
>
|
||||
<Trash2 size={16} /> Eliminar
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={loadProjects}
|
||||
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>
|
||||
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
placeholder="Area Name"
|
||||
value={form.areaName}
|
||||
onChange={(e) => setForm({ ...form, areaName: e.target.value })}
|
||||
/>
|
||||
<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"
|
||||
placeholder="Nombre del proyecto"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: 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">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 Name"
|
||||
value={form.deviceName}
|
||||
onChange={(e) => setForm({ ...form, deviceName: 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>
|
||||
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
placeholder="Device Type"
|
||||
value={form.deviceType}
|
||||
onChange={(e) => setForm({ ...form, deviceType: e.target.value })}
|
||||
/>
|
||||
<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 (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="Operator"
|
||||
value={form.operator}
|
||||
onChange={(e) => setForm({ ...form, operator: e.target.value })}
|
||||
/>
|
||||
<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 })}
|
||||
>
|
||||
<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>
|
||||
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
placeholder="Installed Time"
|
||||
value={form.installedTime}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, installedTime: e.target.value })
|
||||
}
|
||||
/>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
placeholder="Communication Time"
|
||||
value={form.communicationTime}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, communicationTime: e.target.value })
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
setForm({
|
||||
...form,
|
||||
deviceStatus:
|
||||
form.deviceStatus === "ACTIVE" ? "INACTIVE" : "ACTIVE",
|
||||
})
|
||||
}
|
||||
className="w-full border rounded px-3 py-2"
|
||||
>
|
||||
Status: {form.deviceStatus}
|
||||
</button>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-3">
|
||||
<button onClick={() => setShowModal(false)}>Cancel</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"
|
||||
>
|
||||
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
13
upload-panel/index.html
Normal 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
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
25
upload-panel/package.json
Normal 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
58
upload-panel/src/App.tsx
Normal 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;
|
||||
65
upload-panel/src/api/upload.ts
Normal file
65
upload-panel/src/api/upload.ts
Normal 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');
|
||||
}
|
||||
108
upload-panel/src/components/FileDropzone.tsx
Normal file
108
upload-panel/src/components/FileDropzone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
upload-panel/src/components/MetersUpload.tsx
Normal file
121
upload-panel/src/components/MetersUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
upload-panel/src/components/ReadingsUpload.tsx
Normal file
121
upload-panel/src/components/ReadingsUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
upload-panel/src/components/ResultsDisplay.tsx
Normal file
105
upload-panel/src/components/ResultsDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
upload-panel/src/index.css
Normal file
1
upload-panel/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
10
upload-panel/src/main.tsx
Normal file
10
upload-panel/src/main.tsx
Normal 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
1
upload-panel/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
upload-panel/tsconfig.json
Normal file
21
upload-panel/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
11
upload-panel/tsconfig.node.json
Normal file
11
upload-panel/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
21
upload-panel/vite.config.ts
Normal file
21
upload-panel/vite.config.ts
Normal 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"
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -11,13 +11,13 @@ export default defineConfig({
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
host: true,
|
||||
port: 5173,
|
||||
allowedHosts: [
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"reyna-compressive-shaunna.ngrok-free.dev",
|
||||
"https://sistema.gestionrecursoshidricos.com/"
|
||||
"sistema.gestionrecursoshidricos.com"
|
||||
],
|
||||
// proxy:{
|
||||
// '/api':{
|
||||
|
||||
26
water-api/.env.example
Normal file
26
water-api/.env.example
Normal 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
86
water-api/.gitignore
vendored
Normal 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
47
water-api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
62
water-api/scripts/run-migration.sh
Executable file
62
water-api/scripts/run-migration.sh
Executable 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
|
||||
207
water-api/sql/add_audit_logs.sql
Normal file
207
water-api/sql/add_audit_logs.sql
Normal 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
|
||||
-- ============================================================================
|
||||
124
water-api/sql/add_meter_extended_fields.sql
Normal file
124
water-api/sql/add_meter_extended_fields.sql
Normal 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;
|
||||
68
water-api/sql/add_meter_project_relation.sql
Normal file
68
water-api/sql/add_meter_project_relation.sql
Normal 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;
|
||||
39
water-api/sql/add_notifications.sql
Normal file
39
water-api/sql/add_notifications.sql
Normal 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';
|
||||
66
water-api/sql/add_organismos_operadores.sql
Normal file
66
water-api/sql/add_organismos_operadores.sql
Normal 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');
|
||||
11
water-api/sql/add_user_meter_fields.sql
Normal file
11
water-api/sql/add_user_meter_fields.sql
Normal 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);
|
||||
27
water-api/sql/add_user_project_relation.sql
Normal file
27
water-api/sql/add_user_project_relation.sql
Normal 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';
|
||||
81
water-api/sql/create_meter_types.sql
Normal file
81
water-api/sql/create_meter_types.sql
Normal 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
434
water-api/sql/schema.sql
Normal 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
|
||||
-- ============================================================================
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user