import { useState, useRef, useCallback, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { Upload, FileText, Database, CheckCircle2, AlertCircle, Loader2, Scissors, SkipForward, Globe, Terminal, RefreshCw } from "lucide-react"; import { supabase } from "@/integrations/supabase/client"; import { unzipSync } from "fflate"; const CHUNK_SIZE = 5000; // rows per API call — reduced to avoid DB statement timeouts const UI_UPDATE_INTERVAL = 25000; // only update UI every N rows to reduce memory pressure const CHECKPOINT_INTERVAL = 50000; // save checkpoint every N rows const GC_PAUSE_INTERVAL = 100000; // pause to let browser GC every N rows interface ImportState { status: "idle" | "parsing" | "uploading" | "done" | "error" | "resume_prompt"; fileName: string; totalRows: number; processedRows: number; insertedRows: number; errorRows: number; errorMessages: string[]; resumeFrom: number; // rows to skip when resuming jobId: string | null; // DB import_jobs id for URL imports } const initialState: ImportState = { status: "idle", fileName: "", totalRows: 0, processedRows: 0, insertedRows: 0, errorRows: 0, errorMessages: [], resumeFrom: 0, jobId: null, }; function getCheckpointKey(type: string) { return `import_checkpoint_${type}`; } interface Checkpoint { fileName: string; fileSize: number; processedRows: number; insertedRows: number; timestamp: number; } function saveCheckpoint(type: string, cp: Checkpoint) { try { localStorage.setItem(getCheckpointKey(type), JSON.stringify(cp)); } catch {} } function loadCheckpoint(type: string): Checkpoint | null { try { const raw = localStorage.getItem(getCheckpointKey(type)); return raw ? JSON.parse(raw) : null; } catch { return null; } } function clearCheckpoint(type: string) { try { localStorage.removeItem(getCheckpointKey(type)); } catch {} } function normalizeHeader(name: string): string { return name .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") // Remove accents .replace(/[_\s]+/g, "_") .replace(/[^a-z0-9_]/g, "") .trim(); } // Map of normalized header names to expected DB column names for fiber const FIBER_HEADER_MAP: Record = { id_placemark: "id_placemark", idplacemark: "id_placemark", id_appart: "id_appart", idappart: "id_appart", uuidadresse: "uuidadresse", idgouvqc: "uuidadresse", id_gouv_qc: "uuidadresse", nom: "nom", civique: "civique", appartement: "appartement", appt: "appartement", apt: "appartement", rue: "rue", ville: "ville", code_postal: "code_postal", codepostal: "code_postal", cp: "code_postal", lien_googlemap: "lien_googlemap", liengooglemap: "lien_googlemap", googlemap: "lien_googlemap", zone_tarifaire: "zone_tarifaire", zonetarifaire: "zone_tarifaire", zone: "zone_tarifaire", max_speed: "max_speed", maxspeed: "max_speed", vitesse: "max_speed", vitesse_max: "max_speed", commentaire: "commentaire", commentaires: "commentaire", comment: "commentaire", lien_map_targo: "_skip", lienmaptargo: "_skip", }; function parseLine(line: string, delimiter: string): string[] { if (delimiter === "\t" || delimiter === ";") { return line.split(delimiter).map((v) => v.trim().replace(/^"|"$/g, "")); } // CSV with potential quoted fields const values: string[] = []; let current = ""; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { if (inQuotes && i + 1 < line.length && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = !inQuotes; } } else if (char === delimiter && !inQuotes) { values.push(current.trim()); current = ""; } else { current += char; } } values.push(current.trim()); return values; } function ImportCard({ type, label, description, }: { type: "addresses" | "fiber"; label: string; description: string; }) { const [state, setState] = useState(initialState); const [urlInput, setUrlInput] = useState("https://diffusion.mern.gouv.qc.ca/diffusion/RGQ/Vectoriel/Theme/Local/RQA/CSV/RQA_CSV.zip"); const [urlMode, setUrlMode] = useState(false); const [skipRowsInput, setSkipRowsInput] = useState(""); const [importLogs, setImportLogs] = useState([]); const [showLogs, setShowLogs] = useState(false); const [pendingJob, setPendingJob] = useState(null); const abortRef = useRef(false); const fileInputRef = useRef(null); const startTimeRef = useRef(0); const logEndRef = useRef(null); const autoResumeTriggered = useRef(false); const pendingFileRef = useRef(null); // Check for existing checkpoint on mount const checkpoint = loadCheckpoint(type); const addLog = (msg: string) => { setImportLogs(prev => [...prev, msg]); setTimeout(() => logEndRef.current?.scrollIntoView({ behavior: "smooth" }), 50); }; const reset = (keepCheckpoint = false) => { abortRef.current = true; if (!keepCheckpoint) clearCheckpoint(type); pendingFileRef.current = null; setPendingJob(null); setUrlMode(false); setImportLogs([]); setState(initialState); }; // Helper to update job in DB const updateJob = async (jobId: string, updates: Record) => { try { await supabase.from("import_jobs").update({ ...updates, updated_at: new Date().toISOString() }).eq("id", jobId); } catch {} }; // Check for incomplete DB jobs on mount (URL imports only) useEffect(() => { if (autoResumeTriggered.current) return; autoResumeTriggered.current = true; const checkPendingJobs = async () => { const { data: session } = await supabase.auth.getSession(); if (!session?.session?.user) return; const { data: jobs } = await supabase .from("import_jobs") .select("*") .eq("type", type) .in("status", ["running", "paused"]) .order("updated_at", { ascending: false }) .limit(1); if (jobs && jobs.length > 0) { const job = jobs[0]; setPendingJob(job); // Restore logs from DB if (job.logs && job.logs.length > 0) { setImportLogs(job.logs); setShowLogs(true); } } }; checkPendingJobs(); }, [type]); // URL-based import: loops calling edge function with DB checkpointing const processUrl = useCallback( async (url: string, resumeJobId?: string, resumeSkip?: number, resumeInserted?: number) => { abortRef.current = false; startTimeRef.current = Date.now(); if (!resumeJobId) { setImportLogs([]); } setShowLogs(true); setPendingJob(null); addLog(`🚀 Démarrage import ${type} depuis URL${resumeSkip ? ` (reprise à ${resumeSkip} lignes)` : ""}...`); // Get user id const { data: sessionData } = await supabase.auth.getSession(); const userId = sessionData?.session?.user?.id; if (!userId) { addLog("❌ Non authentifié"); setState(s => ({ ...s, status: "error", errorMessages: ["Non authentifié"] })); return; } // Create or reuse DB job let jobId = resumeJobId; if (!jobId) { const { data: newJob, error: jobErr } = await supabase .from("import_jobs") .insert({ type, source_url: url, status: "running", user_id: userId }) .select("id") .single(); if (jobErr || !newJob) { addLog(`❌ Erreur création job: ${jobErr?.message}`); setState(s => ({ ...s, status: "error", errorMessages: [jobErr?.message || "Erreur"] })); return; } jobId = newJob.id; } else { await updateJob(jobId, { status: "running" }); } setState({ ...initialState, status: "uploading", fileName: url.split("/").pop() || "URL import", totalRows: 0, jobId, processedRows: resumeSkip || 0, insertedRows: resumeInserted || 0, }); const isZip = url.toLowerCase().includes(".zip"); let csvText: string | null = null; if (isZip) { // Download and decompress ZIP in the browser addLog(`📥 Téléchargement du fichier ZIP...`); try { const resp = await fetch(url); if (!resp.ok) { addLog(`❌ Erreur téléchargement: ${resp.status} ${resp.statusText}`); await updateJob(jobId!, { status: "error", error_messages: [`Erreur: ${resp.status}`] }); setState((s) => ({ ...s, status: "error", errorMessages: [`Erreur: ${resp.status}`] })); return; } const zipData = new Uint8Array(await resp.arrayBuffer()); addLog(`📦 ZIP téléchargé (${(zipData.length / 1024 / 1024).toFixed(1)} MB), décompression...`); const files = unzipSync(zipData); const csvEntry = Object.entries(files).find(([name]) => name.toLowerCase().endsWith(".csv") || name.toLowerCase().endsWith(".tsv") || name.toLowerCase().endsWith(".txt") ); if (!csvEntry) { addLog(`❌ Aucun fichier CSV trouvé dans le ZIP`); await updateJob(jobId!, { status: "error", error_messages: ["Aucun CSV dans le ZIP"] }); setState((s) => ({ ...s, status: "error", errorMessages: ["Aucun CSV dans le ZIP"] })); return; } addLog(`📄 Fichier extrait: ${csvEntry[0]} (${(csvEntry[1].length / 1024 / 1024).toFixed(1)} MB)`); csvText = new TextDecoder("utf-8").decode(csvEntry[1]); } catch (err: any) { addLog(`💥 Erreur décompression: ${err.message}`); await updateJob(jobId!, { status: "error", error_messages: [err.message] }); setState((s) => ({ ...s, status: "error", errorMessages: [err.message] })); return; } } // Now send CSV data in chunks to edge function let skipRows = resumeSkip || 0; let totalInserted = resumeInserted || 0; let totalErrors = 0; const allErrorMessages: string[] = []; const CSV_CHUNK_SIZE = 5 * 1024 * 1024; // 5 MB let chunkCount = 0; const DB_SAVE_INTERVAL = 3; // save to DB every N chunks while (!abortRef.current) { try { // Refresh session before every chunk to prevent expiry await supabase.auth.getSession(); let body: any; if (csvText) { const chunk = csvText.slice(0, CSV_CHUNK_SIZE); body = { csv_text: chunk, type, skip_rows: skipRows }; } else { body = { url, type, skip_rows: skipRows }; } const { data, error } = await supabase.functions.invoke("import-csv-url", { body }); if (error) { addLog(`❌ Erreur: ${error.message}`); await updateJob(jobId!, { status: "error", error_messages: [error.message], skip_rows: skipRows, total_inserted: totalInserted }); setState((s) => ({ ...s, status: "error", errorMessages: [error.message] })); return; } if (data) { if (data.logs && Array.isArray(data.logs)) { data.logs.forEach((l: string) => addLog(l)); } totalInserted += data.inserted || 0; totalErrors += data.errors || 0; skipRows = data.next_skip || skipRows; if (data.errorMessages) { allErrorMessages.push(...data.errorMessages); } chunkCount++; addLog(`📊 Chunk ${chunkCount} terminé: +${data.processed || 0} lignes, total inséré: ${totalInserted}, erreurs: ${totalErrors}`); setState((s) => ({ ...s, processedRows: skipRows, insertedRows: totalInserted, errorRows: totalErrors, totalRows: data.done ? skipRows : Math.max(s.totalRows, skipRows + 50000), errorMessages: [...new Set(allErrorMessages)].slice(0, 5), })); // Save checkpoint to DB periodically if (chunkCount % DB_SAVE_INTERVAL === 0) { const recentLogs = importLogs.slice(-200); // keep last 200 log lines await updateJob(jobId!, { skip_rows: skipRows, total_inserted: totalInserted, total_errors: totalErrors, error_messages: [...new Set(allErrorMessages)].slice(0, 20), logs: recentLogs, }); } if (data.done) { addLog(`✅ Import terminé! Total: ${skipRows} lignes, ${totalInserted} insérées, ${totalErrors} erreurs`); await updateJob(jobId!, { status: "done", skip_rows: skipRows, total_inserted: totalInserted, total_errors: totalErrors }); setState((s) => ({ ...s, status: "done", totalRows: skipRows, processedRows: skipRows, })); return; } // For CSV text mode, trim processed text if (csvText) { const lines = csvText.split("\n"); const rowsToRemove = data.processed || 0; const linesToRemove = skipRows === rowsToRemove ? rowsToRemove + 1 : rowsToRemove; csvText = lines.slice(linesToRemove).join("\n"); skipRows = 0; if (!csvText.trim()) { addLog(`✅ Import terminé! Total: ${totalInserted} insérées, ${totalErrors} erreurs`); await updateJob(jobId!, { status: "done", total_inserted: totalInserted, total_errors: totalErrors }); setState((s) => ({ ...s, status: "done" })); return; } } await new Promise((r) => setTimeout(r, 500)); } } catch (err: any) { addLog(`💥 Exception: ${err.message}`); // Save progress before dying so we can resume await updateJob(jobId!, { status: "paused", skip_rows: skipRows, total_inserted: totalInserted, total_errors: totalErrors, error_messages: [err.message] }); setState((s) => ({ ...s, status: "error", errorMessages: [`${err.message} — L'import peut être repris automatiquement.`] })); return; } } // User cancelled — save progress if (abortRef.current && jobId) { await updateJob(jobId, { status: "paused", skip_rows: skipRows, total_inserted: totalInserted, total_errors: totalErrors }); } }, [type, importLogs] ); const processFile = useCallback( async (file: File, skipRows = 0) => { abortRef.current = false; startTimeRef.current = Date.now(); const alreadyInserted = skipRows > 0 ? (loadCheckpoint(type)?.insertedRows || 0) : 0; setState({ ...initialState, status: "parsing", fileName: file.name, resumeFrom: skipRows, }); // Estimate total rows from file size (rough: ~300 bytes per row for addresses CSV) const estimatedRows = Math.round(file.size / 300); setState((s) => ({ ...s, totalRows: estimatedRows, status: "uploading" })); const stream = file.stream(); const reader = stream.getReader(); const decoder = new TextDecoder("utf-8", { fatal: false }); let headers: string[] = []; let delimiter = ","; let leftover = ""; let isFirstChunk = true; let processedRows = 0; let insertedRows = alreadyInserted; let errorRows = 0; const errorMessages: string[] = []; let pendingRows: Record[] = []; let totalRowsRead = 0; // total rows read from file (including skipped) const flushBatch = async (rows: Record[]) => { if (rows.length === 0 || abortRef.current) return; try { // Refresh session token before each batch to prevent expiry during long imports await supabase.auth.getSession(); const { data, error } = await supabase.functions.invoke("import-csv", { body: { type, rows }, }); if (error) { errorRows += rows.length; if (errorMessages.length < 5) errorMessages.push(error.message); } else if (data) { insertedRows += data.inserted || 0; errorRows += data.errors || 0; if (data.errorMessages) { errorMessages.push(...data.errorMessages.slice(0, 3)); } } } catch (err: any) { errorRows += rows.length; if (errorMessages.length < 5) errorMessages.push(err.message); } // Throttle UI updates to reduce memory pressure from React re-renders if (processedRows % UI_UPDATE_INTERVAL < CHUNK_SIZE) { setState((s) => ({ ...s, processedRows, insertedRows, errorRows, totalRows: Math.max(s.totalRows, processedRows + skipRows), errorMessages: [...new Set(errorMessages)].slice(0, 5), })); } // Save checkpoint less frequently if (processedRows % CHECKPOINT_INTERVAL < CHUNK_SIZE) { saveCheckpoint(type, { fileName: file.name, fileSize: file.size, processedRows: processedRows + skipRows, insertedRows, timestamp: Date.now(), }); } }; try { while (true) { if (abortRef.current) break; const { done, value } = await reader.read(); const chunk = decoder.decode(value, { stream: !done }); const text = leftover + chunk; const lines = text.split(/\r?\n/); // Keep last incomplete line for next iteration leftover = done ? "" : (lines.pop() || ""); for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; if (isFirstChunk && headers.length === 0) { // Remove BOM const cleanLine = trimmed.charCodeAt(0) === 0xfeff ? trimmed.slice(1) : trimmed; // Detect delimiter const tabCount = (cleanLine.match(/\t/g) || []).length; const semiCount = (cleanLine.match(/;/g) || []).length; const commaCount = (cleanLine.match(/,/g) || []).length; delimiter = tabCount >= semiCount && tabCount >= commaCount ? "\t" : semiCount >= commaCount ? ";" : ","; const candidateHeaders = parseLine(cleanLine, delimiter); // Skip title lines that have too few columns (e.g. "adresses postales") if (candidateHeaders.length < 3) { console.log(`[Import] Skipping title line: "${cleanLine}"`); continue; } headers = candidateHeaders; console.log(`[Import] Stream - Delimiter: "${delimiter === "\t" ? "TAB" : delimiter}", Headers (${headers.length}):`, headers.join(" | ")); // For fiber imports, build a mapping from CSV header index to normalized DB column if (type === "fiber") { const headerMapping = headers.map((h) => { const norm = normalizeHeader(h); const mapped = FIBER_HEADER_MAP[norm]; return mapped ? `${h} → ${mapped}` : `${h} → ❌ UNMAPPED (${norm})`; }); console.log(`[Import] Fiber header mapping:`, headerMapping.join(" | ")); } isFirstChunk = false; continue; } const values = parseLine(trimmed, delimiter); const row: Record = {}; totalRowsRead++; // Skip rows that were already processed in a previous run if (totalRowsRead <= skipRows) { if (totalRowsRead % 100000 === 0) { setState((s) => ({ ...s, status: "uploading", processedRows: 0, totalRows: Math.max(s.totalRows, estimatedRows), errorMessages: [`⏩ Saut de ${totalRowsRead.toLocaleString("fr-CA")} lignes déjà importées...`], })); } continue; } if (type === "fiber") { // Use normalized mapping for fiber headers.forEach((h, idx) => { const norm = normalizeHeader(h); const dbCol = FIBER_HEADER_MAP[norm]; if (dbCol && dbCol !== "_skip") { row[dbCol] = values[idx] || ""; } }); } else { headers.forEach((h, idx) => { row[h] = values[idx] || ""; }); } pendingRows.push(row); processedRows++; if (pendingRows.length >= CHUNK_SIZE) { await flushBatch(pendingRows); pendingRows = []; // Periodically pause to let the browser GC reclaim memory if (processedRows % GC_PAUSE_INTERVAL < CHUNK_SIZE) { await new Promise((r) => setTimeout(r, 50)); } } } if (done) break; } // Flush remaining rows if (pendingRows.length > 0) { await flushBatch(pendingRows); } } catch (err: any) { console.error("[Import] Stream error:", err); setState((s) => ({ ...s, status: "error", errorMessages: [`Erreur de lecture: ${err.message}`], })); return; } if (!abortRef.current) { clearCheckpoint(type); // Import completed successfully } setState((s) => ({ ...s, status: abortRef.current ? "idle" : "done", totalRows: processedRows + skipRows, processedRows, insertedRows, errorRows, })); }, [type] ); const progress = state.totalRows > 0 ? Math.round((state.processedRows / state.totalRows) * 100) : 0; const formatNum = (n: number) => n.toLocaleString("fr-CA"); const getStats = () => { if (state.processedRows === 0 || state.status !== "uploading") return { eta: "", speed: "" }; const elapsed = (Date.now() - startTimeRef.current) / 1000; const rate = state.processedRows / elapsed; const remaining = state.totalRows - state.processedRows; const etaSec = remaining / rate; let eta = ""; if (etaSec < 60) eta = `~${Math.round(etaSec)}s restant`; else if (etaSec < 3600) eta = `~${Math.round(etaSec / 60)} min restant`; else { const h = Math.floor(etaSec / 3600); const m = Math.round((etaSec % 3600) / 60); eta = `~${h}h${m.toString().padStart(2, "0")} restant`; } const speed = rate >= 1000 ? `${(rate / 1000).toFixed(1)}k lignes/s` : `${Math.round(rate)} lignes/s`; return { eta, speed }; }; return (
{type === "addresses" ? ( ) : ( )}

{label}

{description}

{ const file = e.target.files?.[0]; if (file) { const cp = loadCheckpoint(type); if (cp && cp.fileName === file.name && cp.fileSize === file.size) { // Same file — offer to resume pendingFileRef.current = file; setState((s) => ({ ...s, status: "resume_prompt", fileName: file.name, resumeFrom: cp.processedRows, insertedRows: cp.insertedRows, })); } else { clearCheckpoint(type); processFile(file, 0); } } e.target.value = ""; }} /> {state.status === "idle" && ( <> {/* DB-based resume for URL imports */} {pendingJob && (
Import URL interrompu détecté

{pendingJob.source_url?.split("/").pop()} — {(pendingJob.skip_rows || 0).toLocaleString("fr-CA")} lignes traitées ({(pendingJob.total_inserted || 0).toLocaleString("fr-CA")} insérées). Statut: {pendingJob.status}. Dernière mise à jour: {new Date(pendingJob.updated_at).toLocaleString("fr-CA")}.

{pendingJob.error_messages && pendingJob.error_messages.length > 0 && (
Raison de l'interruption:
    {pendingJob.error_messages.map((msg: string, i: number) => (
  • {msg}
  • ))}
)} {pendingJob.skip_rows === 0 && (!pendingJob.error_messages || pendingJob.error_messages.length === 0) && (
Cause probable: Le navigateur a manqué de mémoire lors du téléchargement ou de la décompression du ZIP (2.7 Go). Essayez depuis un ordinateur de bureau avec plus de RAM, ou utilisez un fichier CSV déjà décompressé.
)} {pendingJob.logs && pendingJob.logs.length > 0 && (
Voir les {pendingJob.logs.length} derniers logs
{pendingJob.logs.map((log: string, i: number) => (
{log}
))}
)}
)} {checkpoint && (
Import fichier interrompu détecté

{checkpoint.fileName} — {checkpoint.processedRows.toLocaleString("fr-CA")} lignes déjà traitées ({checkpoint.insertedRows.toLocaleString("fr-CA")} insérées). Sélectionnez le même fichier pour reprendre.

)}
{urlMode && (
setUrlInput(e.target.value)} />

Supporte les fichiers CSV et ZIP. Le mode autonome décompresse et traite le fichier entièrement côté serveur — idéal depuis un téléphone.

setSkipRowsInput(e.target.value)} className="w-32" /> ({Number(skipRowsInput || 0).toLocaleString()})
)} )} {state.status === "resume_prompt" && (
Reprendre l'import ?

{state.fileName} — {formatNum(state.resumeFrom)} lignes déjà importées ({formatNum(state.insertedRows)} insérées). Reprendre à partir de la ligne {formatNum(state.resumeFrom + 1)} ?

)} {(state.status === "parsing" || state.status === "uploading") && (
{state.status === "parsing" ? "Lecture du fichier..." : state.resumeFrom > 0 ? `Import en cours (reprise après ${formatNum(state.resumeFrom)} lignes)...` : "Import en cours..."} {formatNum(state.processedRows)} / {formatNum(state.totalRows)}
{progress}% — {getStats().eta} ⚡ {getStats().speed} · ✓ {formatNum(state.insertedRows)} insérées · ✗ {formatNum(state.errorRows)} erreurs
)} {state.status === "done" && (
Import terminé!

Fichier : {state.fileName}

Total : {formatNum(state.totalRows)} lignes

Insérées : {formatNum(state.insertedRows)}

{state.errorRows > 0 && (

Erreurs : {formatNum(state.errorRows)}

)}
{state.errorMessages.length > 0 && (
{state.errorMessages.map((msg, i) => (

{msg}

))}
)}
)} {state.status === "error" && (
Erreur
{state.errorMessages.map((msg, i) => (

{msg}

))}
)} {/* Log Panel */} {importLogs.length > 0 && (
{showLogs && (
{importLogs.map((line, i) => (
{line}
))}
)}
)}
); } function CsvSplitter() { const [splitting, setSplitting] = useState(false); const [progress, setProgress] = useState({ message: "", current: 0, total: 0, files: 0 }); const fileInputRef = useRef(null); const abortRef = useRef(false); const SPLIT_ROWS = 500_000; const splitFile = async (file: File) => { abortRef.current = false; setSplitting(true); setProgress({ message: "Lecture en streaming...", current: 0, total: 0, files: 0 }); const stream = file.stream(); const reader = stream.getReader(); const decoder = new TextDecoder("utf-8", { fatal: false }); let header = ""; let leftover = ""; let currentLines: string[] = []; let totalRows = 0; let fileCount = 0; const baseName = file.name.replace(/\.[^.]+$/, ""); const flushToFile = (lines: string[]) => { fileCount++; const content = [header, ...lines].join("\n"); const blob = new Blob([content], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${baseName}_part${fileCount}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; try { while (true) { if (abortRef.current) break; const { done, value } = await reader.read(); const chunk = decoder.decode(value, { stream: !done }); const text = leftover + chunk; const lines = text.split(/\r?\n/); leftover = done ? "" : (lines.pop() || ""); for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; if (!header) { // Remove BOM header = trimmed.charCodeAt(0) === 0xfeff ? trimmed.slice(1) : trimmed; continue; } currentLines.push(trimmed); totalRows++; if (currentLines.length >= SPLIT_ROWS) { flushToFile(currentLines); currentLines = []; setProgress({ message: `Fichier ${fileCount} créé...`, current: totalRows, total: Math.max(totalRows, Math.round(file.size / 300)), files: fileCount, }); // Small delay to let browser process the download await new Promise((r) => setTimeout(r, 300)); } } if (done) break; } // Flush remaining if (currentLines.length > 0 && !abortRef.current) { flushToFile(currentLines); } setProgress({ message: `✅ ${fileCount} fichiers créés (${totalRows.toLocaleString("fr-CA")} lignes total)`, current: totalRows, total: totalRows, files: fileCount, }); } catch (err: any) { setProgress({ message: `❌ Erreur: ${err.message}`, current: totalRows, total: totalRows, files: fileCount, }); } setSplitting(false); }; const pct = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0; return (

Découper un CSV

Sépare un gros fichier CSV en plusieurs fichiers de {(SPLIT_ROWS / 1000).toFixed(0)}k lignes pour éviter les timeouts lors de l'import. Utilise le streaming (pas de limite de taille).

{ const file = e.target.files?.[0]; if (file) splitFile(file); e.target.value = ""; }} /> {!splitting && !progress.message && ( )} {splitting && (
{progress.message}
{progress.current.toLocaleString("fr-CA")} lignes lues {progress.files} fichiers créés
)} {!splitting && progress.message && (

{progress.message}

Importez ensuite chaque partie via les cartes ci-dessus.

)}
); } const AdminImport = () => { return (

Import de données

Importez les fichiers CSV d'adresses CTOP et de disponibilité fibre. Les fichiers sont traités par lots de {CHUNK_SIZE.toLocaleString("fr-CA")} lignes.

Important : Importez d'abord les adresses, puis la fibre (dépendance FK). Supporte CSV (virgule) et TSV (tabulation). Les doublons sont mis à jour automatiquement (upsert).

Outils

); }; export default AdminImport;