diff --git a/CAMBIOS_SESION.md b/CAMBIOS_SESION.md new file mode 100644 index 0000000..66c456c --- /dev/null +++ b/CAMBIOS_SESION.md @@ -0,0 +1,131 @@ +# Cambios Realizados en Esta Sesión + +**Fecha:** 2026-01-23 + +--- + +## Problema a Resolver +Pantalla en blanco al entrar a "Water Meters" y "Consumo" después de implementar carga masiva de lecturas. + +--- + +## Cambios Realizados + +### 1. `src/api/client.ts` (línea ~224-237) +**Cambio:** Modificado `parseResponse` para manejar respuestas con paginación + +```typescript +// ANTES: +if ('success' in data) { + if (data.success === false) { throw... } + return data.data as T; // Solo devolvía data.data +} + +// DESPUÉS: +if ('success' in data) { + if (data.success === false) { throw... } + // Si hay pagination, devolver objeto completo + if ('pagination' in data) { + return { + data: data.data, + pagination: data.pagination, + } as T; + } + return data.data as T; +} +``` + +### 2. `src/api/readings.ts` +**Cambios en interface `MeterReading`:** +```typescript +// ELIMINADO: +deviceId: string | null; +areaName: string | null; + +// AGREGADO: +meterLocation: string | null; +concentratorId: string; +concentratorName: string; +projectName: string; +``` + +**Cambios en interface `ReadingFilters`:** +```typescript +// ELIMINADO: +areaName?: string; + +// AGREGADO: +concentratorId?: string; +``` + +**Cambios en interface `ReadingInput`:** +```typescript +// ELIMINADO: +deviceId?: string; +``` + +**Cambios en función `createReading`:** +```typescript +// ELIMINADO de backendData: +device_id: data.deviceId, +``` + +### 3. `src/api/projects.ts` (función `fetchProjects`) +**Cambio:** Ahora maneja respuestas paginadas + +```typescript +// ANTES: +const response = await apiClient.get[]>('/api/projects'); +return transformArray(response); + +// DESPUÉS: +const response = await apiClient.get<...>('/api/projects'); +if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) { + return transformArray(response.data); +} +return transformArray(response as Record[]); +``` + +### 4. `src/api/concentrators.ts` (función `fetchConcentrators`) +**Cambio:** Mismo patrón que projects.ts para manejar paginación + +### 5. `src/pages/consumption/ConsumptionPage.tsx` +**Cambios:** +- Línea ~94: `r.areaName` → `r.meterLocation` (filtro de búsqueda) +- Línea ~127: `"Área"` → `"Ubicación"` (header CSV) +- Línea ~132: `r.areaName` → `r.meterLocation` (datos CSV) +- Línea ~365: `"Área"` → `"Ubicación"` (header tabla) +- Línea ~428: `reading.areaName` → `reading.meterLocation` (celda tabla) + +### 6. `water-api/src/services/reading.service.ts` +**Cambios previos (ya aplicados):** +- Eliminadas todas las referencias a `device_id` en queries SQL +- La columna `device_id` no existe en la tabla `meter_readings` + +--- + +## Estado de los Servidores + +### Backend (puerto 3000) ✅ Funcionando +```bash +curl http://localhost:3000/api/readings?pageSize=1 +# Devuelve datos correctamente +``` + +### Frontend (puerto 5173) ⚠️ Pantalla blanca +- El servidor está corriendo +- Las páginas no renderizan correctamente +- Se necesita revisar la consola del navegador para ver el error específico + +--- + +## Para Debug + +1. Abrir http://localhost:5173 +2. F12 → Console +3. Navegar a "Water Meters" o "Consumo" +4. Copiar el error de la consola + +El error más probable es: +- `Cannot read property 'map' of undefined` - si `transformArray` recibe undefined +- `TypeError: response.data is undefined` - si la respuesta no tiene la estructura esperada diff --git a/ESTADO_ACTUAL.md b/ESTADO_ACTUAL.md new file mode 100644 index 0000000..07c3582 --- /dev/null +++ b/ESTADO_ACTUAL.md @@ -0,0 +1,254 @@ +# Estado Actual del Proyecto Water Project + +**Fecha:** 2026-01-23 +**Última sesión:** Migración de NocoDB a PostgreSQL + Node.js/Express + +--- + +## Resumen del Proyecto + +Sistema de gestión de medidores de agua con: +- **Frontend:** React + TypeScript + Vite (puerto 5173) +- **Backend:** Node.js + Express + TypeScript (puerto 3000) +- **Base de datos:** PostgreSQL + +### Jerarquía de datos: +``` +Projects → Concentrators → Meters → Readings +``` + +--- + +## Lo que se implementó + +### 1. Backend API completo (`water-api/`) +- Autenticación JWT con refresh tokens +- CRUD completo para: Projects, Concentrators, Meters, Readings, Users, Roles +- Carga masiva de medidores y lecturas via Excel +- Endpoints de resumen/estadísticas + +### 2. Frontend adaptado +- Cliente API con manejo automático de JWT (`src/api/client.ts`) +- Transformación automática snake_case ↔ camelCase +- Páginas: Home, Meters, Consumption, Projects, Users, Roles + +### 3. Carga Masiva +- **Medidores:** Subir Excel con columnas: `serial_number`, `name`, `concentrator_serial`, `location`, `type`, `status` +- **Lecturas:** Subir Excel con columnas: `meter_serial`, `reading_value`, `reading_type`, `battery_level`, `signal_strength`, `received_at` + +### 4. Credenciales actualizadas +- **Usuario admin:** Ivan Alcaraz +- **Email:** ialcarazsalazar@consultoria-as.com +- **Password:** Aasi940812 + +--- + +## Problema Actual: Pantalla en Blanco + +### Síntoma +Al entrar a "Water Meters" o "Consumo", la pantalla se queda en blanco. + +### Causa identificada +El cliente API (`src/api/client.ts`) fue modificado para manejar respuestas paginadas. Cuando el backend devuelve: +```json +{ + "success": true, + "data": [...], + "pagination": {...} +} +``` + +El cliente ahora devuelve `{ data: [...], pagination: {...} }` en lugar de solo el array. + +### Archivos modificados para manejar esto: +1. ✅ `src/api/client.ts` - Detecta si hay `pagination` y devuelve el objeto completo +2. ✅ `src/api/meters.ts` - Ya manejaba respuestas paginadas +3. ✅ `src/api/readings.ts` - Ya manejaba respuestas paginadas +4. ✅ `src/api/projects.ts` - Actualizado para manejar paginación +5. ✅ `src/api/concentrators.ts` - Actualizado para manejar paginación + +### Cambios en interfaces de readings.ts: +- Eliminado `deviceId` (no existe en BD) +- Cambiado `areaName` por `meterLocation` +- Agregado `concentratorId`, `concentratorName`, `projectName` + +### Cambios en ConsumptionPage.tsx: +- Cambiado `areaName` por `meterLocation` en todas las referencias +- Cambiado header de tabla "Área" por "Ubicación" + +--- + +## Para Continuar Debugging + +### 1. Verificar que los APIs funcionan +```bash +# Projects +curl http://localhost:3000/api/projects + +# Meters (requiere auth) +curl http://localhost:3000/api/meters?pageSize=2 + +# Readings +curl http://localhost:3000/api/readings?pageSize=2 +``` + +### 2. Ver errores en el navegador +1. Abrir la aplicación en http://localhost:5173 +2. Abrir DevTools (F12) +3. Ir a la pestaña "Console" +4. Navegar a "Water Meters" o "Consumo" +5. Copiar el error que aparezca + +### 3. Posibles causas adicionales +Si el error persiste, verificar: + +a) **Error de transformación de datos:** + - El `transformArray` puede estar recibiendo undefined + - Verificar en `src/api/readings.ts` línea ~120 + +b) **Error en componentes React:** + - Algún componente puede estar accediendo a propiedades que no existen + - Verificar `src/pages/consumption/ConsumptionPage.tsx` + - Verificar `src/pages/meters/MeterPage.tsx` + +c) **Problema con el cliente API:** + - El `parseResponse` en `src/api/client.ts` línea ~211 + - Verificar que la detección de pagination funcione correctamente + +### 4. Test rápido del cliente +Agregar console.log temporal en `src/api/client.ts`: +```typescript +// En la función parseResponse, después de línea 221 +console.log('API Response:', data); +``` + +--- + +## Estructura de Archivos Clave + +``` +water-project/ +├── src/ +│ ├── api/ +│ │ ├── client.ts # Cliente HTTP con JWT +│ │ ├── readings.ts # API de lecturas +│ │ ├── meters.ts # API de medidores +│ │ ├── projects.ts # API de proyectos +│ │ └── concentrators.ts # API de concentradores +│ ├── pages/ +│ │ ├── meters/ +│ │ │ ├── MeterPage.tsx +│ │ │ ├── useMeters.ts # Hook para cargar datos +│ │ │ └── ... +│ │ └── consumption/ +│ │ ├── ConsumptionPage.tsx +│ │ └── ReadingsBulkUploadModal.tsx +│ └── ... +├── water-api/ +│ ├── src/ +│ │ ├── controllers/ +│ │ │ ├── reading.controller.ts +│ │ │ ├── meter.controller.ts +│ │ │ └── bulk-upload.controller.ts +│ │ ├── services/ +│ │ │ ├── reading.service.ts +│ │ │ ├── meter.service.ts +│ │ │ └── bulk-upload.service.ts +│ │ └── routes/ +│ └── ... +└── ESTADO_ACTUAL.md (este archivo) +``` + +--- + +## Comandos Útiles + +```bash +# Iniciar backend +cd /home/GRH/water-project/water-api +npm run dev + +# Iniciar frontend +cd /home/GRH/water-project +npm run dev + +# Verificar TypeScript (ignorar TS6133 - variables no usadas) +cd /home/GRH/water-project +npx tsc --noEmit 2>&1 | grep -v "TS6133" + +# Ver logs del backend +tail -f /tmp/water-api.log + +# Conectar a PostgreSQL +psql -U postgres -d water_project +``` + +--- + +## Esquema de Base de Datos Relevante + +### Tabla `meter_readings` +```sql +- id (UUID) +- meter_id (UUID, FK → meters) +- reading_value (DECIMAL) +- reading_type (VARCHAR) -- AUTOMATIC, MANUAL, SCHEDULED +- battery_level (SMALLINT, nullable) +- signal_strength (SMALLINT, nullable) +- raw_payload (TEXT, nullable) +- received_at (TIMESTAMP) +- created_at (TIMESTAMP) +``` + +**NOTA:** La columna `device_id` NO EXISTE en esta tabla. Fue removida del código. + +### Respuesta del API `/api/readings` +```json +{ + "success": true, + "data": [ + { + "id": "...", + "meter_id": "...", + "reading_value": "300.0000", + "reading_type": "MANUAL", + "battery_level": null, + "signal_strength": null, + "raw_payload": null, + "received_at": "2026-01-23T21:07:28.726Z", + "created_at": "2026-01-23T21:07:28.726Z", + "meter_serial_number": "24300001", + "meter_name": "D307_IB-113107", + "meter_location": null, + "concentrator_id": "...", + "concentrator_name": "Adamant", + "project_id": "...", + "project_name": "ADAMANT" + } + ], + "pagination": { + "page": 1, + "pageSize": 100, + "total": 206, + "totalPages": 3 + } +} +``` + +--- + +## Próximos Pasos Sugeridos + +1. **Resolver pantalla blanca** - Obtener el error específico de la consola del navegador +2. **Probar carga masiva** - Una vez que las páginas funcionen +3. **Integración TTS** - Webhooks para The Things Stack (pendiente) +4. **Despliegue** - Configurar para producción + +--- + +## Contacto del Plan Original + +El plan completo de migración está en: +``` +/home/GRH/.claude/plans/peaceful-napping-bumblebee.md +``` diff --git a/src/api/client.ts b/src/api/client.ts index be9c697..17cac2d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -230,6 +230,13 @@ async function parseResponse(response: Response): Promise { data.error?.errors ); } + // 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; } } diff --git a/src/api/concentrators.ts b/src/api/concentrators.ts index 2660787..1acb39e 100644 --- a/src/api/concentrators.ts +++ b/src/api/concentrators.ts @@ -74,8 +74,15 @@ export interface ConcentratorInput { */ export async function fetchConcentrators(projectId?: string): Promise { const params = projectId ? { project_id: projectId } : undefined; - const response = await apiClient.get[]>('/api/concentrators', { params }); - return transformArray(response); + const response = await apiClient.get<{ data: Record[]; pagination?: unknown } | Record[]>('/api/concentrators', { params }); + + // Handle paginated response + if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) { + return transformArray(response.data); + } + + // Handle array response (fallback) + return transformArray(response as Record[]); } /** diff --git a/src/api/meters.ts b/src/api/meters.ts index a972bc4..9f4d706 100644 --- a/src/api/meters.ts +++ b/src/api/meters.ts @@ -88,14 +88,23 @@ export interface MeterReading { * @returns Promise resolving to an array of meters */ export async function fetchMeters(filters?: { concentratorId?: string; projectId?: string }): Promise { - const params: Record = {}; + const params: Record = { + 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[]>('/api/meters', { - params: Object.keys(params).length > 0 ? params : undefined + const response = await apiClient.get<{ data: Record[]; pagination: unknown }>('/api/meters', { + params }); - return transformArray(response); + + // Handle paginated response + if (response && typeof response === 'object' && 'data' in response) { + return transformArray(response.data); + } + + // Fallback for non-paginated response + return transformArray(response as unknown as Record[]); } /** @@ -192,13 +201,19 @@ export interface BulkUploadResult { * @returns Promise resolving to upload result */ export async function bulkUploadMeters(file: File): Promise { + const token = localStorage.getItem('grh_access_token'); + + if (!token) { + throw new Error('No hay sesión activa. Por favor inicia sesión nuevamente.'); + } + const formData = new FormData(); formData.append('file', file); const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/meters`, { method: 'POST', headers: { - 'Authorization': `Bearer ${localStorage.getItem('accessToken')}`, + 'Authorization': `Bearer ${token}`, }, body: formData, }); @@ -215,15 +230,27 @@ export async function bulkUploadMeters(file: File): Promise { * Download meter template Excel file */ export async function downloadMeterTemplate(): Promise { + 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/meters/template`, { method: 'GET', headers: { - 'Authorization': `Bearer ${localStorage.getItem('accessToken')}`, + 'Authorization': `Bearer ${token}`, }, }); if (!response.ok) { - throw new Error('Error descargando la plantilla'); + // 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}`); + } + throw new Error(`Error ${response.status}: ${response.statusText}`); } const blob = await response.blob(); diff --git a/src/api/projects.ts b/src/api/projects.ts index ae838ac..49bb5d8 100644 --- a/src/api/projects.ts +++ b/src/api/projects.ts @@ -61,8 +61,15 @@ export interface ProjectInput { * @returns Promise resolving to an array of projects */ export async function fetchProjects(): Promise { - const response = await apiClient.get[]>('/api/projects'); - return transformArray(response); + const response = await apiClient.get<{ data: Record[]; pagination?: unknown } | Record[]>('/api/projects'); + + // Handle paginated response + if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) { + return transformArray(response.data); + } + + // Handle array response (fallback) + return transformArray(response as Record[]); } /** diff --git a/src/api/readings.ts b/src/api/readings.ts index 93710ea..d09026b 100644 --- a/src/api/readings.ts +++ b/src/api/readings.ts @@ -36,7 +36,6 @@ function transformArray(arr: Record[]): T[] { export interface MeterReading { id: string; meterId: string; - deviceId: string | null; readingValue: number; readingType: string; batteryLevel: number | null; @@ -47,8 +46,11 @@ export interface MeterReading { // From join with meters meterSerialNumber: string; meterName: string; - areaName: string | null; + meterLocation: string | null; + concentratorId: string; + concentratorName: string; projectId: string; + projectName: string; } /** @@ -85,7 +87,7 @@ export interface PaginatedResponse { export interface ReadingFilters { meterId?: string; projectId?: string; - areaName?: string; + concentratorId?: string; startDate?: string; endDate?: string; readingType?: string; @@ -103,7 +105,7 @@ export async function fetchReadings(filters?: ReadingFilters): Promise { const backendData = { meter_id: data.meterId, - device_id: data.deviceId, reading_value: data.readingValue, reading_type: data.readingType, battery_level: data.batteryLevel, @@ -184,3 +184,88 @@ export async function createReading(data: ReadingInput): Promise { export async function deleteReading(id: string): Promise { return apiClient.delete(`/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; + }>; + }; +} + +/** + * 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 { + 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 { + 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); +} diff --git a/src/pages/consumption/ConsumptionPage.tsx b/src/pages/consumption/ConsumptionPage.tsx index c622859..53a836e 100644 --- a/src/pages/consumption/ConsumptionPage.tsx +++ b/src/pages/consumption/ConsumptionPage.tsx @@ -12,6 +12,7 @@ import { Filter, X, Activity, + Upload, } from "lucide-react"; import { fetchReadings, @@ -21,6 +22,7 @@ import { type Pagination, } from "../../api/readings"; import { fetchProjects, type Project } from "../../api/projects"; +import ReadingsBulkUploadModal from "./ReadingsBulkUploadModal"; export default function ConsumptionPage() { const [readings, setReadings] = useState([]); @@ -41,6 +43,7 @@ export default function ConsumptionPage() { const [endDate, setEndDate] = useState(""); const [search, setSearch] = useState(""); const [showFilters, setShowFilters] = useState(false); + const [showBulkUpload, setShowBulkUpload] = useState(false); useEffect(() => { const loadProjects = async () => { @@ -92,7 +95,7 @@ export default function ConsumptionPage() { (r) => (r.meterSerialNumber ?? "").toLowerCase().includes(q) || (r.meterName ?? "").toLowerCase().includes(q) || - (r.areaName ?? "").toLowerCase().includes(q) || + (r.meterLocation ?? "").toLowerCase().includes(q) || String(r.readingValue).includes(q) ); }, [readings, search]); @@ -121,12 +124,12 @@ export default function ConsumptionPage() { }; const exportToCSV = () => { - const headers = ["Fecha", "Medidor", "Serial", "Área", "Valor", "Tipo", "Batería", "Señal"]; + 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.areaName || "—", + r.meterLocation || "—", r.readingValue.toFixed(2), r.readingType || "—", r.batteryLevel !== null ? `${r.batteryLevel}%` : "—", @@ -162,6 +165,13 @@ export default function ConsumptionPage() {
+ Arrastra un archivo Excel aquí o +
{error}
Total de filas: {result.data.totalRows}
Insertadas: {result.data.inserted}
Fallidas: {result.data.failed}