Checkpoint: Ajout du bouton "Imports & Données" au-dessus du bouton Paramètres dans la sidebar (icône Upload, couleur indigo). Création du composant ImportModal avec 3 onglets : Inventaire postes (ISI-APP), Établissements (import CSV), Utilisateurs (import CSV). Enrichissement de la page Paramètres avec 3 onglets : Paramètres (contenu existant), Établissements (CRUD + activer/désactiver), Utilisateurs (CRUD + rattachement multi-établissements par checkboxes).

This commit is contained in:
Manus
2026-06-04 14:11:08 +00:00
parent 6b81d689b1
commit 4ff5228ac3
3 changed files with 1214 additions and 211 deletions

View File

@@ -13,6 +13,7 @@
// └── Paramètres → /parametres
import { useState } from 'react';
import { ImportModal } from './ImportModal';
import { useLocation } from 'wouter';
import {
BarChart3,
@@ -30,6 +31,7 @@ import {
PanelLeftClose,
PanelLeftOpen,
TableProperties,
Upload,
} from 'lucide-react';
// Palette de couleurs par section principale
@@ -203,6 +205,7 @@ interface AppSidebarProps {
export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
const [location, navigate] = useLocation();
const [openMenus, setOpenMenus] = useState<Set<string>>(() => getInitialOpenSections(location));
const [showImport, setShowImport] = useState(false);
// Mode accordéon : ferme les autres sections du même niveau lors de l'ouverture
const toggleSection = (id: string) => {
@@ -385,8 +388,23 @@ export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
})}
</nav>
{/* Paramètres — entrée bien visible en bas de sidebar */}
<div className="px-2 pb-3 pt-2 border-t border-white/10">
{/* Imports & Données + Paramètres — entrées bien visibles en bas de sidebar */}
<div className="px-2 pb-3 pt-2 border-t border-white/10 space-y-1">
{/* Bouton Imports & Données */}
<button
onClick={() => setShowImport(true)}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg transition-all duration-150 text-left group text-white/70 hover:bg-white/12 hover:text-white"
>
<div className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 transition-colors bg-indigo-500/30 group-hover:bg-indigo-500/50">
<Upload className="w-3.5 h-3.5 text-indigo-200" />
</div>
{!collapsed && (
<span className="flex-1 text-xs font-semibold tracking-wide truncate">Imports &amp; Données</span>
)}
</button>
{/* Modale d'import */}
<ImportModal open={showImport} onClose={() => setShowImport(false)} />
<button
onClick={() => navigate(SETTINGS_ITEM.path)}
className={`w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg transition-all duration-150 text-left group ${

View File

@@ -0,0 +1,450 @@
// ImportModal.tsx — Fenêtre d'import de données (Inventaire, Établissements, Utilisateurs)
// Design: Corporate Modernism — Itinova Budget SI
import { useState, useRef } from 'react';
import {
Upload,
X,
Monitor,
Building2,
Users,
CheckCircle2,
AlertCircle,
FileSpreadsheet,
Info,
} from 'lucide-react';
import { toast } from 'sonner';
// ─── 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';
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 [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('');
if (!open) return null;
// ── Inventaire ISI-APP ──────────────────────────────────────────────────────
const handleInventaireFile = async (file: File) => {
try {
// Validation basique du format
if (!file.name.match(/\.(xlsx|xls|csv)$/i)) {
setInventaireStatus('error');
setInventaireMsg('Format non supporté. Utilisez .xlsx, .xls ou .csv');
return;
}
// Stocker le nom du fichier importé
localStorage.setItem('budgetsi_inventaire_import', JSON.stringify({
filename: file.name,
date: new Date().toISOString(),
size: file.size,
}));
setInventaireStatus('success');
setInventaireMsg(`Fichier "${file.name}" importé avec succès`);
toast.success('Inventaire importé', { description: `Fichier ${file.name} enregistré` });
} catch {
setInventaireStatus('error');
setInventaireMsg('Erreur lors de l\'import du fichier');
}
};
// ── É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);
saveEtablissements(etabs);
setEtabStatus('success');
setEtabMsg(`${etabs.length} établissements importés`);
toast.success('Établissements importés', { description: `${etabs.length} é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);
saveUtilisateurs(users);
setUserStatus('success');
setUserMsg(`${users.length} utilisateurs importés`);
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' && (
<>
<div className="flex items-start gap-3 p-4 bg-blue-50 border border-blue-200 rounded-xl">
<Info className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-semibold mb-1">Inventaire des postes (ISI-APP)</p>
<p className="text-xs text-blue-700">
Importez l'export de l'inventaire depuis ISI-APP au format Excel (.xlsx) ou CSV (.csv).
Les colonnes attendues sont : <strong>Code établissement, Nom, Type (fixe/portable), Date achat, Modèle</strong>.
</p>
</div>
</div>
<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>
);
}

File diff suppressed because it is too large Load Diff