// ImportModal.tsx — Fenêtre d'import de données (Inventaire, Établissements, Utilisateurs) // Design: Corporate Modernism — Itinova Budget SI import { useState, useRef, useEffect } from 'react'; import { trpc } from '@/lib/trpc'; import { useAnnee } from '../contexts/AnneeContext'; import { Upload, X, Monitor, Building2, Users, CheckCircle2, AlertCircle, FileSpreadsheet, Info, Pencil, Save, RotateCcw, ChevronDown, ChevronUp, BrainCircuit, } from 'lucide-react'; import { toast } from 'sonner'; // ─── Helper : lecture fichier → tableau de lignes (CSV ou XLSX) ──────────────── async function fileToRows(file: File): Promise { const isXlsx = file.name.match(/\.(xlsx|xls)$/i); if (isXlsx) { // XLSX pèse plusieurs centaines de Ko : il n'est requis qu'à l'ouverture // d'un fichier d'import et ne doit pas ralentir l'affichage initial. const XLSX = await import('xlsx'); const buffer = await file.arrayBuffer(); const wb = XLSX.read(buffer, { type: 'array', cellDates: true }); const ws = wb.Sheets[wb.SheetNames[0]]; // sheet_to_json avec header:1 retourne un tableau de tableaux const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }); return rows.map(r => r.map(c => String(c ?? '').trim())); } else { // CSV : détection du séparateur const text = await file.text(); const lines = text.split('\n').filter(l => l.trim()); const sep = lines[0].includes(';') ? ';' : ','; return lines.map(l => l.split(sep).map(c => c.trim().replace(/"/g, '').replace(/[\r\n]/g, ''))); } } // ─── Helpers date / vétusté ────────────────────────────────────────────────── const DATE_REF_IMPORT = new Date('2027-01-01'); function parseImportDate(val: string): Date | null { if (!val || val === '-' || val.trim() === '') return null; const clean = val.trim(); const dmy = clean.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2,4})$/); if (dmy) { const [, d, m, y] = dmy; const year = y.length === 2 ? (parseInt(y) > 50 ? 1900 + parseInt(y) : 2000 + parseInt(y)) : parseInt(y); return new Date(year, parseInt(m) - 1, parseInt(d)); } const ymd = clean.match(/^(\d{4})-(\d{2})-(\d{2})/); if (ymd) return new Date(clean); const dt = new Date(clean); return isNaN(dt.getTime()) ? null : dt; } function calcAgeAns(dateStr: string): number | null { const d = parseImportDate(dateStr); if (!d) return null; const diffMs = DATE_REF_IMPORT.getTime() - d.getTime(); if (diffMs < 0) return null; return Math.floor(diffMs / (1000 * 60 * 60 * 24 * 365.25)); } // ─── Types ──────────────────────────────────────────────────────────────────── export interface Etablissement { code: string; nom: string; groupe?: string; ville?: string; actif: boolean; } export interface Utilisateur { id: string; nom: string; prenom: string; email: string; role: 'admin' | 'standard' | 'lecture'; etablissements: string[]; // codes établissements actif: boolean; } // ─── Storage helpers ────────────────────────────────────────────────────────── const ETAB_KEY = 'budgetsi_etablissements'; const USER_KEY = 'budgetsi_utilisateurs'; const IMPORT_PROMPT_KEY = 'budgetsi_import_prompt'; const DEFAULT_IMPORT_PROMPT = `# Processus d'import de l'inventaire ISI-APP ## Format du fichier source - Export CSV depuis ISI-APP (séparateur : point-virgule ou virgule) - Encodage : UTF-8 ou Windows-1252 - Première ligne : en-têtes de colonnes ## Extraction du code établissement - Colonne source : "libellé" (1ère colonne) - Règle : prendre les 8 premiers caractères du libellé - Exemple : libellé "1083MAS-F4833" → code établissement "1083MAS" - Exemple : libellé "3069UDA-P5191" → code établissement "3069UDA" ## Extraction du nom établissement - Colonne source : "entité" - Utiliser la valeur telle quelle comme nom de l'établissement ## Détection du type de poste (fixe / portable) - Colonne source : "libellé" - PC fixe : le libellé contient "-F" ou "-FIX" après le code établissement Exemple : 1083SYL-F4833 → PC Fixe - PC portable : le libellé contient "-P" ou "-POR" après le code établissement Exemple : 3069UDA-P5191 → PC Portable ## Calcul de la vétusté - Utiliser en priorité la colonne "date de mise en service" - Si cette colonne est vide ou absente, utiliser la colonne "date d'achat" - L'âge en années est calculé depuis cette date jusqu'à la date de référence (01/01/2027) ## Règles de traitement 1. Les établissements uniques (code + nom) sont extraits et enregistrés dans la liste des établissements 2. Les établissements déjà présents ne sont pas écrasés (fusion intelligente) 3. Les lignes sans libellé ou sans entité sont ignorées 4. Chaque ligne = un poste (un poste = une ligne) ## Comportement attendu - Créer ou mettre à jour la liste des établissements (code 8 car. + nom depuis "entité") - Classifier chaque poste en fixe ou portable selon le libellé - Calculer l'âge à partir de la date de mise en service (ou date d'achat si vide) - Enregistrer les métadonnées du fichier importé (nom, date, nb lignes) - Afficher un résumé : nb lignes traitées, nb établissements détectés, nb fixes, nb portables`; export function loadImportPrompt(): string { try { const raw = localStorage.getItem(IMPORT_PROMPT_KEY); return raw ? JSON.parse(raw) : DEFAULT_IMPORT_PROMPT; } catch { return DEFAULT_IMPORT_PROMPT; } } export function saveImportPrompt(prompt: string) { localStorage.setItem(IMPORT_PROMPT_KEY, JSON.stringify(prompt)); } export function loadEtablissements(): Etablissement[] { try { const raw = localStorage.getItem(ETAB_KEY); return raw ? JSON.parse(raw) : []; } catch { return []; } } export function saveEtablissements(data: Etablissement[]) { localStorage.setItem(ETAB_KEY, JSON.stringify(data)); } export function loadUtilisateurs(): Utilisateur[] { try { const raw = localStorage.getItem(USER_KEY); return raw ? JSON.parse(raw) : []; } catch { return []; } } export function saveUtilisateurs(data: Utilisateur[]) { localStorage.setItem(USER_KEY, JSON.stringify(data)); } // ─── Sub-components ─────────────────────────────────────────────────────────── type ImportTab = 'inventaire' | 'etablissements' | 'utilisateurs'; interface DropZoneProps { label: string; description: string; accept: string; onFile: (file: File) => void; status: 'idle' | 'success' | 'error'; statusMsg?: string; } function DropZone({ label, description, accept, onFile, status, statusMsg }: DropZoneProps) { const inputRef = useRef(null); const [dragging, setDragging] = useState(false); const handleFile = (file: File) => { if (!file) return; onFile(file); }; return (
inputRef.current?.click()} onDragOver={e => { e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={e => { e.preventDefault(); setDragging(false); const file = e.dataTransfer.files[0]; if (file) handleFile(file); }} > { const f = e.target.files?.[0]; if (f) handleFile(f); e.target.value = ''; }} /> {status === 'success' ? ( ) : status === 'error' ? ( ) : ( )}

{label}

{description}

{statusMsg && (

{statusMsg}

)} {status === 'idle' && ( )}
); } // ─── Main Component ─────────────────────────────────────────────────────────── interface ImportModalProps { open: boolean; onClose: () => void; } export function ImportModal({ open, onClose }: ImportModalProps) { const { annee } = useAnnee(); const utils = trpc.useUtils(); // Ne faire la requête que quand la modale est ouverte (et non sur /login) const isLoginPage = typeof window !== 'undefined' && window.location.pathname === '/login'; const { data: inventaireMetaList } = trpc.inventaire.listMeta.useQuery(undefined, { enabled: open && !isLoginPage, retry: false, }); const importInventaireMutation = trpc.inventaire.import.useMutation({ onSuccess: () => { utils.inventaire.get.invalidate(); utils.inventaire.listMeta.invalidate(); utils.inventaire.getStats.invalidate(); window.dispatchEvent(new CustomEvent('budgetsi_inventaire_updated', { detail: { annee } })); }, }); const upsertEtabMutation = trpc.etablissements.upsert.useMutation({ onSuccess: () => utils.etablissements.list.invalidate(), }); const importUsersMutation = trpc.users.importBulk.useMutation(); const [tab, setTab] = useState('inventaire'); const [inventaireStatus, setInventaireStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [inventaireMsg, setInventaireMsg] = useState(''); const [etabStatus, setEtabStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [etabMsg, setEtabMsg] = useState(''); const [userStatus, setUserStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [userMsg, setUserMsg] = useState(''); // ── Prompt de processus d'import ──────────────────────────────────────────── const [importPrompt, setImportPrompt] = useState(() => loadImportPrompt()); const [promptDraft, setPromptDraft] = useState(''); const [promptEditing, setPromptEditing] = useState(false); const [promptExpanded, setPromptExpanded] = useState(false); const [promptSaved, setPromptSaved] = useState(false); // Sync prompt depuis localStorage à l'ouverture useEffect(() => { if (open) { const p = loadImportPrompt(); setImportPrompt(p); setPromptDraft(p); setPromptEditing(false); } }, [open]); const handlePromptEdit = () => { setPromptDraft(importPrompt); setPromptEditing(true); setPromptExpanded(true); }; const handlePromptSave = () => { saveImportPrompt(promptDraft); setImportPrompt(promptDraft); setPromptEditing(false); setPromptSaved(true); toast.success('Prompt enregistré', { description: 'La configuration du processus d\'import a été sauvegardée.' }); setTimeout(() => setPromptSaved(false), 2500); }; const handlePromptCancel = () => { setPromptDraft(importPrompt); setPromptEditing(false); }; const handlePromptReset = () => { if (!confirm('Réinitialiser le prompt aux valeurs par défaut ?')) return; setPromptDraft(DEFAULT_IMPORT_PROMPT); saveImportPrompt(DEFAULT_IMPORT_PROMPT); setImportPrompt(DEFAULT_IMPORT_PROMPT); setPromptEditing(false); toast.info('Prompt réinitialisé'); }; if (!open) return null; // ── Inventaire ISI-APP ────────────────────────────────────────────────────── // Colonnes attendues dans l'export ISI-APP : // code_etab (ou code_etablissement, code etab…) — obligatoire // nom_etab (ou nom_etablissement, libelle etab, etablissement…) — obligatoire // type (fixe / portable / bureau / laptop…) // date_achat, modele, fabricant — optionnels const handleInventaireFile = async (file: File) => { try { if (!file.name.match(/\.(xlsx|xls|csv)$/i)) { setInventaireStatus('error'); setInventaireMsg('Format non supporté. Utilisez .xlsx, .xls ou .csv'); return; } // Lecture unifiée CSV + XLSX via SheetJS const rows = await fileToRows(file); if (rows.length < 2) { setInventaireStatus('error'); setInventaireMsg('Fichier vide ou format invalide (moins de 2 lignes)'); return; } const rawHeaders = rows[0].map(h => h.toLowerCase().replace(/[\r\n]/g, '')); // ── Format ISI-APP : extraction par libellé et entité ────────────────── // Colonne "libellé" : contient le code poste (ex: 1083MAS-F4833) // → code établissement = 8 premiers caractères avant le tiret // → type = F/FIX → fixe, P/POR → portable // Colonne "entité" : nom de l'établissement // Colonne "date de mise en service" (priorité) ou "date d'achat" : pour la vétusté const libelleIdx = rawHeaders.findIndex(h => h === 'libellé' || h === 'libelle' || h === 'label' || h.includes('libellé') || h.includes('libelle') ); const entiteIdx = rawHeaders.findIndex(h => h === 'entité' || h === 'entite' || h === 'entity' || h.includes('entité') || h.includes('entite') ); const dateMiseEnServiceIdx = rawHeaders.findIndex(h => h.includes('mise en service') || h.includes('miseenservice') || h.includes('mise_en_service') || h.includes('service') ); const dateAchatIdx = rawHeaders.findIndex(h => h.includes('achat') || h.includes('purchase') || h.includes('date_achat') ); const groupeIdx = rawHeaders.findIndex(h => h.includes('groupe') || h.includes('association') || h.includes('groupement') ); const villeIdx = rawHeaders.findIndex(h => h.includes('ville') || h.includes('commune') || h.includes('localite') || h.includes('localité') ); // Fallback : si pas de colonne libellé/entité, chercher code/nom classiques const codeIdxFallback = libelleIdx === -1 ? rawHeaders.findIndex(h => h === 'code' || h.includes('code_etab') || h.includes('code etablissement') || (h.startsWith('code') && h.length < 20) ) : -1; const nomIdxFallback = entiteIdx === -1 ? rawHeaders.findIndex(h => h === 'nom' || h.includes('nom_etab') || h.includes('nom etablissement') || h.includes('etablissement') || (h.startsWith('nom') && h.length < 20) ) : -1; const useIsiFormat = libelleIdx !== -1 && entiteIdx !== -1; const useClassicFormat = !useIsiFormat && codeIdxFallback !== -1 && nomIdxFallback !== -1; if (!useIsiFormat && !useClassicFormat) { setInventaireStatus('error'); setInventaireMsg( `Colonnes introuvables. En-têtes détectés : ${rawHeaders.slice(0, 10).join(', ')}…\n` + 'Format ISI-APP attendu : colonnes "libellé" et "entité". ' + 'Format alternatif : colonnes "code" et "nom".' ); return; } // Index colonnes supplémentaires ISI-APP const typeMaterielIdx = rawHeaders.findIndex(h => h === 'type de matériel' || h === 'type materiel' || h === 'type' || h.includes('type de mat') || h.includes('type mat') ); const modeleIdx = rawHeaders.findIndex(h => h === 'modèle' || h === 'modele' || h === 'model' || h.includes('modèle') || h.includes('modele') ); const fabricantIdx = rawHeaders.findIndex(h => h === 'fabricant' || h === 'marque' || h === 'brand' || h.includes('fabricant') || h.includes('marque') ); // Compteurs pour le résumé let nbFixes = 0; let nbPortables = 0; // ── Structures pour stocker les postes complets par établissement ──────────── // etabData : code → { nom, fixes[], portables[] } interface PosteData { libelle: string; date_achat: string; age_ans: number | null; a_renouveler: boolean; modele: string; fabricant: string; } interface EtabData { nom: string; groupe?: string; ville?: string; fixes: PosteData[]; portables: PosteData[]; } const etabData = new Map(); for (const cols of rows.slice(1)) { let code = ''; let nom = ''; let typePoste: 'fixe' | 'portable' | 'autre' = 'autre'; if (useIsiFormat) { const libelle = cols[libelleIdx]?.trim() || ''; const dashIdx = libelle.indexOf('-'); code = dashIdx > 0 ? libelle.substring(0, dashIdx) : libelle.substring(0, 8); code = code.trim(); nom = cols[entiteIdx]?.trim() || ''; // Priorité 1 : colonne "Type de matériel" if (typeMaterielIdx >= 0) { const t = (cols[typeMaterielIdx] || '').toLowerCase(); if (t === 'fixe' || t === 'pc fixe' || t === 'desktop') typePoste = 'fixe'; else if (t === 'portable' || t === 'laptop' || t === 'pc portable' || t === 'notebook') typePoste = 'portable'; } // Fallback : détection par libellé if (typePoste === 'autre') { const afterDash = dashIdx > 0 ? libelle.substring(dashIdx + 1).toUpperCase() : ''; if (afterDash.startsWith('F') || afterDash.startsWith('FIX')) typePoste = 'fixe'; else if (afterDash.startsWith('P') || afterDash.startsWith('POR')) typePoste = 'portable'; } } else { code = cols[codeIdxFallback]?.trim() || ''; nom = cols[nomIdxFallback]?.trim() || ''; // Format classique : colonne type si disponible if (typeMaterielIdx >= 0) { const t = (cols[typeMaterielIdx] || '').toLowerCase(); if (t.includes('fixe') || t.includes('desktop')) typePoste = 'fixe'; else if (t.includes('portable') || t.includes('laptop')) typePoste = 'portable'; } } if (!code || !nom) continue; // Ignorer les postes hors production si statut présent // (on garde tout : en production + en stock) // Initialiser l'établissement si besoin if (!etabData.has(code)) { etabData.set(code, { nom, groupe: groupeIdx >= 0 ? cols[groupeIdx]?.trim() || undefined : undefined, ville: villeIdx >= 0 ? cols[villeIdx]?.trim() || undefined : undefined, fixes: [], portables: [], }); } // Calculer l'âge du poste const dateMES = dateMiseEnServiceIdx >= 0 ? cols[dateMiseEnServiceIdx]?.trim() || '' : ''; const dateAchat = dateAchatIdx >= 0 ? cols[dateAchatIdx]?.trim() || '' : ''; const dateRef = dateMES || dateAchat; // priorité mise en service const age = calcAgeAns(dateRef); const libelle = useIsiFormat ? cols[libelleIdx]?.trim() || '' : code; const modele = modeleIdx >= 0 ? cols[modeleIdx]?.trim() || '' : ''; const fabricant = fabricantIdx >= 0 ? cols[fabricantIdx]?.trim() || '' : ''; const poste: PosteData = { libelle, date_achat: dateRef, age_ans: age, a_renouveler: false, // sera recalculé dynamiquement par useBudgetData modele, fabricant, }; const etab = etabData.get(code)!; if (typePoste === 'fixe') { etab.fixes.push(poste); nbFixes++; } else if (typePoste === 'portable') { etab.portables.push(poste); nbPortables++; } else { // Type inconnu : on l'ignore dans les compteurs mais on ne le perd pas // On peut le mettre en fixe par défaut si besoin } } if (etabData.size === 0) { setInventaireStatus('error'); setInventaireMsg('Aucun établissement extrait. Vérifiez que les colonnes code/nom contiennent des données.'); return; } // ── Construire le payload pour tRPC ────────────────────────────────────── const postesPayload: Array<{ etablissementCode: string; libelle?: string | null; typePoste: 'fixe' | 'portable'; dateRef?: string | null; ageAns?: string | null; modele?: string | null; fabricant?: string | null }> = []; etabData.forEach((d, code) => { d.fixes.forEach(p => postesPayload.push({ etablissementCode: code, libelle: p.libelle || null, typePoste: 'fixe', dateRef: p.date_achat || null, ageAns: p.age_ans !== null ? String(p.age_ans) : null, modele: p.modele || null, fabricant: p.fabricant || null })); d.portables.forEach(p => postesPayload.push({ etablissementCode: code, libelle: p.libelle || null, typePoste: 'portable', dateRef: p.date_achat || null, ageAns: p.age_ans !== null ? String(p.age_ans) : null, modele: p.modele || null, fabricant: p.fabricant || null })); }); // ── Envoyer à la base de données via tRPC ────────────────────────────────── await importInventaireMutation.mutateAsync({ annee, filename: file.name, postes: postesPayload }); // ── Upsert établissements dans la BDD ────────────────────────────────────── for (const [code, info] of Array.from(etabData.entries())) { try { await upsertEtabMutation.mutateAsync({ code, nom: info.nom, groupe: info.groupe ?? null, ville: info.ville ?? null, actif: true }); } catch { /* ignore les erreurs d'upsert établissement */ } } const details = [ `${rows.length - 1} poste${rows.length - 1 > 1 ? 's' : ''}`, `${etabData.size} établissement${etabData.size > 1 ? 's' : ''} détectés`, `${nbFixes} fixes`, `${nbPortables} portables`, ].join(' — '); setInventaireStatus('success'); setInventaireMsg(`"${file.name}" importé — ${details}`); toast.success('Inventaire importé', { description: `${etabData.size} établissements — ${nbFixes} fixes, ${nbPortables} portables.`, }); } catch (err) { console.error('Import inventaire:', err); setInventaireStatus('error'); setInventaireMsg('Erreur lors de la lecture du fichier. Vérifiez le format CSV.'); } }; // ── Établissements ────────────────────────────────────────────────────────── const handleEtabFile = async (file: File) => { try { if (!file.name.match(/\.(xlsx|xls|csv)$/i)) { setEtabStatus('error'); setEtabMsg('Format non supporté. Utilisez .xlsx, .xls ou .csv'); return; } const text = await file.text(); const lines = text.split('\n').filter(l => l.trim()); if (lines.length < 2) { setEtabStatus('error'); setEtabMsg('Fichier vide ou format invalide'); return; } // Parsing CSV simple (séparateur ; ou ,) const sep = lines[0].includes(';') ? ';' : ','; const headers = lines[0].split(sep).map(h => h.trim().toLowerCase().replace(/"/g, '')); const codeIdx = headers.findIndex(h => h.includes('code')); const nomIdx = headers.findIndex(h => h.includes('nom') || h.includes('libelle') || h.includes('établissement')); const groupeIdx = headers.findIndex(h => h.includes('groupe') || h.includes('association')); const villeIdx = headers.findIndex(h => h.includes('ville') || h.includes('commune')); if (codeIdx === -1 || nomIdx === -1) { setEtabStatus('error'); setEtabMsg('Colonnes "code" et "nom" requises dans le fichier'); return; } const etabs: Etablissement[] = lines.slice(1).map(line => { const cols = line.split(sep).map(c => c.trim().replace(/"/g, '')); return { code: cols[codeIdx] || '', nom: cols[nomIdx] || '', groupe: groupeIdx >= 0 ? cols[groupeIdx] : undefined, ville: villeIdx >= 0 ? cols[villeIdx] : undefined, actif: true, }; }).filter(e => e.code && e.nom); // Upsert dans la BDD via tRPC let nbImported = 0; for (const etab of etabs) { try { await upsertEtabMutation.mutateAsync({ code: etab.code, nom: etab.nom, groupe: etab.groupe ?? null, ville: etab.ville ?? null, actif: etab.actif }); nbImported++; } catch { /* ignore */ } } // Aussi sauvegarder en localStorage pour compatibilité saveEtablissements(etabs); setEtabStatus('success'); setEtabMsg(`${nbImported} établissements importés`); toast.success('Établissements importés', { description: `${nbImported} établissements chargés` }); } catch { setEtabStatus('error'); setEtabMsg('Erreur lors de la lecture du fichier'); } }; // ── Utilisateurs ──────────────────────────────────────────────────────────── const handleUserFile = async (file: File) => { try { if (!file.name.match(/\.(xlsx|xls|csv)$/i)) { setUserStatus('error'); setUserMsg('Format non supporté. Utilisez .xlsx, .xls ou .csv'); return; } const text = await file.text(); const lines = text.split('\n').filter(l => l.trim()); if (lines.length < 2) { setUserStatus('error'); setUserMsg('Fichier vide ou format invalide'); return; } const sep = lines[0].includes(';') ? ';' : ','; const headers = lines[0].split(sep).map(h => h.trim().toLowerCase().replace(/"/g, '')); const nomIdx = headers.findIndex(h => h.includes('nom')); const prenomIdx = headers.findIndex(h => h.includes('prenom') || h.includes('prénom')); const emailIdx = headers.findIndex(h => h.includes('email') || h.includes('mail')); const roleIdx = headers.findIndex(h => h.includes('role') || h.includes('profil')); const etabIdx = headers.findIndex(h => h.includes('etablissement') || h.includes('code')); if (nomIdx === -1 || emailIdx === -1) { setUserStatus('error'); setUserMsg('Colonnes "nom" et "email" requises dans le fichier'); return; } const users: Utilisateur[] = lines.slice(1).map((line, i) => { const cols = line.split(sep).map(c => c.trim().replace(/"/g, '')); const roleRaw = roleIdx >= 0 ? cols[roleIdx].toLowerCase() : ''; const role: Utilisateur['role'] = roleRaw.includes('admin') ? 'admin' : roleRaw.includes('lecture') ? 'lecture' : 'standard'; const etabsRaw = etabIdx >= 0 ? cols[etabIdx] : ''; const etablissements = etabsRaw ? etabsRaw.split('|').map(e => e.trim()).filter(Boolean) : []; return { id: `user_${Date.now()}_${i}`, nom: cols[nomIdx] || '', prenom: prenomIdx >= 0 ? cols[prenomIdx] : '', email: cols[emailIdx] || '', role, etablissements, actif: true, }; }).filter(u => u.nom && u.email); // Import dans la BDD via tRPC const usersPayload = users.map(u => ({ login: u.email.split('@')[0].replace(/[^a-zA-Z0-9._-]/g, '') || `user_${Date.now()}`, password: 'Itinova2027!', email: u.email || null, firstName: u.prenom || null, lastName: u.nom || null, role: (u.role === 'lecture' ? 'readonly' : u.role) as 'admin' | 'standard' | 'readonly', })); try { const result = await importUsersMutation.mutateAsync(usersPayload); setUserStatus('success'); setUserMsg(`${result.created} utilisateurs créés, ${result.skipped} ignorés (login existant)`); toast.success('Utilisateurs importés', { description: `${result.created} créés — mot de passe par défaut : Itinova2027!` }); } catch (err) { // Fallback localStorage saveUtilisateurs(users); setUserStatus('success'); setUserMsg(`${users.length} utilisateurs importés (local)`); toast.success('Utilisateurs importés', { description: `${users.length} utilisateurs chargés` }); } } catch { setUserStatus('error'); setUserMsg('Erreur lors de la lecture du fichier'); } }; const tabs: { id: ImportTab; label: string; icon: React.ElementType; color: string }[] = [ { id: 'inventaire', label: 'Inventaire postes', icon: Monitor, color: 'text-blue-600' }, { id: 'etablissements', label: 'Établissements', icon: Building2, color: 'text-emerald-600' }, { id: 'utilisateurs', label: 'Utilisateurs', icon: Users, color: 'text-violet-600' }, ]; return (
{/* Header */}

Imports & Données

Importer des données depuis des fichiers CSV ou Excel

{/* Tabs */}
{tabs.map(t => { const Icon = t.icon; return ( ); })}
{/* Content */}
{tab === 'inventaire' && ( <> {/* ── Prompt de processus d'import ── */}
{/* En-tête du prompt */}
!promptEditing && setPromptExpanded(e => !e)} >

Prompt de processus d'import

Instructions de traitement du fichier ISI-APP

{!promptEditing && ( )} {!promptEditing && ( {promptExpanded ? : } )}
{/* Corps du prompt */} {(promptExpanded || promptEditing) && (
{promptEditing ? (