Agregar carga masiva de lecturas y corregir manejo de respuestas paginadas
- Implementar carga masiva de lecturas via Excel (backend y frontend) - Corregir cliente API para manejar respuestas con paginación - Eliminar referencias a device_id (columna inexistente) - Cambiar areaName por meterLocation en lecturas - Actualizar fetchProjects y fetchConcentrators para paginación - Agregar documentación del estado actual y cambios Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
131
CAMBIOS_SESION.md
Normal file
131
CAMBIOS_SESION.md
Normal file
@@ -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<Record<string, unknown>[]>('/api/projects');
|
||||||
|
return transformArray<Project>(response);
|
||||||
|
|
||||||
|
// DESPUÉS:
|
||||||
|
const response = await apiClient.get<...>('/api/projects');
|
||||||
|
if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) {
|
||||||
|
return transformArray<Project>(response.data);
|
||||||
|
}
|
||||||
|
return transformArray<Project>(response as Record<string, unknown>[]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
254
ESTADO_ACTUAL.md
Normal file
254
ESTADO_ACTUAL.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
@@ -230,6 +230,13 @@ async function parseResponse<T>(response: Response): Promise<T> {
|
|||||||
data.error?.errors
|
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;
|
return data.data as T;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,8 +74,15 @@ export interface ConcentratorInput {
|
|||||||
*/
|
*/
|
||||||
export async function fetchConcentrators(projectId?: string): Promise<Concentrator[]> {
|
export async function fetchConcentrators(projectId?: string): Promise<Concentrator[]> {
|
||||||
const params = projectId ? { project_id: projectId } : undefined;
|
const params = projectId ? { project_id: projectId } : undefined;
|
||||||
const response = await apiClient.get<Record<string, unknown>[]>('/api/concentrators', { params });
|
const response = await apiClient.get<{ data: Record<string, unknown>[]; pagination?: unknown } | Record<string, unknown>[]>('/api/concentrators', { params });
|
||||||
return transformArray<Concentrator>(response);
|
|
||||||
|
// Handle paginated response
|
||||||
|
if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) {
|
||||||
|
return transformArray<Concentrator>(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle array response (fallback)
|
||||||
|
return transformArray<Concentrator>(response as Record<string, unknown>[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -88,14 +88,23 @@ export interface MeterReading {
|
|||||||
* @returns Promise resolving to an array of meters
|
* @returns Promise resolving to an array of meters
|
||||||
*/
|
*/
|
||||||
export async function fetchMeters(filters?: { concentratorId?: string; projectId?: string }): Promise<Meter[]> {
|
export async function fetchMeters(filters?: { concentratorId?: string; projectId?: string }): Promise<Meter[]> {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {
|
||||||
|
pageSize: '1000', // Request up to 1000 meters
|
||||||
|
};
|
||||||
if (filters?.concentratorId) params.concentrator_id = filters.concentratorId;
|
if (filters?.concentratorId) params.concentrator_id = filters.concentratorId;
|
||||||
if (filters?.projectId) params.project_id = filters.projectId;
|
if (filters?.projectId) params.project_id = filters.projectId;
|
||||||
|
|
||||||
const response = await apiClient.get<Record<string, unknown>[]>('/api/meters', {
|
const response = await apiClient.get<{ data: Record<string, unknown>[]; pagination: unknown }>('/api/meters', {
|
||||||
params: Object.keys(params).length > 0 ? params : undefined
|
params
|
||||||
});
|
});
|
||||||
return transformArray<Meter>(response);
|
|
||||||
|
// 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>[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -192,13 +201,19 @@ export interface BulkUploadResult {
|
|||||||
* @returns Promise resolving to upload result
|
* @returns Promise resolving to upload result
|
||||||
*/
|
*/
|
||||||
export async function bulkUploadMeters(file: File): Promise<BulkUploadResult> {
|
export async function bulkUploadMeters(file: File): Promise<BulkUploadResult> {
|
||||||
|
const token = localStorage.getItem('grh_access_token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('No hay sesión activa. Por favor inicia sesión nuevamente.');
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/meters`, {
|
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/meters`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -215,15 +230,27 @@ export async function bulkUploadMeters(file: File): Promise<BulkUploadResult> {
|
|||||||
* Download meter template Excel file
|
* Download meter template Excel file
|
||||||
*/
|
*/
|
||||||
export async function downloadMeterTemplate(): Promise<void> {
|
export async function downloadMeterTemplate(): 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/meters/template`, {
|
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/bulk-upload/meters/template`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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();
|
const blob = await response.blob();
|
||||||
|
|||||||
@@ -61,8 +61,15 @@ export interface ProjectInput {
|
|||||||
* @returns Promise resolving to an array of projects
|
* @returns Promise resolving to an array of projects
|
||||||
*/
|
*/
|
||||||
export async function fetchProjects(): Promise<Project[]> {
|
export async function fetchProjects(): Promise<Project[]> {
|
||||||
const response = await apiClient.get<Record<string, unknown>[]>('/api/projects');
|
const response = await apiClient.get<{ data: Record<string, unknown>[]; pagination?: unknown } | Record<string, unknown>[]>('/api/projects');
|
||||||
return transformArray<Project>(response);
|
|
||||||
|
// Handle paginated response
|
||||||
|
if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) {
|
||||||
|
return transformArray<Project>(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle array response (fallback)
|
||||||
|
return transformArray<Project>(response as Record<string, unknown>[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ function transformArray<T>(arr: Record<string, unknown>[]): T[] {
|
|||||||
export interface MeterReading {
|
export interface MeterReading {
|
||||||
id: string;
|
id: string;
|
||||||
meterId: string;
|
meterId: string;
|
||||||
deviceId: string | null;
|
|
||||||
readingValue: number;
|
readingValue: number;
|
||||||
readingType: string;
|
readingType: string;
|
||||||
batteryLevel: number | null;
|
batteryLevel: number | null;
|
||||||
@@ -47,8 +46,11 @@ export interface MeterReading {
|
|||||||
// From join with meters
|
// From join with meters
|
||||||
meterSerialNumber: string;
|
meterSerialNumber: string;
|
||||||
meterName: string;
|
meterName: string;
|
||||||
areaName: string | null;
|
meterLocation: string | null;
|
||||||
|
concentratorId: string;
|
||||||
|
concentratorName: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,7 +87,7 @@ export interface PaginatedResponse<T> {
|
|||||||
export interface ReadingFilters {
|
export interface ReadingFilters {
|
||||||
meterId?: string;
|
meterId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
areaName?: string;
|
concentratorId?: string;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
readingType?: string;
|
readingType?: string;
|
||||||
@@ -103,7 +105,7 @@ export async function fetchReadings(filters?: ReadingFilters): Promise<Paginated
|
|||||||
|
|
||||||
if (filters?.meterId) params.meter_id = filters.meterId;
|
if (filters?.meterId) params.meter_id = filters.meterId;
|
||||||
if (filters?.projectId) params.project_id = filters.projectId;
|
if (filters?.projectId) params.project_id = filters.projectId;
|
||||||
if (filters?.areaName) params.area_name = filters.areaName;
|
if (filters?.concentratorId) params.concentrator_id = filters.concentratorId;
|
||||||
if (filters?.startDate) params.start_date = filters.startDate;
|
if (filters?.startDate) params.start_date = filters.startDate;
|
||||||
if (filters?.endDate) params.end_date = filters.endDate;
|
if (filters?.endDate) params.end_date = filters.endDate;
|
||||||
if (filters?.readingType) params.reading_type = filters.readingType;
|
if (filters?.readingType) params.reading_type = filters.readingType;
|
||||||
@@ -147,7 +149,6 @@ export async function fetchConsumptionSummary(projectId?: string): Promise<Consu
|
|||||||
*/
|
*/
|
||||||
export interface ReadingInput {
|
export interface ReadingInput {
|
||||||
meterId: string;
|
meterId: string;
|
||||||
deviceId?: string;
|
|
||||||
readingValue: number;
|
readingValue: number;
|
||||||
readingType?: string;
|
readingType?: string;
|
||||||
batteryLevel?: number;
|
batteryLevel?: number;
|
||||||
@@ -164,7 +165,6 @@ export interface ReadingInput {
|
|||||||
export async function createReading(data: ReadingInput): Promise<MeterReading> {
|
export async function createReading(data: ReadingInput): Promise<MeterReading> {
|
||||||
const backendData = {
|
const backendData = {
|
||||||
meter_id: data.meterId,
|
meter_id: data.meterId,
|
||||||
device_id: data.deviceId,
|
|
||||||
reading_value: data.readingValue,
|
reading_value: data.readingValue,
|
||||||
reading_type: data.readingType,
|
reading_type: data.readingType,
|
||||||
battery_level: data.batteryLevel,
|
battery_level: data.batteryLevel,
|
||||||
@@ -184,3 +184,88 @@ export async function createReading(data: ReadingInput): Promise<MeterReading> {
|
|||||||
export async function deleteReading(id: string): Promise<void> {
|
export async function deleteReading(id: string): Promise<void> {
|
||||||
return apiClient.delete<void>(`/api/readings/${id}`);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
X,
|
X,
|
||||||
Activity,
|
Activity,
|
||||||
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
fetchReadings,
|
fetchReadings,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
type Pagination,
|
type Pagination,
|
||||||
} from "../../api/readings";
|
} from "../../api/readings";
|
||||||
import { fetchProjects, type Project } from "../../api/projects";
|
import { fetchProjects, type Project } from "../../api/projects";
|
||||||
|
import ReadingsBulkUploadModal from "./ReadingsBulkUploadModal";
|
||||||
|
|
||||||
export default function ConsumptionPage() {
|
export default function ConsumptionPage() {
|
||||||
const [readings, setReadings] = useState<MeterReading[]>([]);
|
const [readings, setReadings] = useState<MeterReading[]>([]);
|
||||||
@@ -41,6 +43,7 @@ export default function ConsumptionPage() {
|
|||||||
const [endDate, setEndDate] = useState<string>("");
|
const [endDate, setEndDate] = useState<string>("");
|
||||||
const [search, setSearch] = useState<string>("");
|
const [search, setSearch] = useState<string>("");
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [showBulkUpload, setShowBulkUpload] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadProjects = async () => {
|
const loadProjects = async () => {
|
||||||
@@ -92,7 +95,7 @@ export default function ConsumptionPage() {
|
|||||||
(r) =>
|
(r) =>
|
||||||
(r.meterSerialNumber ?? "").toLowerCase().includes(q) ||
|
(r.meterSerialNumber ?? "").toLowerCase().includes(q) ||
|
||||||
(r.meterName ?? "").toLowerCase().includes(q) ||
|
(r.meterName ?? "").toLowerCase().includes(q) ||
|
||||||
(r.areaName ?? "").toLowerCase().includes(q) ||
|
(r.meterLocation ?? "").toLowerCase().includes(q) ||
|
||||||
String(r.readingValue).includes(q)
|
String(r.readingValue).includes(q)
|
||||||
);
|
);
|
||||||
}, [readings, search]);
|
}, [readings, search]);
|
||||||
@@ -121,12 +124,12 @@ export default function ConsumptionPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const exportToCSV = () => {
|
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) => [
|
const rows = filteredReadings.map((r) => [
|
||||||
formatFullDate(r.receivedAt),
|
formatFullDate(r.receivedAt),
|
||||||
r.meterName || "—",
|
r.meterName || "—",
|
||||||
r.meterSerialNumber || "—",
|
r.meterSerialNumber || "—",
|
||||||
r.areaName || "—",
|
r.meterLocation || "—",
|
||||||
r.readingValue.toFixed(2),
|
r.readingValue.toFixed(2),
|
||||||
r.readingType || "—",
|
r.readingType || "—",
|
||||||
r.batteryLevel !== null ? `${r.batteryLevel}%` : "—",
|
r.batteryLevel !== null ? `${r.batteryLevel}%` : "—",
|
||||||
@@ -162,6 +165,13 @@ export default function ConsumptionPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<button
|
||||||
onClick={() => loadData(pagination.page)}
|
onClick={() => loadData(pagination.page)}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 hover:border-slate-300 transition-all shadow-sm"
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 hover:border-slate-300 transition-all shadow-sm"
|
||||||
@@ -353,7 +363,7 @@ export default function ConsumptionPage() {
|
|||||||
Serial
|
Serial
|
||||||
</th>
|
</th>
|
||||||
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||||
Área
|
Ubicación
|
||||||
</th>
|
</th>
|
||||||
<th className="px-5 py-3 text-right text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
<th className="px-5 py-3 text-right text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||||
Consumo
|
Consumo
|
||||||
@@ -415,7 +425,7 @@ export default function ConsumptionPage() {
|
|||||||
</code>
|
</code>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3.5">
|
<td className="px-5 py-3.5">
|
||||||
<span className="text-sm text-slate-600">{reading.areaName || "—"}</span>
|
<span className="text-sm text-slate-600">{reading.meterLocation || "—"}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3.5 text-right">
|
<td className="px-5 py-3.5 text-right">
|
||||||
<span className="text-sm font-semibold text-slate-800 tabular-nums">
|
<span className="text-sm font-semibold text-slate-800 tabular-nums">
|
||||||
@@ -440,6 +450,16 @@ export default function ConsumptionPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showBulkUpload && (
|
||||||
|
<ReadingsBulkUploadModal
|
||||||
|
onClose={() => setShowBulkUpload(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
loadData(1);
|
||||||
|
setShowBulkUpload(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { bulkUploadMeters, generateMeterTemplate } from '../services/bulk-upload.service';
|
import {
|
||||||
|
bulkUploadMeters,
|
||||||
|
generateMeterTemplate,
|
||||||
|
bulkUploadReadings,
|
||||||
|
generateReadingTemplate,
|
||||||
|
} from '../services/bulk-upload.service';
|
||||||
|
|
||||||
// Configure multer for memory storage
|
// Configure multer for memory storage
|
||||||
const storage = multer.memoryStorage();
|
const storage = multer.memoryStorage();
|
||||||
@@ -11,13 +16,17 @@ export const upload = multer({
|
|||||||
fileSize: 10 * 1024 * 1024, // 10MB max
|
fileSize: 10 * 1024 * 1024, // 10MB max
|
||||||
},
|
},
|
||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
// Accept Excel files only
|
// Accept Excel files only - check both MIME type and extension
|
||||||
const allowedMimes = [
|
const allowedMimes = [
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
|
||||||
'application/vnd.ms-excel', // .xls
|
'application/vnd.ms-excel', // .xls
|
||||||
|
'application/octet-stream', // Generic binary (some systems send this)
|
||||||
];
|
];
|
||||||
|
|
||||||
if (allowedMimes.includes(file.mimetype)) {
|
const allowedExtensions = ['.xlsx', '.xls'];
|
||||||
|
const fileExtension = file.originalname.toLowerCase().slice(file.originalname.lastIndexOf('.'));
|
||||||
|
|
||||||
|
if (allowedMimes.includes(file.mimetype) || allowedExtensions.includes(fileExtension)) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(new Error('Solo se permiten archivos Excel (.xlsx, .xls)'));
|
cb(new Error('Solo se permiten archivos Excel (.xlsx, .xls)'));
|
||||||
@@ -80,3 +89,59 @@ export async function downloadMeterTemplate(_req: Request, res: Response): Promi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/bulk-upload/readings
|
||||||
|
* Upload Excel file with readings data
|
||||||
|
*/
|
||||||
|
export async function uploadReadings(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'No se proporcionó ningún archivo',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await bulkUploadReadings(req.file.buffer);
|
||||||
|
|
||||||
|
res.status(result.success ? 200 : 207).json({
|
||||||
|
success: result.success,
|
||||||
|
data: {
|
||||||
|
totalRows: result.totalRows,
|
||||||
|
inserted: result.inserted,
|
||||||
|
failed: result.errors.length,
|
||||||
|
errors: result.errors.slice(0, 50), // Limit errors in response
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error;
|
||||||
|
console.error('Error in readings bulk upload:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Error procesando la carga masiva de lecturas',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/bulk-upload/readings/template
|
||||||
|
* Download Excel template for readings
|
||||||
|
*/
|
||||||
|
export async function downloadReadingTemplate(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const buffer = generateReadingTemplate();
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename=plantilla_lecturas.xlsx');
|
||||||
|
res.send(buffer);
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error;
|
||||||
|
console.error('Error generating readings template:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Error generando la plantilla de lecturas',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import * as readingService from '../services/reading.service';
|
|||||||
export async function getAll(req: Request, res: Response): Promise<void> {
|
export async function getAll(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const page = parseInt(req.query.page as string, 10) || 1;
|
const page = parseInt(req.query.page as string, 10) || 1;
|
||||||
const pageSize = Math.min(parseInt(req.query.pageSize as string, 10) || 10, 100);
|
const pageSize = Math.min(parseInt(req.query.pageSize as string, 10) || 50, 1000);
|
||||||
|
|
||||||
const filters: meterService.MeterFilters = {};
|
const filters: meterService.MeterFilters = {};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { uploadMeters, downloadMeterTemplate, upload } from '../controllers/bulk-upload.controller';
|
import {
|
||||||
|
uploadMeters,
|
||||||
|
downloadMeterTemplate,
|
||||||
|
uploadReadings,
|
||||||
|
downloadReadingTemplate,
|
||||||
|
upload,
|
||||||
|
} from '../controllers/bulk-upload.controller';
|
||||||
import { authenticateToken } from '../middleware/auth.middleware';
|
import { authenticateToken } from '../middleware/auth.middleware';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -19,4 +25,16 @@ router.post('/meters', upload.single('file'), uploadMeters);
|
|||||||
*/
|
*/
|
||||||
router.get('/meters/template', downloadMeterTemplate);
|
router.get('/meters/template', downloadMeterTemplate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/bulk-upload/readings
|
||||||
|
* Upload Excel file with readings data
|
||||||
|
*/
|
||||||
|
router.post('/readings', upload.single('file'), uploadReadings);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/bulk-upload/readings/template
|
||||||
|
* Download Excel template for readings
|
||||||
|
*/
|
||||||
|
router.get('/readings/template', downloadReadingTemplate);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -315,3 +315,294 @@ export function generateMeterTemplate(): Buffer {
|
|||||||
|
|
||||||
return Buffer.from(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }));
|
return Buffer.from(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expected columns in the Excel file for readings
|
||||||
|
*/
|
||||||
|
interface ReadingRow {
|
||||||
|
meter_serial: string;
|
||||||
|
reading_value: number;
|
||||||
|
reading_type?: string;
|
||||||
|
received_at?: string;
|
||||||
|
battery_level?: number;
|
||||||
|
signal_strength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize column name for readings
|
||||||
|
*/
|
||||||
|
function normalizeReadingColumnName(name: string): string {
|
||||||
|
const normalized = name
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.replace(/[áàäâ]/g, 'a')
|
||||||
|
.replace(/[éèëê]/g, 'e')
|
||||||
|
.replace(/[íìïî]/g, 'i')
|
||||||
|
.replace(/[óòöô]/g, 'o')
|
||||||
|
.replace(/[úùüû]/g, 'u')
|
||||||
|
.replace(/ñ/g, 'n');
|
||||||
|
|
||||||
|
const mappings: Record<string, string> = {
|
||||||
|
// Meter serial
|
||||||
|
'serial': 'meter_serial',
|
||||||
|
'serial_number': 'meter_serial',
|
||||||
|
'meter_serial': 'meter_serial',
|
||||||
|
'numero_de_serie': 'meter_serial',
|
||||||
|
'serial_medidor': 'meter_serial',
|
||||||
|
'medidor': 'meter_serial',
|
||||||
|
// Reading value
|
||||||
|
'valor': 'reading_value',
|
||||||
|
'value': 'reading_value',
|
||||||
|
'reading_value': 'reading_value',
|
||||||
|
'lectura': 'reading_value',
|
||||||
|
'consumo': 'reading_value',
|
||||||
|
// Reading type
|
||||||
|
'tipo': 'reading_type',
|
||||||
|
'type': 'reading_type',
|
||||||
|
'reading_type': 'reading_type',
|
||||||
|
'tipo_lectura': 'reading_type',
|
||||||
|
// Received at
|
||||||
|
'fecha': 'received_at',
|
||||||
|
'date': 'received_at',
|
||||||
|
'received_at': 'received_at',
|
||||||
|
'fecha_lectura': 'received_at',
|
||||||
|
'fecha_hora': 'received_at',
|
||||||
|
// Battery
|
||||||
|
'bateria': 'battery_level',
|
||||||
|
'battery': 'battery_level',
|
||||||
|
'battery_level': 'battery_level',
|
||||||
|
'nivel_bateria': 'battery_level',
|
||||||
|
// Signal
|
||||||
|
'senal': 'signal_strength',
|
||||||
|
'signal': 'signal_strength',
|
||||||
|
'signal_strength': 'signal_strength',
|
||||||
|
'intensidad_senal': 'signal_strength',
|
||||||
|
};
|
||||||
|
|
||||||
|
return mappings[normalized] || normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize row for readings
|
||||||
|
*/
|
||||||
|
function normalizeReadingRow(row: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const normalized: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(row)) {
|
||||||
|
const normalizedKey = normalizeReadingColumnName(key);
|
||||||
|
normalized[normalizedKey] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a reading row
|
||||||
|
*/
|
||||||
|
function validateReadingRow(row: Record<string, unknown>, rowIndex: number): { valid: boolean; error?: string } {
|
||||||
|
if (!row.meter_serial || String(row.meter_serial).trim() === '') {
|
||||||
|
return { valid: false, error: `Fila ${rowIndex}: meter_serial es requerido` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.reading_value === null || row.reading_value === undefined || row.reading_value === '') {
|
||||||
|
return { valid: false, error: `Fila ${rowIndex}: reading_value es requerido` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = parseFloat(String(row.reading_value));
|
||||||
|
if (isNaN(value)) {
|
||||||
|
return { valid: false, error: `Fila ${rowIndex}: reading_value debe ser un número` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk upload readings from Excel buffer
|
||||||
|
*/
|
||||||
|
export async function bulkUploadReadings(buffer: Buffer): Promise<BulkUploadResult> {
|
||||||
|
const result: BulkUploadResult = {
|
||||||
|
success: true,
|
||||||
|
totalRows: 0,
|
||||||
|
inserted: 0,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse Excel file
|
||||||
|
const rawRows = parseExcelBuffer(buffer);
|
||||||
|
result.totalRows = rawRows.length;
|
||||||
|
|
||||||
|
if (rawRows.length === 0) {
|
||||||
|
result.success = false;
|
||||||
|
result.errors.push({ row: 0, error: 'El archivo está vacío o no tiene datos válidos' });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize column names
|
||||||
|
const rows = rawRows.map(row => normalizeReadingRow(row));
|
||||||
|
|
||||||
|
// Get all meters for lookup by serial number
|
||||||
|
const metersResult = await query<{ id: string; serial_number: string }>(
|
||||||
|
'SELECT id, serial_number FROM meters'
|
||||||
|
);
|
||||||
|
const meterMap = new Map(
|
||||||
|
metersResult.rows.map(m => [m.serial_number.toLowerCase(), m.id])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process each row
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const row = rows[i];
|
||||||
|
const rowIndex = i + 2; // Excel row number (1-indexed + header row)
|
||||||
|
|
||||||
|
// Validate row
|
||||||
|
const validation = validateReadingRow(row, rowIndex);
|
||||||
|
if (!validation.valid) {
|
||||||
|
result.errors.push({ row: rowIndex, error: validation.error!, data: row });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up meter
|
||||||
|
const meterSerial = String(row.meter_serial).trim().toLowerCase();
|
||||||
|
const meterId = meterMap.get(meterSerial);
|
||||||
|
|
||||||
|
if (!meterId) {
|
||||||
|
result.errors.push({
|
||||||
|
row: rowIndex,
|
||||||
|
error: `Medidor con serial "${row.meter_serial}" no encontrado`,
|
||||||
|
data: row,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare reading data
|
||||||
|
const readingValue = parseFloat(String(row.reading_value));
|
||||||
|
const readingType = row.reading_type ? String(row.reading_type).trim().toUpperCase() : 'MANUAL';
|
||||||
|
const receivedAt = row.received_at ? String(row.received_at).trim() : null;
|
||||||
|
|
||||||
|
// Parse battery level - handle NaN and invalid values
|
||||||
|
let batteryLevel: number | null = null;
|
||||||
|
if (row.battery_level !== undefined && row.battery_level !== null && row.battery_level !== '') {
|
||||||
|
const parsed = parseFloat(String(row.battery_level));
|
||||||
|
if (!isNaN(parsed) && isFinite(parsed)) {
|
||||||
|
batteryLevel = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse signal strength - handle NaN and invalid values
|
||||||
|
let signalStrength: number | null = null;
|
||||||
|
if (row.signal_strength !== undefined && row.signal_strength !== null && row.signal_strength !== '') {
|
||||||
|
const parsed = parseFloat(String(row.signal_strength));
|
||||||
|
if (!isNaN(parsed) && isFinite(parsed)) {
|
||||||
|
signalStrength = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reading type
|
||||||
|
const validTypes = ['AUTOMATIC', 'MANUAL', 'SCHEDULED'];
|
||||||
|
const finalReadingType = validTypes.includes(readingType) ? readingType : 'MANUAL';
|
||||||
|
|
||||||
|
// Insert reading
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO meter_readings (meter_id, reading_value, reading_type, battery_level, signal_strength, received_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, COALESCE($6::timestamp, NOW()))`,
|
||||||
|
[
|
||||||
|
meterId,
|
||||||
|
readingValue,
|
||||||
|
finalReadingType,
|
||||||
|
batteryLevel,
|
||||||
|
signalStrength,
|
||||||
|
receivedAt,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update meter's last reading
|
||||||
|
await query(
|
||||||
|
`UPDATE meters
|
||||||
|
SET last_reading_value = $1, last_reading_at = COALESCE($2::timestamp, NOW()), updated_at = NOW()
|
||||||
|
WHERE id = $3`,
|
||||||
|
[readingValue, receivedAt, meterId]
|
||||||
|
);
|
||||||
|
|
||||||
|
result.inserted++;
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error & { code?: string; detail?: string };
|
||||||
|
result.errors.push({
|
||||||
|
row: rowIndex,
|
||||||
|
error: error.message,
|
||||||
|
data: row,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success = result.errors.length === 0;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error;
|
||||||
|
result.success = false;
|
||||||
|
result.errors.push({ row: 0, error: `Error procesando archivo: ${error.message}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Excel template for readings
|
||||||
|
*/
|
||||||
|
export function generateReadingTemplate(): Buffer {
|
||||||
|
const templateData = [
|
||||||
|
{
|
||||||
|
meter_serial: '24151158',
|
||||||
|
reading_value: 123.45,
|
||||||
|
reading_type: 'MANUAL',
|
||||||
|
received_at: '2024-01-15 10:30:00',
|
||||||
|
battery_level: 85,
|
||||||
|
signal_strength: -70,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
meter_serial: '24151159',
|
||||||
|
reading_value: 456.78,
|
||||||
|
reading_type: 'MANUAL',
|
||||||
|
received_at: '2024-01-15 10:35:00',
|
||||||
|
battery_level: 90,
|
||||||
|
signal_strength: -65,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(templateData);
|
||||||
|
|
||||||
|
// Set column widths
|
||||||
|
worksheet['!cols'] = [
|
||||||
|
{ wch: 15 }, // meter_serial
|
||||||
|
{ wch: 15 }, // reading_value
|
||||||
|
{ wch: 12 }, // reading_type
|
||||||
|
{ wch: 20 }, // received_at
|
||||||
|
{ wch: 15 }, // battery_level
|
||||||
|
{ wch: 15 }, // signal_strength
|
||||||
|
];
|
||||||
|
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, 'Lecturas');
|
||||||
|
|
||||||
|
// Add instructions sheet
|
||||||
|
const instructionsData = [
|
||||||
|
{ Campo: 'meter_serial', Descripcion: 'Número de serie del medidor (REQUERIDO)', Ejemplo: '24151158' },
|
||||||
|
{ Campo: 'reading_value', Descripcion: 'Valor de la lectura en m³ (REQUERIDO)', Ejemplo: '123.45' },
|
||||||
|
{ Campo: 'reading_type', Descripcion: 'Tipo: AUTOMATIC, MANUAL, SCHEDULED (opcional, default: MANUAL)', Ejemplo: 'MANUAL' },
|
||||||
|
{ Campo: 'received_at', Descripcion: 'Fecha y hora de la lectura YYYY-MM-DD HH:MM:SS (opcional, default: ahora)', Ejemplo: '2024-01-15 10:30:00' },
|
||||||
|
{ Campo: 'battery_level', Descripcion: 'Nivel de batería en % (opcional)', Ejemplo: '85' },
|
||||||
|
{ Campo: 'signal_strength', Descripcion: 'Intensidad de señal en dBm (opcional)', Ejemplo: '-70' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const instructionsSheet = XLSX.utils.json_to_sheet(instructionsData);
|
||||||
|
instructionsSheet['!cols'] = [
|
||||||
|
{ wch: 20 },
|
||||||
|
{ wch: 60 },
|
||||||
|
{ wch: 25 },
|
||||||
|
];
|
||||||
|
|
||||||
|
XLSX.utils.book_append_sheet(workbook, instructionsSheet, 'Instrucciones');
|
||||||
|
|
||||||
|
return Buffer.from(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }));
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { query } from '../config/database';
|
|||||||
export interface MeterReading {
|
export interface MeterReading {
|
||||||
id: string;
|
id: string;
|
||||||
meter_id: string;
|
meter_id: string;
|
||||||
device_id: string | null;
|
|
||||||
reading_value: number;
|
reading_value: number;
|
||||||
reading_type: string;
|
reading_type: string;
|
||||||
battery_level: number | null;
|
battery_level: number | null;
|
||||||
@@ -67,7 +66,6 @@ export interface PaginatedResult<T> {
|
|||||||
*/
|
*/
|
||||||
export interface CreateReadingInput {
|
export interface CreateReadingInput {
|
||||||
meter_id: string;
|
meter_id: string;
|
||||||
device_id?: string;
|
|
||||||
reading_value: number;
|
reading_value: number;
|
||||||
reading_type?: string;
|
reading_type?: string;
|
||||||
battery_level?: number;
|
battery_level?: number;
|
||||||
@@ -147,7 +145,7 @@ export async function getAll(
|
|||||||
// Get paginated data with meter, concentrator, and project info
|
// Get paginated data with meter, concentrator, and project info
|
||||||
const dataQuery = `
|
const dataQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
mr.id, mr.meter_id, mr.device_id, mr.reading_value, mr.reading_type,
|
mr.id, mr.meter_id, mr.reading_value, mr.reading_type,
|
||||||
mr.battery_level, mr.signal_strength, mr.raw_payload, mr.received_at, mr.created_at,
|
mr.battery_level, mr.signal_strength, mr.raw_payload, mr.received_at, mr.created_at,
|
||||||
m.serial_number as meter_serial_number, m.name as meter_name, m.location as meter_location,
|
m.serial_number as meter_serial_number, m.name as meter_name, m.location as meter_location,
|
||||||
m.concentrator_id, c.name as concentrator_name,
|
m.concentrator_id, c.name as concentrator_name,
|
||||||
@@ -183,7 +181,7 @@ export async function getAll(
|
|||||||
export async function getById(id: string): Promise<MeterReadingWithMeter | null> {
|
export async function getById(id: string): Promise<MeterReadingWithMeter | null> {
|
||||||
const result = await query<MeterReadingWithMeter>(
|
const result = await query<MeterReadingWithMeter>(
|
||||||
`SELECT
|
`SELECT
|
||||||
mr.id, mr.meter_id, mr.device_id, mr.reading_value, mr.reading_type,
|
mr.id, mr.meter_id, mr.reading_value, mr.reading_type,
|
||||||
mr.battery_level, mr.signal_strength, mr.raw_payload, mr.received_at, mr.created_at,
|
mr.battery_level, mr.signal_strength, mr.raw_payload, mr.received_at, mr.created_at,
|
||||||
m.serial_number as meter_serial_number, m.name as meter_name, m.location as meter_location,
|
m.serial_number as meter_serial_number, m.name as meter_name, m.location as meter_location,
|
||||||
m.concentrator_id, c.name as concentrator_name,
|
m.concentrator_id, c.name as concentrator_name,
|
||||||
@@ -206,14 +204,13 @@ export async function getById(id: string): Promise<MeterReadingWithMeter | null>
|
|||||||
*/
|
*/
|
||||||
export async function create(data: CreateReadingInput): Promise<MeterReading> {
|
export async function create(data: CreateReadingInput): Promise<MeterReading> {
|
||||||
const result = await query<MeterReading>(
|
const result = await query<MeterReading>(
|
||||||
`INSERT INTO meter_readings (meter_id, device_id, reading_value, reading_type,
|
`INSERT INTO meter_readings (meter_id, reading_value, reading_type,
|
||||||
battery_level, signal_strength, raw_payload, received_at)
|
battery_level, signal_strength, raw_payload, received_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, NOW()))
|
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, NOW()))
|
||||||
RETURNING id, meter_id, device_id, reading_value, reading_type,
|
RETURNING id, meter_id, reading_value, reading_type,
|
||||||
battery_level, signal_strength, raw_payload, received_at, created_at`,
|
battery_level, signal_strength, raw_payload, received_at, created_at`,
|
||||||
[
|
[
|
||||||
data.meter_id,
|
data.meter_id,
|
||||||
data.device_id || null,
|
|
||||||
data.reading_value,
|
data.reading_value,
|
||||||
data.reading_type || 'AUTOMATIC',
|
data.reading_type || 'AUTOMATIC',
|
||||||
data.battery_level || null,
|
data.battery_level || null,
|
||||||
|
|||||||
Reference in New Issue
Block a user