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:
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" />
|
||||
Reference in New Issue
Block a user