Add CSV upload panel for meters and readings
- Add CSV upload service with upsert logic for meters - Add CSV upload routes (POST /csv-upload/meters, POST /csv-upload/readings) - Add template download endpoints for CSV format - Create standalone upload-panel React app on port 5174 - Support concentrator_serial lookup for meter creation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
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"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
146
water-api/src/routes/csv-upload.routes.ts
Normal file
146
water-api/src/routes/csv-upload.routes.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import {
|
||||||
|
uploadMetersCSV,
|
||||||
|
uploadReadingsCSV,
|
||||||
|
generateMeterCSVTemplate,
|
||||||
|
generateReadingCSVTemplate
|
||||||
|
} from '../services/csv-upload.service';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Configure multer for memory storage (we'll process the file content directly)
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: {
|
||||||
|
fileSize: 10 * 1024 * 1024, // 10MB limit
|
||||||
|
},
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
// Accept only CSV files
|
||||||
|
if (file.mimetype === 'text/csv' ||
|
||||||
|
file.originalname.endsWith('.csv') ||
|
||||||
|
file.mimetype === 'application/vnd.ms-excel') {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Solo se permiten archivos CSV'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/csv-upload/meters
|
||||||
|
* Upload CSV file with meters data (upsert logic)
|
||||||
|
*/
|
||||||
|
router.post('/meters', upload.single('file'), async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'No se proporcionó archivo CSV'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvContent = req.file.buffer.toString('utf-8');
|
||||||
|
|
||||||
|
if (!csvContent.trim()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'El archivo CSV está vacío'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await uploadMetersCSV(csvContent);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: `Procesamiento completado: ${result.inserted} insertados, ${result.updated} actualizados, ${result.errors.length} errores`,
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading meters CSV:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : 'Error al procesar el archivo CSV'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/csv-upload/readings
|
||||||
|
* Upload CSV file with readings data
|
||||||
|
*/
|
||||||
|
router.post('/readings', upload.single('file'), async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'No se proporcionó archivo CSV'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvContent = req.file.buffer.toString('utf-8');
|
||||||
|
|
||||||
|
if (!csvContent.trim()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'El archivo CSV está vacío'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await uploadReadingsCSV(csvContent);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: `Procesamiento completado: ${result.inserted} lecturas insertadas, ${result.errors.length} errores`,
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading readings CSV:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : 'Error al procesar el archivo CSV'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/csv-upload/meters/template
|
||||||
|
* Download CSV template for meters upload
|
||||||
|
*/
|
||||||
|
router.get('/meters/template', (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const template = generateMeterCSVTemplate();
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="plantilla_medidores.csv"');
|
||||||
|
return res.send(template);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating meters template:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error al generar la plantilla'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/csv-upload/readings/template
|
||||||
|
* Download CSV template for readings upload
|
||||||
|
*/
|
||||||
|
router.get('/readings/template', (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const template = generateReadingCSVTemplate();
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="plantilla_lecturas.csv"');
|
||||||
|
return res.send(template);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating readings template:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error al generar la plantilla'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -13,6 +13,7 @@ import roleRoutes from './role.routes';
|
|||||||
import ttsRoutes from './tts.routes';
|
import ttsRoutes from './tts.routes';
|
||||||
import readingRoutes from './reading.routes';
|
import readingRoutes from './reading.routes';
|
||||||
import bulkUploadRoutes from './bulk-upload.routes';
|
import bulkUploadRoutes from './bulk-upload.routes';
|
||||||
|
import csvUploadRoutes from './csv-upload.routes';
|
||||||
import auditRoutes from './audit.routes';
|
import auditRoutes from './audit.routes';
|
||||||
import notificationRoutes from './notification.routes';
|
import notificationRoutes from './notification.routes';
|
||||||
import testRoutes from './test.routes';
|
import testRoutes from './test.routes';
|
||||||
@@ -145,6 +146,15 @@ router.use('/readings', readingRoutes);
|
|||||||
*/
|
*/
|
||||||
router.use('/bulk-upload', bulkUploadRoutes);
|
router.use('/bulk-upload', bulkUploadRoutes);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV upload routes (no authentication required):
|
||||||
|
* - POST /csv-upload/meters - Upload CSV file with meters data (upsert)
|
||||||
|
* - POST /csv-upload/readings - Upload CSV file with readings data
|
||||||
|
* - GET /csv-upload/meters/template - Download CSV template for meters
|
||||||
|
* - GET /csv-upload/readings/template - Download CSV template for readings
|
||||||
|
*/
|
||||||
|
router.use('/csv-upload', csvUploadRoutes);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audit routes:
|
* Audit routes:
|
||||||
* - GET /audit-logs - List all audit logs (admin only)
|
* - GET /audit-logs - List all audit logs (admin only)
|
||||||
|
|||||||
523
water-api/src/services/csv-upload.service.ts
Normal file
523
water-api/src/services/csv-upload.service.ts
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
import { query } from '../config/database';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV Upload Service
|
||||||
|
* Handles parsing and processing of CSV files for meters and readings
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== INTERFACES ====================
|
||||||
|
|
||||||
|
export interface CSVMeterRow {
|
||||||
|
serial_number: string;
|
||||||
|
name: string;
|
||||||
|
project_id: string;
|
||||||
|
concentrator_serial: string;
|
||||||
|
area_name: string;
|
||||||
|
location: string;
|
||||||
|
meter_type: string;
|
||||||
|
status: string;
|
||||||
|
installation_date: string;
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CSVReadingRow {
|
||||||
|
meter_serial: string;
|
||||||
|
reading_value: string;
|
||||||
|
received_at: string;
|
||||||
|
reading_type: string;
|
||||||
|
battery_level: string;
|
||||||
|
signal_strength: string;
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
total: number;
|
||||||
|
inserted: number;
|
||||||
|
updated: number;
|
||||||
|
errors: UploadError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadError {
|
||||||
|
row: number;
|
||||||
|
field?: string;
|
||||||
|
message: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CSV PARSING ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a CSV string into an array of objects
|
||||||
|
* @param csvContent - Raw CSV string content
|
||||||
|
* @returns Array of parsed row objects
|
||||||
|
*/
|
||||||
|
export function parseCSV<T extends Record<string, string>>(csvContent: string): T[] {
|
||||||
|
const lines = csvContent.trim().split(/\r?\n/);
|
||||||
|
|
||||||
|
if (lines.length < 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse header row
|
||||||
|
const headers = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase());
|
||||||
|
|
||||||
|
// Parse data rows
|
||||||
|
const rows: T[] = [];
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
const values = parseCSVLine(line);
|
||||||
|
const row: Record<string, string> = {};
|
||||||
|
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
row[header] = values[index]?.trim() || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.push(row as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single CSV line, handling quoted values and commas within quotes
|
||||||
|
*/
|
||||||
|
function parseCSVLine(line: string): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i];
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && line[i + 1] === '"') {
|
||||||
|
// Escaped quote
|
||||||
|
current += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
result.push(current);
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(current);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== METERS UPLOAD ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process CSV upload for meters (upsert logic)
|
||||||
|
* If serial_number exists -> UPDATE, if not -> INSERT
|
||||||
|
*/
|
||||||
|
export async function uploadMetersCSV(csvContent: string): Promise<UploadResult> {
|
||||||
|
const rows = parseCSV<CSVMeterRow>(csvContent);
|
||||||
|
|
||||||
|
const result: UploadResult = {
|
||||||
|
total: rows.length,
|
||||||
|
inserted: 0,
|
||||||
|
updated: 0,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const row = rows[i];
|
||||||
|
const rowNumber = i + 2; // +2 because row 1 is header, and we're 0-indexed
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate required fields
|
||||||
|
if (!row.serial_number) {
|
||||||
|
result.errors.push({
|
||||||
|
row: rowNumber,
|
||||||
|
field: 'serial_number',
|
||||||
|
message: 'El campo serial_number es requerido',
|
||||||
|
data: row as unknown as Record<string, unknown>
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if meter exists
|
||||||
|
const existingMeter = await query<{ id: string; project_id: string }>(
|
||||||
|
'SELECT id, project_id FROM meters WHERE serial_number = $1',
|
||||||
|
[row.serial_number]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingMeter.rows.length > 0) {
|
||||||
|
// UPDATE existing meter
|
||||||
|
const meterId = existingMeter.rows[0].id;
|
||||||
|
await updateMeterFromCSV(meterId, row);
|
||||||
|
result.updated++;
|
||||||
|
} else {
|
||||||
|
// INSERT new meter
|
||||||
|
// Validate required fields for creation
|
||||||
|
if (!row.name) {
|
||||||
|
result.errors.push({
|
||||||
|
row: rowNumber,
|
||||||
|
field: 'name',
|
||||||
|
message: 'El campo name es requerido para crear un nuevo medidor',
|
||||||
|
data: row as unknown as Record<string, unknown>
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.concentrator_serial) {
|
||||||
|
result.errors.push({
|
||||||
|
row: rowNumber,
|
||||||
|
field: 'concentrator_serial',
|
||||||
|
message: 'El campo concentrator_serial es requerido para crear un nuevo medidor',
|
||||||
|
data: row as unknown as Record<string, unknown>
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find concentrator by serial number
|
||||||
|
const concentrator = await query<{ id: string; project_id: string }>(
|
||||||
|
'SELECT id, project_id FROM concentrators WHERE serial_number = $1',
|
||||||
|
[row.concentrator_serial]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (concentrator.rows.length === 0) {
|
||||||
|
result.errors.push({
|
||||||
|
row: rowNumber,
|
||||||
|
field: 'concentrator_serial',
|
||||||
|
message: `Concentrador con serial "${row.concentrator_serial}" no encontrado`,
|
||||||
|
data: row as unknown as Record<string, unknown>
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const concentratorId = concentrator.rows[0].id;
|
||||||
|
const projectId = row.project_id || concentrator.rows[0].project_id;
|
||||||
|
|
||||||
|
await createMeterFromCSV(row, concentratorId, projectId);
|
||||||
|
result.inserted++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
|
||||||
|
result.errors.push({
|
||||||
|
row: rowNumber,
|
||||||
|
message: errorMessage,
|
||||||
|
data: row as unknown as Record<string, unknown>
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new meter from CSV row data
|
||||||
|
*/
|
||||||
|
async function createMeterFromCSV(row: CSVMeterRow, concentratorId: string, projectId: string): Promise<void> {
|
||||||
|
const meterType = validateMeterType(row.meter_type) || 'WATER';
|
||||||
|
const status = validateStatus(row.status) || 'ACTIVE';
|
||||||
|
const installationDate = parseDate(row.installation_date);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO meters (serial_number, name, project_id, concentrator_id, area_name, location, type, status, installation_date)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
[
|
||||||
|
row.serial_number,
|
||||||
|
row.name,
|
||||||
|
projectId,
|
||||||
|
concentratorId,
|
||||||
|
row.area_name || null,
|
||||||
|
row.location || null,
|
||||||
|
meterType,
|
||||||
|
status,
|
||||||
|
installationDate
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing meter from CSV row data
|
||||||
|
*/
|
||||||
|
async function updateMeterFromCSV(meterId: string, row: CSVMeterRow): Promise<void> {
|
||||||
|
const updates: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (row.name) {
|
||||||
|
updates.push(`name = $${paramIndex}`);
|
||||||
|
params.push(row.name);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.project_id) {
|
||||||
|
updates.push(`project_id = $${paramIndex}`);
|
||||||
|
params.push(row.project_id);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.area_name !== undefined) {
|
||||||
|
updates.push(`area_name = $${paramIndex}`);
|
||||||
|
params.push(row.area_name || null);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.location !== undefined) {
|
||||||
|
updates.push(`location = $${paramIndex}`);
|
||||||
|
params.push(row.location || null);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.meter_type) {
|
||||||
|
const meterType = validateMeterType(row.meter_type);
|
||||||
|
if (meterType) {
|
||||||
|
updates.push(`type = $${paramIndex}`);
|
||||||
|
params.push(meterType);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.status) {
|
||||||
|
const status = validateStatus(row.status);
|
||||||
|
if (status) {
|
||||||
|
updates.push(`status = $${paramIndex}`);
|
||||||
|
params.push(status);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.installation_date) {
|
||||||
|
const date = parseDate(row.installation_date);
|
||||||
|
if (date) {
|
||||||
|
updates.push(`installation_date = $${paramIndex}`);
|
||||||
|
params.push(date);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push(`updated_at = NOW()`);
|
||||||
|
params.push(meterId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE meters SET ${updates.join(', ')} WHERE id = $${paramIndex}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== READINGS UPLOAD ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process CSV upload for readings
|
||||||
|
*/
|
||||||
|
export async function uploadReadingsCSV(csvContent: string): Promise<UploadResult> {
|
||||||
|
const rows = parseCSV<CSVReadingRow>(csvContent);
|
||||||
|
|
||||||
|
const result: UploadResult = {
|
||||||
|
total: rows.length,
|
||||||
|
inserted: 0,
|
||||||
|
updated: 0,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const row = rows[i];
|
||||||
|
const rowNumber = i + 2;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate required fields
|
||||||
|
if (!row.meter_serial) {
|
||||||
|
result.errors.push({
|
||||||
|
row: rowNumber,
|
||||||
|
field: 'meter_serial',
|
||||||
|
message: 'El campo meter_serial es requerido',
|
||||||
|
data: row as unknown as Record<string, unknown>
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.reading_value) {
|
||||||
|
result.errors.push({
|
||||||
|
row: rowNumber,
|
||||||
|
field: 'reading_value',
|
||||||
|
message: 'El campo reading_value es requerido',
|
||||||
|
data: row as unknown as Record<string, unknown>
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const readingValue = parseFloat(row.reading_value);
|
||||||
|
if (isNaN(readingValue)) {
|
||||||
|
result.errors.push({
|
||||||
|
row: rowNumber,
|
||||||
|
field: 'reading_value',
|
||||||
|
message: `Valor de lectura inválido: "${row.reading_value}"`,
|
||||||
|
data: row as unknown as Record<string, unknown>
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find meter by serial number
|
||||||
|
const meter = await query<{ id: string }>(
|
||||||
|
'SELECT id FROM meters WHERE serial_number = $1',
|
||||||
|
[row.meter_serial]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (meter.rows.length === 0) {
|
||||||
|
result.errors.push({
|
||||||
|
row: rowNumber,
|
||||||
|
field: 'meter_serial',
|
||||||
|
message: `Medidor con serial "${row.meter_serial}" no encontrado`,
|
||||||
|
data: row as unknown as Record<string, unknown>
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meterId = meter.rows[0].id;
|
||||||
|
const readingType = validateReadingType(row.reading_type) || 'MANUAL';
|
||||||
|
const receivedAt = parseDateTime(row.received_at) || new Date();
|
||||||
|
const batteryLevel = row.battery_level ? parseInt(row.battery_level, 10) : null;
|
||||||
|
const signalStrength = row.signal_strength ? parseInt(row.signal_strength, 10) : null;
|
||||||
|
|
||||||
|
// Validate battery level range
|
||||||
|
if (batteryLevel !== null && (batteryLevel < 0 || batteryLevel > 100)) {
|
||||||
|
result.errors.push({
|
||||||
|
row: rowNumber,
|
||||||
|
field: 'battery_level',
|
||||||
|
message: `Nivel de batería inválido: "${row.battery_level}" (debe ser 0-100)`,
|
||||||
|
data: row as unknown as Record<string, unknown>
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert reading
|
||||||
|
await query(
|
||||||
|
`INSERT INTO meter_readings (meter_id, reading_value, reading_type, battery_level, signal_strength, received_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
[meterId, readingValue, readingType, batteryLevel, signalStrength, receivedAt]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update meter's last reading
|
||||||
|
await query(
|
||||||
|
`UPDATE meters SET last_reading_value = $1, last_reading_at = $2, updated_at = NOW() WHERE id = $3`,
|
||||||
|
[readingValue, receivedAt, meterId]
|
||||||
|
);
|
||||||
|
|
||||||
|
result.inserted++;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
|
||||||
|
result.errors.push({
|
||||||
|
row: rowNumber,
|
||||||
|
message: errorMessage,
|
||||||
|
data: row as unknown as Record<string, unknown>
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CSV TEMPLATES ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate CSV template for meters upload
|
||||||
|
*/
|
||||||
|
export function generateMeterCSVTemplate(): string {
|
||||||
|
const headers = [
|
||||||
|
'serial_number',
|
||||||
|
'name',
|
||||||
|
'concentrator_serial',
|
||||||
|
'area_name',
|
||||||
|
'location',
|
||||||
|
'meter_type',
|
||||||
|
'status',
|
||||||
|
'installation_date'
|
||||||
|
];
|
||||||
|
|
||||||
|
const exampleRow = [
|
||||||
|
'MED001',
|
||||||
|
'Medidor Centro',
|
||||||
|
'CONC001',
|
||||||
|
'Zona A',
|
||||||
|
'Calle 1 #100',
|
||||||
|
'WATER',
|
||||||
|
'ACTIVE',
|
||||||
|
'2024-01-15'
|
||||||
|
];
|
||||||
|
|
||||||
|
return headers.join(',') + '\n' + exampleRow.join(',') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate CSV template for readings upload
|
||||||
|
*/
|
||||||
|
export function generateReadingCSVTemplate(): string {
|
||||||
|
const headers = [
|
||||||
|
'meter_serial',
|
||||||
|
'reading_value',
|
||||||
|
'received_at',
|
||||||
|
'reading_type',
|
||||||
|
'battery_level',
|
||||||
|
'signal_strength'
|
||||||
|
];
|
||||||
|
|
||||||
|
const exampleRow = [
|
||||||
|
'MED001',
|
||||||
|
'1234.56',
|
||||||
|
'2024-01-20 10:30:00',
|
||||||
|
'MANUAL',
|
||||||
|
'85',
|
||||||
|
'-45'
|
||||||
|
];
|
||||||
|
|
||||||
|
return headers.join(',') + '\n' + exampleRow.join(',') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== VALIDATION HELPERS ====================
|
||||||
|
|
||||||
|
const VALID_METER_TYPES = ['WATER', 'GAS', 'ELECTRIC'];
|
||||||
|
const VALID_STATUSES = ['ACTIVE', 'INACTIVE', 'OFFLINE', 'MAINTENANCE', 'ERROR'];
|
||||||
|
const VALID_READING_TYPES = ['AUTOMATIC', 'MANUAL', 'SCHEDULED'];
|
||||||
|
|
||||||
|
function validateMeterType(type?: string): string | null {
|
||||||
|
if (!type) return null;
|
||||||
|
const upperType = type.toUpperCase();
|
||||||
|
return VALID_METER_TYPES.includes(upperType) ? upperType : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateStatus(status?: string): string | null {
|
||||||
|
if (!status) return null;
|
||||||
|
const upperStatus = status.toUpperCase();
|
||||||
|
return VALID_STATUSES.includes(upperStatus) ? upperStatus : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateReadingType(type?: string): string | null {
|
||||||
|
if (!type) return null;
|
||||||
|
const upperType = type.toUpperCase();
|
||||||
|
return VALID_READING_TYPES.includes(upperType) ? upperType : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(dateStr?: string): string | null {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
// Try to parse YYYY-MM-DD format
|
||||||
|
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (match) {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
// Try DD/MM/YYYY format
|
||||||
|
const match2 = dateStr.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
|
||||||
|
if (match2) {
|
||||||
|
return `${match2[3]}-${match2[2]}-${match2[1]}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateTime(dateTimeStr?: string): Date | null {
|
||||||
|
if (!dateTimeStr) return null;
|
||||||
|
const date = new Date(dateTimeStr);
|
||||||
|
return isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user