Integrates www.gigafibre.ca (React/Vite) into the monorepo. Full git history accessible via `git log -- apps/website/`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1189 lines
44 KiB
TypeScript
1189 lines
44 KiB
TypeScript
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<string, string> = {
|
|
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<ImportState>(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<string[]>([]);
|
|
const [showLogs, setShowLogs] = useState(false);
|
|
const [pendingJob, setPendingJob] = useState<any>(null);
|
|
const abortRef = useRef(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const startTimeRef = useRef<number>(0);
|
|
const logEndRef = useRef<HTMLDivElement>(null);
|
|
const autoResumeTriggered = useRef(false);
|
|
|
|
const pendingFileRef = useRef<File | null>(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<string, any>) => {
|
|
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<string, string>[] = [];
|
|
let totalRowsRead = 0; // total rows read from file (including skipped)
|
|
|
|
const flushBatch = async (rows: Record<string, string>[]) => {
|
|
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<string, string> = {};
|
|
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 (
|
|
<div className="bg-card border border-border rounded-2xl p-6 space-y-4">
|
|
<div className="flex items-start gap-4">
|
|
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
|
|
{type === "addresses" ? (
|
|
<Database className="w-6 h-6 text-primary" />
|
|
) : (
|
|
<FileText className="w-6 h-6 text-primary" />
|
|
)}
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold text-lg">{label}</h3>
|
|
<p className="text-sm text-muted-foreground">{description}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".csv,.tsv,.txt"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
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 && (
|
|
<div className="bg-accent/50 rounded-lg p-3 text-sm space-y-2 border border-primary/20">
|
|
<div className="flex items-center gap-2">
|
|
<RefreshCw className="w-4 h-4 text-primary" />
|
|
<span className="font-medium">Import URL interrompu détecté</span>
|
|
</div>
|
|
<p className="text-muted-foreground text-xs">
|
|
{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: <span className="font-medium">{pendingJob.status}</span>.
|
|
Dernière mise à jour: {new Date(pendingJob.updated_at).toLocaleString("fr-CA")}.
|
|
</p>
|
|
{pendingJob.error_messages && pendingJob.error_messages.length > 0 && (
|
|
<div className="bg-destructive/10 rounded p-2 text-xs text-destructive">
|
|
<span className="font-medium">Raison de l'interruption:</span>
|
|
<ul className="list-disc ml-4 mt-1">
|
|
{pendingJob.error_messages.map((msg: string, i: number) => (
|
|
<li key={i}>{msg}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
{pendingJob.skip_rows === 0 && (!pendingJob.error_messages || pendingJob.error_messages.length === 0) && (
|
|
<div className="bg-yellow-500/10 rounded p-2 text-xs text-yellow-700">
|
|
<span className="font-medium">Cause probable:</span> 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é.
|
|
</div>
|
|
)}
|
|
{pendingJob.logs && pendingJob.logs.length > 0 && (
|
|
<details className="text-xs">
|
|
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">Voir les {pendingJob.logs.length} derniers logs</summary>
|
|
<div className="mt-1 max-h-32 overflow-y-auto bg-background/50 rounded p-2 font-mono text-[10px] space-y-0.5">
|
|
{pendingJob.logs.map((log: string, i: number) => (
|
|
<div key={i}>{log}</div>
|
|
))}
|
|
</div>
|
|
</details>
|
|
)}
|
|
<div className="flex gap-2">
|
|
<Button size="sm" onClick={() => {
|
|
processUrl(pendingJob.source_url, pendingJob.id, pendingJob.skip_rows || 0, pendingJob.total_inserted || 0);
|
|
}}>
|
|
<RefreshCw className="w-3 h-3 mr-1" />
|
|
Reprendre l'import
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={async () => {
|
|
await supabase.from("import_jobs").update({ status: "done" }).eq("id", pendingJob.id);
|
|
setPendingJob(null);
|
|
}}>
|
|
Ignorer
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{checkpoint && (
|
|
<div className="bg-accent/50 rounded-lg p-3 text-sm space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<SkipForward className="w-4 h-4 text-primary" />
|
|
<span className="font-medium">Import fichier interrompu détecté</span>
|
|
</div>
|
|
<p className="text-muted-foreground text-xs">
|
|
{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.
|
|
</p>
|
|
<Button variant="ghost" size="sm" onClick={() => { clearCheckpoint(type); setState({...initialState}); }}>
|
|
Ignorer et recommencer
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="flex-1"
|
|
variant="outline"
|
|
size="lg"
|
|
>
|
|
<Upload className="w-5 h-5 mr-2" />
|
|
Fichier CSV
|
|
</Button>
|
|
<Button
|
|
onClick={() => setUrlMode(!urlMode)}
|
|
className="flex-1"
|
|
variant={urlMode ? "default" : "outline"}
|
|
size="lg"
|
|
>
|
|
<Globe className="w-5 h-5 mr-2" />
|
|
Lien URL
|
|
</Button>
|
|
</div>
|
|
{urlMode && (
|
|
<div className="space-y-2">
|
|
<Input
|
|
placeholder="https://exemple.com/fichier.csv ou .zip"
|
|
value={urlInput}
|
|
onChange={(e) => setUrlInput(e.target.value)}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
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.
|
|
</p>
|
|
<div className="space-y-2">
|
|
<div className="flex gap-2 items-center">
|
|
<label className="text-xs text-muted-foreground whitespace-nowrap">Lignes à sauter :</label>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
placeholder="0"
|
|
value={skipRowsInput}
|
|
onChange={(e) => setSkipRowsInput(e.target.value)}
|
|
className="w-32"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">({Number(skipRowsInput || 0).toLocaleString()})</span>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={() => {
|
|
if (urlInput.trim()) processUrl(urlInput.trim());
|
|
}}
|
|
disabled={!urlInput.trim()}
|
|
className="flex-1"
|
|
variant="outline"
|
|
>
|
|
<Globe className="w-4 h-4 mr-2" />
|
|
Import navigateur
|
|
</Button>
|
|
<Button
|
|
onClick={async () => {
|
|
if (!urlInput.trim()) return;
|
|
const { data: sessionData } = await supabase.auth.getSession();
|
|
const userId = sessionData?.session?.user?.id;
|
|
if (!userId) { addLog("❌ Non authentifié"); return; }
|
|
const skipVal = parseInt(skipRowsInput || "0") || 0;
|
|
const { data: newJob, error: jobErr } = await supabase
|
|
.from("import_jobs")
|
|
.insert({ type, source_url: urlInput.trim(), status: "queued", user_id: userId, skip_rows: skipVal, total_inserted: skipVal })
|
|
.select("id")
|
|
.single();
|
|
if (jobErr || !newJob) {
|
|
addLog(`❌ Erreur: ${jobErr?.message}`);
|
|
return;
|
|
}
|
|
addLog(`🤖 Job autonome créé (${newJob.id.slice(0, 8)}…). Skip=${skipVal.toLocaleString()} lignes. Le serveur traitera le fichier automatiquement toutes les 2 minutes.`);
|
|
setShowLogs(true);
|
|
setPendingJob({ ...newJob, type, source_url: urlInput.trim(), status: "queued", skip_rows: skipVal, total_inserted: skipVal });
|
|
}}
|
|
disabled={!urlInput.trim()}
|
|
className="flex-1"
|
|
>
|
|
<Database className="w-4 h-4 mr-2" />
|
|
🤖 Mode autonome
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{state.status === "resume_prompt" && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 text-primary">
|
|
<SkipForward className="w-5 h-5" />
|
|
<span className="font-medium">Reprendre l'import ?</span>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
{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)} ?
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button onClick={() => {
|
|
const file = pendingFileRef.current;
|
|
if (file) processFile(file, state.resumeFrom);
|
|
}}>
|
|
<SkipForward className="w-4 h-4 mr-2" />
|
|
Reprendre
|
|
</Button>
|
|
<Button variant="outline" onClick={() => {
|
|
clearCheckpoint(type);
|
|
const file = pendingFileRef.current;
|
|
if (file) processFile(file, 0);
|
|
}}>
|
|
Recommencer à zéro
|
|
</Button>
|
|
<Button variant="ghost" onClick={() => reset()}>
|
|
Annuler
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{(state.status === "parsing" || state.status === "uploading") && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="flex items-center gap-2">
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
{state.status === "parsing" ? "Lecture du fichier..." :
|
|
state.resumeFrom > 0 ? `Import en cours (reprise après ${formatNum(state.resumeFrom)} lignes)...` : "Import en cours..."}
|
|
</span>
|
|
<span className="font-mono text-muted-foreground">
|
|
{formatNum(state.processedRows)} / {formatNum(state.totalRows)}
|
|
</span>
|
|
</div>
|
|
<Progress value={progress} className="h-3" />
|
|
<div className="flex justify-between text-xs text-muted-foreground">
|
|
<span>{progress}% — {getStats().eta}</span>
|
|
<span>
|
|
⚡ {getStats().speed} · ✓ {formatNum(state.insertedRows)} insérées · ✗ {formatNum(state.errorRows)} erreurs
|
|
</span>
|
|
</div>
|
|
<Button variant="destructive" size="sm" onClick={() => reset(true)}>
|
|
Annuler (garder checkpoint)
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{state.status === "done" && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 text-primary">
|
|
<CheckCircle2 className="w-5 h-5" />
|
|
<span className="font-medium">Import terminé!</span>
|
|
</div>
|
|
<div className="text-sm space-y-1">
|
|
<p>Fichier : {state.fileName}</p>
|
|
<p>Total : {formatNum(state.totalRows)} lignes</p>
|
|
<p>Insérées : {formatNum(state.insertedRows)}</p>
|
|
{state.errorRows > 0 && (
|
|
<p className="text-destructive">Erreurs : {formatNum(state.errorRows)}</p>
|
|
)}
|
|
</div>
|
|
{state.errorMessages.length > 0 && (
|
|
<div className="bg-destructive/10 rounded-lg p-3 text-xs space-y-1">
|
|
{state.errorMessages.map((msg, i) => (
|
|
<p key={i} className="text-destructive">{msg}</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
<Button variant="outline" onClick={() => reset()}>
|
|
Nouvel import
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{state.status === "error" && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 text-destructive">
|
|
<AlertCircle className="w-5 h-5" />
|
|
<span className="font-medium">Erreur</span>
|
|
</div>
|
|
{state.errorMessages.map((msg, i) => (
|
|
<p key={i} className="text-sm text-destructive">{msg}</p>
|
|
))}
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={() => { reset(true); }}>
|
|
Sélectionner le fichier pour reprendre
|
|
</Button>
|
|
<Button variant="ghost" onClick={() => reset()}>
|
|
Abandonner
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Log Panel */}
|
|
{importLogs.length > 0 && (
|
|
<div className="space-y-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowLogs(!showLogs)}
|
|
className="flex items-center gap-2 text-xs"
|
|
>
|
|
<Terminal className="w-4 h-4" />
|
|
{showLogs ? "Masquer" : "Afficher"} les logs ({importLogs.length})
|
|
</Button>
|
|
{showLogs && (
|
|
<div className="bg-muted/50 border border-border rounded-lg p-3 max-h-64 overflow-y-auto font-mono text-xs space-y-0.5">
|
|
{importLogs.map((line, i) => (
|
|
<div key={i} className={line.includes("❌") || line.includes("💥") ? "text-destructive" : line.includes("✅") ? "text-primary" : "text-muted-foreground"}>
|
|
{line}
|
|
</div>
|
|
))}
|
|
<div ref={logEndRef} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CsvSplitter() {
|
|
const [splitting, setSplitting] = useState(false);
|
|
const [progress, setProgress] = useState({ message: "", current: 0, total: 0, files: 0 });
|
|
const fileInputRef = useRef<HTMLInputElement>(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 (
|
|
<div className="bg-card border border-border rounded-2xl p-6 space-y-4">
|
|
<div className="flex items-start gap-4">
|
|
<div className="w-12 h-12 rounded-xl bg-accent/50 flex items-center justify-center flex-shrink-0">
|
|
<Scissors className="w-6 h-6 text-muted-foreground" />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold text-lg">Découper un CSV</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
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).
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".csv,.tsv,.txt"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) splitFile(file);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
|
|
{!splitting && !progress.message && (
|
|
<Button onClick={() => fileInputRef.current?.click()} className="w-full" variant="outline" size="lg">
|
|
<Scissors className="w-5 h-5 mr-2" />
|
|
Sélectionner un CSV à découper
|
|
</Button>
|
|
)}
|
|
|
|
{splitting && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
{progress.message}
|
|
</div>
|
|
<Progress value={pct} className="h-3" />
|
|
<div className="flex justify-between text-xs text-muted-foreground">
|
|
<span>{progress.current.toLocaleString("fr-CA")} lignes lues</span>
|
|
<span>{progress.files} fichiers créés</span>
|
|
</div>
|
|
<Button variant="destructive" size="sm" onClick={() => { abortRef.current = true; }}>
|
|
Annuler
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{!splitting && progress.message && (
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-primary font-medium">{progress.message}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Importez ensuite chaque partie via les cartes ci-dessus.
|
|
</p>
|
|
<Button variant="outline" size="sm" onClick={() => setProgress({ message: "", current: 0, total: 0, files: 0 })}>
|
|
Découper un autre fichier
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const AdminImport = () => {
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<div className="container max-w-3xl py-12 space-y-8">
|
|
<div>
|
|
<h1 className="font-display text-3xl font-bold">Import de données</h1>
|
|
<p className="text-muted-foreground mt-2">
|
|
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.
|
|
</p>
|
|
<div className="mt-3 p-3 bg-accent/50 rounded-lg text-sm text-muted-foreground">
|
|
<strong>Important :</strong> Importez d'abord les adresses, puis la fibre (dépendance FK).
|
|
Supporte CSV (virgule) et TSV (tabulation). Les doublons sont mis à jour automatiquement (upsert).
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<ImportCard
|
|
type="addresses"
|
|
label="1. Adresses CTOP"
|
|
description="Fichier CSV/TSV des adresses du Québec (identifiant_unique_adresse, adresse_formatee, etc.)"
|
|
/>
|
|
<ImportCard
|
|
type="fiber"
|
|
label="2. Disponibilité fibre"
|
|
description="Fichier CSV/TSV de couverture fibre (uuidadresse, zone_tarifaire, max_speed, etc.)"
|
|
/>
|
|
</div>
|
|
|
|
<div className="border-t border-border pt-6">
|
|
<h2 className="text-xl font-semibold mb-4">Outils</h2>
|
|
<CsvSplitter />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminImport;
|