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>
);
}

View File

@@ -1,5 +1,6 @@
// Parametres.tsx — Page de paramétrage du calcul de renouvellement
// Parametres.tsx — Page de paramétrage global
// Design: Corporate Modernism — Itinova Budget SI 2027
// Onglets : Paramètres | Établissements | Utilisateurs
import { useState, useEffect } from 'react';
import { useLocation } from 'wouter';
@@ -15,12 +16,32 @@ import {
Info,
BarChart3,
Calendar,
Building2,
Users,
Plus,
Pencil,
Trash2,
X,
Check,
ShieldCheck,
Eye,
User,
} from 'lucide-react';
import { ANNEES_DISPONIBLES } from '../contexts/AnneeContext';
import { AppSidebar } from '../components/AppSidebar';
import { useParametres, PARAMETRES_DEFAULTS } from '../contexts/ParametresContext';
import { formatEuros } from '../lib/format';
import { toast } from 'sonner';
import {
loadEtablissements,
saveEtablissements,
loadUtilisateurs,
saveUtilisateurs,
type Etablissement,
type Utilisateur,
} from '../components/ImportModal';
// ─── Types locaux ─────────────────────────────────────────────────────────────
interface FieldState {
seuilFixesAns: number;
@@ -30,6 +51,10 @@ interface FieldState {
anneeDefaut: number;
}
type ActiveTab = 'parametres' | 'etablissements' | 'utilisateurs';
// ─── SliderInput ──────────────────────────────────────────────────────────────
function SliderInput({
label,
icon: Icon,
@@ -72,8 +97,6 @@ function SliderInput({
<span className="text-sm text-muted-foreground ml-1">{unit}</span>
</div>
</div>
{/* Slider */}
<div className="space-y-2">
<input
type="range"
@@ -89,8 +112,6 @@ function SliderInput({
<span>{max} {unit}</span>
</div>
</div>
{/* Input numérique direct */}
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground flex-shrink-0">Valeur exacte :</label>
<input
@@ -111,13 +132,568 @@ function SliderInput({
);
}
// ─── Onglet Établissements ────────────────────────────────────────────────────
function OngletEtablissements() {
const [etabs, setEtabs] = useState<Etablissement[]>([]);
const [editId, setEditId] = useState<string | null>(null);
const [editDraft, setEditDraft] = useState<Partial<Etablissement>>({});
const [showAdd, setShowAdd] = useState(false);
const [newEtab, setNewEtab] = useState<Partial<Etablissement>>({ actif: true });
const [search, setSearch] = useState('');
useEffect(() => {
setEtabs(loadEtablissements());
}, []);
const save = (data: Etablissement[]) => {
saveEtablissements(data);
setEtabs(data);
};
const toggleActif = (code: string) => {
save(etabs.map(e => e.code === code ? { ...e, actif: !e.actif } : e));
};
const startEdit = (e: Etablissement) => {
setEditId(e.code);
setEditDraft({ ...e });
};
const commitEdit = () => {
if (!editId || !editDraft.nom) return;
save(etabs.map(e => e.code === editId ? { ...e, ...editDraft } as Etablissement : e));
setEditId(null);
toast.success('Établissement modifié');
};
const deleteEtab = (code: string) => {
if (!confirm(`Supprimer l'établissement ${code} ?`)) return;
save(etabs.filter(e => e.code !== code));
toast.success('Établissement supprimé');
};
const addEtab = () => {
if (!newEtab.code || !newEtab.nom) {
toast.error('Code et nom obligatoires');
return;
}
if (etabs.some(e => e.code === newEtab.code)) {
toast.error('Ce code existe déjà');
return;
}
save([...etabs, { code: newEtab.code, nom: newEtab.nom, groupe: newEtab.groupe, ville: newEtab.ville, actif: true }]);
setNewEtab({ actif: true });
setShowAdd(false);
toast.success('Établissement ajouté');
};
const filtered = etabs.filter(e =>
!search || e.code.toLowerCase().includes(search.toLowerCase()) || e.nom.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="space-y-4">
<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" />
<p className="text-sm text-emerald-800">
Gérez la liste des établissements. Vous pouvez activer/désactiver, modifier ou supprimer des établissements.
Pour importer en masse, utilisez le bouton <strong>Imports &amp; Données</strong> dans la barre latérale.
</p>
</div>
{/* Barre d'actions */}
<div className="flex items-center gap-3">
<input
type="text"
placeholder="Rechercher par code ou nom…"
value={search}
onChange={e => setSearch(e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-card focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<button
onClick={() => setShowAdd(true)}
className="flex items-center gap-2 px-4 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors font-medium"
>
<Plus className="w-4 h-4" />
Ajouter
</button>
</div>
{/* Formulaire d'ajout */}
{showAdd && (
<div className="bg-card border border-emerald-200 rounded-xl p-4 space-y-3">
<p className="font-semibold text-sm text-foreground">Nouvel établissement</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground">Code *</label>
<input
type="text"
value={newEtab.code || ''}
onChange={e => setNewEtab(d => ({ ...d, code: e.target.value }))}
className="w-full mt-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Ex: ETB001"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Nom *</label>
<input
type="text"
value={newEtab.nom || ''}
onChange={e => setNewEtab(d => ({ ...d, nom: e.target.value }))}
className="w-full mt-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Nom de l'établissement"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Groupe</label>
<input
type="text"
value={newEtab.groupe || ''}
onChange={e => setNewEtab(d => ({ ...d, groupe: e.target.value }))}
className="w-full mt-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Ex: Itinova"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Ville</label>
<input
type="text"
value={newEtab.ville || ''}
onChange={e => setNewEtab(d => ({ ...d, ville: e.target.value }))}
className="w-full mt-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Ex: Lyon"
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<button onClick={() => setShowAdd(false)} className="px-3 py-1.5 text-sm rounded-lg bg-muted hover:bg-muted/70 text-muted-foreground">
Annuler
</button>
<button onClick={addEtab} className="px-4 py-1.5 text-sm rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 font-medium">
Ajouter
</button>
</div>
</div>
)}
{/* Tableau */}
{filtered.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Building2 className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="font-medium">Aucun établissement</p>
<p className="text-sm mt-1">Importez une liste via "Imports &amp; Données" ou ajoutez manuellement.</p>
</div>
) : (
<div className="bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Code</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Nom</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Groupe</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Ville</th>
<th className="text-center px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Actif</th>
<th className="text-right px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Actions</th>
</tr>
</thead>
<tbody>
{filtered.map((e, i) => (
<tr key={e.code} className={`border-b border-border last:border-0 ${i % 2 === 0 ? '' : 'bg-muted/20'}`}>
{editId === e.code ? (
<>
<td className="px-4 py-2">
<input value={editDraft.code || ''} onChange={ev => setEditDraft(d => ({ ...d, code: ev.target.value }))}
className="w-full px-2 py-1 text-xs border border-border rounded bg-muted/50" />
</td>
<td className="px-4 py-2">
<input value={editDraft.nom || ''} onChange={ev => setEditDraft(d => ({ ...d, nom: ev.target.value }))}
className="w-full px-2 py-1 text-xs border border-border rounded bg-muted/50" />
</td>
<td className="px-4 py-2">
<input value={editDraft.groupe || ''} onChange={ev => setEditDraft(d => ({ ...d, groupe: ev.target.value }))}
className="w-full px-2 py-1 text-xs border border-border rounded bg-muted/50" />
</td>
<td className="px-4 py-2">
<input value={editDraft.ville || ''} onChange={ev => setEditDraft(d => ({ ...d, ville: ev.target.value }))}
className="w-full px-2 py-1 text-xs border border-border rounded bg-muted/50" />
</td>
<td className="px-4 py-2 text-center"></td>
<td className="px-4 py-2 text-right">
<div className="flex items-center justify-end gap-1">
<button onClick={commitEdit} className="p-1.5 rounded bg-emerald-100 text-emerald-700 hover:bg-emerald-200">
<Check className="w-3.5 h-3.5" />
</button>
<button onClick={() => setEditId(null)} className="p-1.5 rounded bg-muted text-muted-foreground hover:bg-muted/70">
<X className="w-3.5 h-3.5" />
</button>
</div>
</td>
</>
) : (
<>
<td className="px-4 py-2.5 font-mono text-xs font-semibold text-blue-700">{e.code}</td>
<td className="px-4 py-2.5 text-foreground">{e.nom}</td>
<td className="px-4 py-2.5 text-muted-foreground text-xs">{e.groupe || '—'}</td>
<td className="px-4 py-2.5 text-muted-foreground text-xs">{e.ville || '—'}</td>
<td className="px-4 py-2.5 text-center">
<button
onClick={() => toggleActif(e.code)}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium transition-colors ${
e.actif ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}
>
{e.actif ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />}
{e.actif ? 'Actif' : 'Inactif'}
</button>
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex items-center justify-end gap-1">
<button onClick={() => startEdit(e)} className="p-1.5 rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={() => deleteEtab(e.code)} className="p-1.5 rounded text-muted-foreground hover:bg-red-50 hover:text-red-600 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
<div className="px-4 py-2 border-t border-border bg-muted/30 text-xs text-muted-foreground">
{filtered.length} établissement{filtered.length > 1 ? 's' : ''} affiché{filtered.length > 1 ? 's' : ''}
{search && ` sur ${etabs.length} au total`}
</div>
</div>
)}
</div>
);
}
// ─── Onglet Utilisateurs ──────────────────────────────────────────────────────
const ROLE_LABELS: Record<Utilisateur['role'], { label: string; icon: React.ElementType; color: string }> = {
admin: { label: 'Administrateur', icon: ShieldCheck, color: 'bg-red-100 text-red-700' },
standard: { label: 'Standard', icon: User, color: 'bg-blue-100 text-blue-700' },
lecture: { label: 'Lecture seule', icon: Eye, color: 'bg-slate-100 text-slate-600' },
};
interface UserFormProps {
initial?: Partial<Utilisateur>;
etabs: Etablissement[];
onSave: (u: Omit<Utilisateur, 'id'>) => void;
onCancel: () => void;
title: string;
}
function UserForm({ initial, etabs, onSave, onCancel, title }: UserFormProps) {
const [form, setForm] = useState<Partial<Utilisateur>>({
nom: '',
prenom: '',
email: '',
role: 'standard',
etablissements: [],
actif: true,
...initial,
});
const toggleEtab = (code: string) => {
setForm(f => ({
...f,
etablissements: f.etablissements?.includes(code)
? f.etablissements.filter(e => e !== code)
: [...(f.etablissements || []), code],
}));
};
const handleSave = () => {
if (!form.nom || !form.email) { toast.error('Nom et email obligatoires'); return; }
onSave({
nom: form.nom!,
prenom: form.prenom || '',
email: form.email!,
role: form.role || 'standard',
etablissements: form.etablissements || [],
actif: form.actif !== false,
});
};
return (
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<p className="font-semibold text-sm text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>{title}</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground">Nom *</label>
<input value={form.nom || ''} onChange={e => setForm(f => ({ ...f, nom: e.target.value }))}
className="w-full mt-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Dupont" />
</div>
<div>
<label className="text-xs text-muted-foreground">Prénom</label>
<input value={form.prenom || ''} onChange={e => setForm(f => ({ ...f, prenom: e.target.value }))}
className="w-full mt-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Jean" />
</div>
<div className="col-span-2">
<label className="text-xs text-muted-foreground">Email *</label>
<input type="email" value={form.email || ''} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
className="w-full mt-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="jean.dupont@itinova.fr" />
</div>
<div>
<label className="text-xs text-muted-foreground">Rôle</label>
<select value={form.role || 'standard'} onChange={e => setForm(f => ({ ...f, role: e.target.value as Utilisateur['role'] }))}
className="w-full mt-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
<option value="admin">Administrateur</option>
<option value="standard">Standard</option>
<option value="lecture">Lecture seule</option>
</select>
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={form.actif !== false} onChange={e => setForm(f => ({ ...f, actif: e.target.checked }))}
className="w-4 h-4 accent-primary" />
<span className="text-sm text-foreground">Compte actif</span>
</label>
</div>
</div>
{/* Rattachement établissements */}
<div>
<label className="text-xs text-muted-foreground block mb-2">
Établissements rattachés ({(form.etablissements || []).length} sélectionné{(form.etablissements || []).length > 1 ? 's' : ''})
</label>
{etabs.length === 0 ? (
<p className="text-xs text-muted-foreground italic">Aucun établissement disponible importez-en via "Imports &amp; Données"</p>
) : (
<div className="max-h-40 overflow-y-auto border border-border rounded-lg divide-y divide-border">
{etabs.map(e => (
<label key={e.code} className="flex items-center gap-3 px-3 py-2 hover:bg-muted/40 cursor-pointer">
<input
type="checkbox"
checked={(form.etablissements || []).includes(e.code)}
onChange={() => toggleEtab(e.code)}
className="w-4 h-4 accent-primary"
/>
<span className="font-mono text-xs text-blue-700 w-16 flex-shrink-0">{e.code}</span>
<span className="text-sm text-foreground">{e.nom}</span>
{e.groupe && <span className="text-xs text-muted-foreground ml-auto">{e.groupe}</span>}
</label>
))}
</div>
)}
</div>
<div className="flex gap-2 justify-end">
<button onClick={onCancel} className="px-3 py-1.5 text-sm rounded-lg bg-muted hover:bg-muted/70 text-muted-foreground">
Annuler
</button>
<button onClick={handleSave} className="px-4 py-1.5 text-sm rounded-lg bg-violet-600 text-white hover:bg-violet-700 font-medium">
Enregistrer
</button>
</div>
</div>
);
}
function OngletUtilisateurs() {
const [users, setUsers] = useState<Utilisateur[]>([]);
const [etabs, setEtabs] = useState<Etablissement[]>([]);
const [showAdd, setShowAdd] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [expandedUser, setExpandedUser] = useState<string | null>(null);
useEffect(() => {
setUsers(loadUtilisateurs());
setEtabs(loadEtablissements());
}, []);
const save = (data: Utilisateur[]) => {
saveUtilisateurs(data);
setUsers(data);
};
const addUser = (u: Omit<Utilisateur, 'id'>) => {
const newUser: Utilisateur = { ...u, id: `user_${Date.now()}` };
save([...users, newUser]);
setShowAdd(false);
toast.success('Utilisateur ajouté');
};
const editUser = (u: Omit<Utilisateur, 'id'>) => {
save(users.map(x => x.id === editId ? { ...u, id: editId! } : x));
setEditId(null);
toast.success('Utilisateur modifié');
};
const deleteUser = (id: string) => {
if (!confirm('Supprimer cet utilisateur ?')) return;
save(users.filter(u => u.id !== id));
toast.success('Utilisateur supprimé');
};
const toggleActif = (id: string) => {
save(users.map(u => u.id === id ? { ...u, actif: !u.actif } : u));
};
const filtered = users.filter(u =>
!search ||
u.nom.toLowerCase().includes(search.toLowerCase()) ||
u.prenom.toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase())
);
const editingUser = editId ? users.find(u => u.id === editId) : undefined;
return (
<div className="space-y-4">
<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" />
<p className="text-sm text-violet-800">
Gérez les utilisateurs et leurs rattachements aux établissements.
Un utilisateur peut être rattaché à <strong>plusieurs établissements</strong>.
Les rôles disponibles sont : <strong>Administrateur</strong>, <strong>Standard</strong> et <strong>Lecture seule</strong>.
</p>
</div>
<div className="flex items-center gap-3">
<input
type="text"
placeholder="Rechercher par nom, prénom ou email…"
value={search}
onChange={e => setSearch(e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-card focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<button
onClick={() => { setShowAdd(true); setEditId(null); }}
className="flex items-center gap-2 px-4 py-2 text-sm bg-violet-600 text-white rounded-lg hover:bg-violet-700 transition-colors font-medium"
>
<Plus className="w-4 h-4" />
Ajouter
</button>
</div>
{showAdd && (
<UserForm
etabs={etabs}
onSave={addUser}
onCancel={() => setShowAdd(false)}
title="Nouvel utilisateur"
/>
)}
{editId && editingUser && (
<UserForm
initial={editingUser}
etabs={etabs}
onSave={editUser}
onCancel={() => setEditId(null)}
title={`Modifier ${editingUser.prenom} ${editingUser.nom}`}
/>
)}
{filtered.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Users className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="font-medium">Aucun utilisateur</p>
<p className="text-sm mt-1">Importez une liste via "Imports &amp; Données" ou ajoutez manuellement.</p>
</div>
) : (
<div className="bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Utilisateur</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Email</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Rôle</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Établissements</th>
<th className="text-center px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Statut</th>
<th className="text-right px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Actions</th>
</tr>
</thead>
<tbody>
{filtered.map((u, i) => {
const roleInfo = ROLE_LABELS[u.role];
const RoleIcon = roleInfo.icon;
const isExpanded = expandedUser === u.id;
return (
<tr key={u.id} className={`border-b border-border last:border-0 ${i % 2 === 0 ? '' : 'bg-muted/20'}`}>
<td className="px-4 py-2.5">
<p className="font-medium text-foreground">{u.prenom} {u.nom}</p>
</td>
<td className="px-4 py-2.5 text-muted-foreground text-xs">{u.email}</td>
<td className="px-4 py-2.5">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${roleInfo.color}`}>
<RoleIcon className="w-3 h-3" />
{roleInfo.label}
</span>
</td>
<td className="px-4 py-2.5">
{u.etablissements.length === 0 ? (
<span className="text-xs text-muted-foreground italic">Aucun</span>
) : (
<button
onClick={() => setExpandedUser(isExpanded ? null : u.id)}
className="text-xs text-blue-600 hover:underline"
>
{u.etablissements.length} établissement{u.etablissements.length > 1 ? 's' : ''}
{isExpanded && (
<span className="block mt-1 text-muted-foreground font-normal text-left">
{u.etablissements.join(', ')}
</span>
)}
</button>
)}
</td>
<td className="px-4 py-2.5 text-center">
<button
onClick={() => toggleActif(u.id)}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium transition-colors ${
u.actif ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}
>
{u.actif ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />}
{u.actif ? 'Actif' : 'Inactif'}
</button>
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex items-center justify-end gap-1">
<button onClick={() => { setEditId(u.id); setShowAdd(false); }} className="p-1.5 rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={() => deleteUser(u.id)} className="p-1.5 rounded text-muted-foreground hover:bg-red-50 hover:text-red-600 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="px-4 py-2 border-t border-border bg-muted/30 text-xs text-muted-foreground">
{filtered.length} utilisateur{filtered.length > 1 ? 's' : ''} affiché{filtered.length > 1 ? 's' : ''}
{search && ` sur ${users.length} au total`}
</div>
</div>
)}
</div>
);
}
// ─── Page principale ──────────────────────────────────────────────────────────
export default function Parametres() {
const [, navigate] = useLocation();
const { parametres, setParametres, resetParametres } = useParametres();
const [draft, setDraft] = useState<FieldState>({ ...parametres });
const [saved, setSaved] = useState(false);
const [activeTab, setActiveTab] = useState<ActiveTab>('parametres');
// Sync si les paramètres changent depuis l'extérieur
useEffect(() => {
setDraft({ ...parametres });
}, [parametres]);
@@ -150,11 +726,16 @@ export default function Parametres() {
setDraft((d) => ({ ...d, [key]: v }));
};
const tabs: { id: ActiveTab; label: string; icon: React.ElementType }[] = [
{ id: 'parametres', label: 'Paramètres', icon: Settings },
{ id: 'etablissements', label: 'Établissements', icon: Building2 },
{ id: 'utilisateurs', label: 'Utilisateurs', icon: Users },
];
return (
<div className="min-h-screen flex bg-background">
<AppSidebar />
{/* Contenu principal */}
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
{/* En-tête */}
<header className="bg-card border-b border-border px-6 py-4 flex-shrink-0">
@@ -163,19 +744,20 @@ export default function Parametres() {
<button
onClick={() => navigate('/')}
className="p-2 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
title="Retour aux établissements"
title="Retour"
>
<ChevronLeft className="w-4 h-4" />
</button>
<div>
<h1 className="text-xl font-bold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
Paramètres de calcul
Paramètres
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Seuils de vétusté et coûts unitaires Budget 2027
Configuration de l'application Budget SI
</p>
</div>
</div>
{activeTab === 'parametres' && (
<div className="flex items-center gap-2">
<button
onClick={handleReset}
@@ -196,26 +778,41 @@ export default function Parametres() {
}`}
>
{saved ? (
<>
<CheckCircle className="w-3.5 h-3.5" />
Enregistré
</>
<><CheckCircle className="w-3.5 h-3.5" />Enregistré</>
) : (
<>
<Save className="w-3.5 h-3.5" />
Enregistrer
</>
<><Save className="w-3.5 h-3.5" />Enregistrer</>
)}
</button>
</div>
)}
</div>
{/* Onglets */}
<div className="flex mt-4 border-b border-border -mb-4 gap-1">
{tabs.map(t => {
const Icon = t.icon;
return (
<button
key={t.id}
onClick={() => setActiveTab(t.id)}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
activeTab === t.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<Icon className="w-4 h-4" />
{t.label}
</button>
);
})}
</div>
</header>
{/* Contenu */}
<div className="flex-1 overflow-y-auto px-6 py-6">
{activeTab === 'parametres' && (
<div className="max-w-2xl space-y-8">
{/* Bandeau info */}
<div className="flex items-start gap-3 bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-sm text-blue-800">
<Info className="w-4 h-4 mt-0.5 flex-shrink-0 text-blue-500" />
<p>
@@ -225,92 +822,37 @@ export default function Parametres() {
</p>
</div>
{/* Section vétusté */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-border">
<Settings className="w-4 h-4 text-muted-foreground" />
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
Seuils de vétusté
</h2>
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>Seuils de vétusté</h2>
</div>
<p className="text-sm text-muted-foreground">
Un équipement est considéré à renouveler si son âge (calculé au 01/01/2027) est
<strong> supérieur ou égal</strong> au seuil défini ci-dessous.
</p>
<SliderInput
label="PC Fixes"
icon={Monitor}
value={draft.seuilFixesAns}
min={1}
max={15}
step={1}
unit="ans"
description="Âge minimum pour renouveler un PC de bureau"
onChange={update('seuilFixesAns')}
accentClass="bg-blue-100 text-blue-700"
/>
<SliderInput
label="PC Portables"
icon={Laptop}
value={draft.seuilPortablesAns}
min={1}
max={15}
step={1}
unit="ans"
description="Âge minimum pour renouveler un PC portable"
onChange={update('seuilPortablesAns')}
accentClass="bg-orange-100 text-orange-700"
/>
<SliderInput label="PC Fixes" icon={Monitor} value={draft.seuilFixesAns} min={1} max={15} step={1} unit="ans"
description="Âge minimum pour renouveler un PC de bureau" onChange={update('seuilFixesAns')} accentClass="bg-blue-100 text-blue-700" />
<SliderInput label="PC Portables" icon={Laptop} value={draft.seuilPortablesAns} min={1} max={15} step={1} unit="ans"
description="Âge minimum pour renouveler un PC portable" onChange={update('seuilPortablesAns')} accentClass="bg-orange-100 text-orange-700" />
</section>
{/* Section coûts */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-border">
<Euro className="w-4 h-4 text-muted-foreground" />
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
Coûts unitaires TTC
</h2>
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>Coûts unitaires TTC</h2>
</div>
<p className="text-sm text-muted-foreground">
Prix d'achat unitaire estimé utilisé pour le calcul du budget de renouvellement.
</p>
<SliderInput
label="Coût d'un PC Fixe"
icon={Monitor}
value={draft.coutFixe}
min={100}
max={2000}
step={50}
unit=" TTC"
description="Prix unitaire estimé pour un PC de bureau neuf"
onChange={update('coutFixe')}
accentClass="bg-blue-100 text-blue-700"
/>
<SliderInput
label="Coût d'un PC Portable"
icon={Laptop}
value={draft.coutPortable}
min={100}
max={3000}
step={50}
unit="€ TTC"
description="Prix unitaire estimé pour un PC portable neuf"
onChange={update('coutPortable')}
accentClass="bg-orange-100 text-orange-700"
/>
<p className="text-sm text-muted-foreground">Prix d'achat unitaire estimé utilisé pour le calcul du budget de renouvellement.</p>
<SliderInput label="Coût d'un PC Fixe" icon={Monitor} value={draft.coutFixe} min={100} max={2000} step={50} unit="€ TTC"
description="Prix unitaire estimé pour un PC de bureau neuf" onChange={update('coutFixe')} accentClass="bg-blue-100 text-blue-700" />
<SliderInput label="Coût d'un PC Portable" icon={Laptop} value={draft.coutPortable} min={100} max={3000} step={50} unit="€ TTC"
description="Prix unitaire estimé pour un PC portable neuf" onChange={update('coutPortable')} accentClass="bg-orange-100 text-orange-700" />
</section>
{/* Section année par défaut */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-border">
<Calendar className="w-4 h-4 text-muted-foreground" />
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
Année d'ouverture par défaut
</h2>
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>Année d'ouverture par défaut</h2>
</div>
<p className="text-sm text-muted-foreground">
L'année sélectionnée au démarrage de l'application lorsqu'aucune session précédente n'est mémorisée.
@@ -326,15 +868,10 @@ export default function Parametres() {
</div>
<div className="flex items-center gap-2 flex-wrap">
{ANNEES_DISPONIBLES.map(a => (
<button
key={a}
onClick={() => setDraft(d => ({ ...d, anneeDefaut: a }))}
<button key={a} onClick={() => setDraft(d => ({ ...d, anneeDefaut: a }))}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
draft.anneeDefaut === a
? 'bg-indigo-600 text-white shadow-sm'
: 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}
>
draft.anneeDefaut === a ? 'bg-indigo-600 text-white shadow-sm' : 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}>
{a}
</button>
))}
@@ -343,13 +880,10 @@ export default function Parametres() {
</div>
</section>
{/* Aperçu de l'impact */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-border">
<BarChart3 className="w-4 h-4 text-muted-foreground" />
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
Paramètres en cours d'édition
</h2>
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>Paramètres en cours d'édition</h2>
</div>
<div className="grid grid-cols-2 gap-3">
{[
@@ -359,22 +893,11 @@ export default function Parametres() {
{ label: 'Coût portable unitaire', value: formatEuros(draft.coutPortable), changed: draft.coutPortable !== parametres.coutPortable },
{ label: 'Année par défaut', value: `${draft.anneeDefaut}`, changed: draft.anneeDefaut !== parametres.anneeDefaut },
].map((item) => (
<div
key={item.label}
className={`rounded-lg border px-4 py-3 transition-colors ${
item.changed
? 'border-orange-300 bg-orange-50'
: 'border-border bg-card'
}`}
>
<div key={item.label} className={`rounded-lg border px-4 py-3 transition-colors ${item.changed ? 'border-orange-300 bg-orange-50' : 'border-border bg-card'}`}>
<p className="text-xs text-muted-foreground">{item.label}</p>
<p className={`font-bold mt-0.5 ${item.changed ? 'text-orange-700' : 'text-foreground'}`}>
{item.value}
{item.changed && (
<span className="ml-2 text-[10px] font-normal text-orange-500 uppercase tracking-wide">
modifié
</span>
)}
{item.changed && <span className="ml-2 text-[10px] font-normal text-orange-500 uppercase tracking-wide">modifié</span>}
</p>
</div>
))}
@@ -386,8 +909,20 @@ export default function Parametres() {
</p>
)}
</section>
</div>
)}
{activeTab === 'etablissements' && (
<div className="max-w-4xl">
<OngletEtablissements />
</div>
)}
{activeTab === 'utilisateurs' && (
<div className="max-w-5xl">
<OngletUtilisateurs />
</div>
)}
</div>
</main>
</div>