gigafibre-fsm/apps/website/src/pages/AdminImport.tsx
louispaulb 6620652900 merge: import site-web-targo into apps/website/ (4 commits preserved)
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>
2026-03-28 08:09:15 -04:00

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;