988 lines
43 KiB
TypeScript
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 & 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>
|
|
);
|
|
}
|