Files
itinova-budget-si/client/src/components/ImportModal.tsx

988 lines
43 KiB
TypeScript

// 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<string[][]> {
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<string[]>(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<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const handleFile = (file: File) => {
if (!file) return;
onFile(file);
};
return (
<div
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all duration-200 ${
dragging
? 'border-blue-400 bg-blue-50'
: status === 'success'
? 'border-emerald-400 bg-emerald-50'
: status === 'error'
? 'border-red-400 bg-red-50'
: 'border-border hover:border-blue-300 hover:bg-blue-50/40'
}`}
onClick={() => 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);
}}
>
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) handleFile(f); e.target.value = ''; }}
/>
{status === 'success' ? (
<CheckCircle2 className="w-10 h-10 text-emerald-500 mx-auto mb-3" />
) : status === 'error' ? (
<AlertCircle className="w-10 h-10 text-red-500 mx-auto mb-3" />
) : (
<FileSpreadsheet className="w-10 h-10 text-blue-400 mx-auto mb-3" />
)}
<p className="font-semibold text-sm text-foreground">{label}</p>
<p className="text-xs text-muted-foreground mt-1">{description}</p>
{statusMsg && (
<p className={`text-xs mt-2 font-medium ${status === 'success' ? 'text-emerald-600' : 'text-red-600'}`}>
{statusMsg}
</p>
)}
{status === 'idle' && (
<button className="mt-3 inline-flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors">
<Upload className="w-3.5 h-3.5" />
Choisir un fichier
</button>
)}
</div>
);
}
// ─── 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<ImportTab>('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<string>(() => loadImportPrompt());
const [promptDraft, setPromptDraft] = useState<string>('');
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<string, EtabData>();
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}>
<div className="bg-background rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col border border-border">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-blue-100 flex items-center justify-center">
<Upload className="w-4.5 h-4.5 text-blue-600" />
</div>
<div>
<h2 className="font-bold text-base text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
Imports &amp; Données
</h2>
<p className="text-xs text-muted-foreground">Importer des données depuis des fichiers CSV ou Excel</p>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg flex items-center justify-center text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-border px-6">
{tabs.map(t => {
const Icon = t.icon;
return (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px ${
tab === t.id
? `border-blue-600 ${t.color}`
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<Icon className="w-4 h-4" />
{t.label}
</button>
);
})}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{tab === 'inventaire' && (
<>
{/* ── Prompt de processus d'import ── */}
<div className="border border-border rounded-xl overflow-hidden">
{/* En-tête du prompt */}
<div
className="flex items-center justify-between px-4 py-3 bg-muted/40 cursor-pointer select-none"
onClick={() => !promptEditing && setPromptExpanded(e => !e)}
>
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-md bg-blue-100 flex items-center justify-center flex-shrink-0">
<BrainCircuit className="w-3.5 h-3.5 text-blue-600" />
</div>
<div>
<p className="text-sm font-semibold text-foreground">Prompt de processus d'import</p>
<p className="text-xs text-muted-foreground">Instructions de traitement du fichier ISI-APP</p>
</div>
</div>
<div className="flex items-center gap-2">
{!promptEditing && (
<button
onClick={e => { e.stopPropagation(); handlePromptEdit(); }}
className="flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-lg bg-blue-100 text-blue-700 hover:bg-blue-200 transition-colors font-medium"
title="Modifier le prompt"
>
<Pencil className="w-3 h-3" />
Modifier
</button>
)}
{!promptEditing && (
<span className="text-muted-foreground">
{promptExpanded
? <ChevronUp className="w-4 h-4" />
: <ChevronDown className="w-4 h-4" />}
</span>
)}
</div>
</div>
{/* Corps du prompt */}
{(promptExpanded || promptEditing) && (
<div className="border-t border-border">
{promptEditing ? (
<div className="p-4 space-y-3">
<textarea
value={promptDraft}
onChange={e => setPromptDraft(e.target.value)}
rows={16}
className="w-full px-3 py-2.5 text-xs font-mono border border-border rounded-lg bg-muted/30 focus:outline-none focus:ring-2 focus:ring-blue-300 focus:border-blue-400 resize-y leading-relaxed"
placeholder="Décrivez ici le processus d'import, les colonnes attendues, les règles de traitement"
spellCheck={false}
/>
<div className="flex items-center justify-between">
<button
onClick={handlePromptReset}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RotateCcw className="w-3 h-3" />
Réinitialiser par défaut
</button>
<div className="flex items-center gap-2">
<button
onClick={handlePromptCancel}
className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/70 text-muted-foreground transition-colors"
>
Annuler
</button>
<button
onClick={handlePromptSave}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg font-medium transition-all ${
promptSaved
? 'bg-emerald-600 text-white'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
<Save className="w-3 h-3" />
{promptSaved ? 'Enregistré !' : 'Enregistrer'}
</button>
</div>
</div>
</div>
) : (
<pre className="px-4 py-3 text-xs font-mono text-muted-foreground whitespace-pre-wrap leading-relaxed max-h-48 overflow-y-auto bg-muted/20">{importPrompt}</pre>
)}
</div>
)}
</div>
{/* ── Indicateur imports par année ── */}
<div className="border border-border rounded-xl overflow-hidden">
<div className="px-4 py-2.5 bg-muted/30 border-b border-border">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">État des inventaires par année</p>
</div>
<div className="flex flex-wrap gap-2 p-3">
{[2025, 2026, 2027, 2028, 2029, 2030].map(yr => {
const metaRow = inventaireMetaList?.find(m => m.annee === yr);
const hasData = !!metaRow;
const meta = metaRow ? { filename: metaRow.filename, nbLignes: (metaRow.nbFixes ?? 0) + (metaRow.nbPortables ?? 0), date: metaRow.dateImport?.toISOString() ?? '' } : null;
return (
<div
key={yr}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-xs font-medium ${
yr === annee
? hasData
? 'bg-emerald-100 border-emerald-400 text-emerald-800'
: 'bg-blue-100 border-blue-400 text-blue-800'
: hasData
? 'bg-emerald-50 border-emerald-200 text-emerald-700'
: 'bg-muted/40 border-border text-muted-foreground'
}`}
title={meta ? `${meta.filename} — ${meta.nbLignes} lignes — ${new Date(meta.date).toLocaleDateString('fr-FR')}` : 'Aucun inventaire importé'}
>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${hasData ? 'bg-emerald-500' : 'bg-muted-foreground/40'}`} />
<span>{yr}</span>
{yr === annee && <span className="text-[10px] opacity-70">(actif)</span>}
</div>
);
})}
</div>
</div>
{/* ── Info résumée ── */}
<div className="flex items-start gap-3 p-3 bg-blue-50 border border-blue-200 rounded-xl">
<Info className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-blue-700">
Formats acceptés : <strong>.xlsx, .xls, .csv</strong> — Export ISI-APP.
Les établissements seront automatiquement extraits et enregistrés.
Consultez le prompt ci-dessus pour le détail des règles de traitement.
</p>
</div>
{/* ── Zone de dépôt ── */}
<DropZone
label="Glissez votre fichier d'inventaire ici"
description="Formats acceptés : .xlsx, .xls, .csv — Export ISI-APP"
accept=".xlsx,.xls,.csv"
onFile={handleInventaireFile}
status={inventaireStatus}
statusMsg={inventaireMsg}
/>
{inventaireStatus !== 'idle' && (
<button
onClick={() => { setInventaireStatus('idle'); setInventaireMsg(''); }}
className="text-xs text-muted-foreground hover:text-foreground underline"
>
Réinitialiser
</button>
)}
</>
)}
{tab === 'etablissements' && (
<>
<div className="flex items-start gap-3 p-4 bg-emerald-50 border border-emerald-200 rounded-xl">
<Info className="w-4 h-4 text-emerald-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-emerald-800">
<p className="font-semibold mb-1">Liste des établissements</p>
<p className="text-xs text-emerald-700">
Importez la liste des établissements au format CSV. Colonnes attendues :
<strong> code, nom</strong> (obligatoires), <em>groupe, ville</em> (optionnels).
Séparateur : <code>;</code> ou <code>,</code>.
</p>
</div>
</div>
<DropZone
label="Glissez votre fichier d'établissements ici"
description="Formats acceptés : .xlsx, .xls, .csv"
accept=".xlsx,.xls,.csv"
onFile={handleEtabFile}
status={etabStatus}
statusMsg={etabMsg}
/>
{etabStatus !== 'idle' && (
<button
onClick={() => { setEtabStatus('idle'); setEtabMsg(''); }}
className="text-xs text-muted-foreground hover:text-foreground underline"
>
Réinitialiser
</button>
)}
</>
)}
{tab === 'utilisateurs' && (
<>
<div className="flex items-start gap-3 p-4 bg-violet-50 border border-violet-200 rounded-xl">
<Info className="w-4 h-4 text-violet-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-violet-800">
<p className="font-semibold mb-1">Liste des utilisateurs</p>
<p className="text-xs text-violet-700">
Importez la liste des utilisateurs au format CSV. Colonnes attendues :
<strong> nom, email</strong> (obligatoires), <em>prenom, role (admin/standard/lecture), etablissements</em> (optionnels).
Pour plusieurs établissements, séparez les codes par <code>|</code> dans la colonne <em>etablissements</em>.
</p>
</div>
</div>
<DropZone
label="Glissez votre fichier d'utilisateurs ici"
description="Formats acceptés : .xlsx, .xls, .csv"
accept=".xlsx,.xls,.csv"
onFile={handleUserFile}
status={userStatus}
statusMsg={userMsg}
/>
{userStatus !== 'idle' && (
<button
onClick={() => { setUserStatus('idle'); setUserMsg(''); }}
className="text-xs text-muted-foreground hover:text-foreground underline"
>
Réinitialiser
</button>
)}
</>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-border flex justify-end">
<button
onClick={onClose}
className="px-5 py-2 text-sm rounded-lg bg-muted hover:bg-muted/80 text-foreground transition-colors font-medium"
>
Fermer
</button>
</div>
</div>
</div>
);
}