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:
@@ -13,6 +13,7 @@
|
|||||||
// └── Paramètres → /parametres
|
// └── Paramètres → /parametres
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { ImportModal } from './ImportModal';
|
||||||
import { useLocation } from 'wouter';
|
import { useLocation } from 'wouter';
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
TableProperties,
|
TableProperties,
|
||||||
|
Upload,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
// Palette de couleurs par section principale
|
// Palette de couleurs par section principale
|
||||||
@@ -203,6 +205,7 @@ interface AppSidebarProps {
|
|||||||
export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
|
export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
|
||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
const [openMenus, setOpenMenus] = useState<Set<string>>(() => getInitialOpenSections(location));
|
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
|
// Mode accordéon : ferme les autres sections du même niveau lors de l'ouverture
|
||||||
const toggleSection = (id: string) => {
|
const toggleSection = (id: string) => {
|
||||||
@@ -385,8 +388,23 @@ export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Paramètres — entrée bien visible en bas de sidebar */}
|
{/* 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">
|
<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 & Données</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Modale d'import */}
|
||||||
|
<ImportModal open={showImport} onClose={() => setShowImport(false)} />
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(SETTINGS_ITEM.path)}
|
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 ${
|
className={`w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg transition-all duration-150 text-left group ${
|
||||||
|
|||||||
450
client/src/components/ImportModal.tsx
Normal file
450
client/src/components/ImportModal.tsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
// Design: Corporate Modernism — Itinova Budget SI 2027
|
||||||
|
// Onglets : Paramètres | Établissements | Utilisateurs
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLocation } from 'wouter';
|
import { useLocation } from 'wouter';
|
||||||
@@ -15,12 +16,32 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
Building2,
|
||||||
|
Users,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
Check,
|
||||||
|
ShieldCheck,
|
||||||
|
Eye,
|
||||||
|
User,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ANNEES_DISPONIBLES } from '../contexts/AnneeContext';
|
import { ANNEES_DISPONIBLES } from '../contexts/AnneeContext';
|
||||||
import { AppSidebar } from '../components/AppSidebar';
|
import { AppSidebar } from '../components/AppSidebar';
|
||||||
import { useParametres, PARAMETRES_DEFAULTS } from '../contexts/ParametresContext';
|
import { useParametres, PARAMETRES_DEFAULTS } from '../contexts/ParametresContext';
|
||||||
import { formatEuros } from '../lib/format';
|
import { formatEuros } from '../lib/format';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
loadEtablissements,
|
||||||
|
saveEtablissements,
|
||||||
|
loadUtilisateurs,
|
||||||
|
saveUtilisateurs,
|
||||||
|
type Etablissement,
|
||||||
|
type Utilisateur,
|
||||||
|
} from '../components/ImportModal';
|
||||||
|
|
||||||
|
// ─── Types locaux ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface FieldState {
|
interface FieldState {
|
||||||
seuilFixesAns: number;
|
seuilFixesAns: number;
|
||||||
@@ -30,6 +51,10 @@ interface FieldState {
|
|||||||
anneeDefaut: number;
|
anneeDefaut: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ActiveTab = 'parametres' | 'etablissements' | 'utilisateurs';
|
||||||
|
|
||||||
|
// ─── SliderInput ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SliderInput({
|
function SliderInput({
|
||||||
label,
|
label,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
@@ -72,8 +97,6 @@ function SliderInput({
|
|||||||
<span className="text-sm text-muted-foreground ml-1">{unit}</span>
|
<span className="text-sm text-muted-foreground ml-1">{unit}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Slider */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -89,8 +112,6 @@ function SliderInput({
|
|||||||
<span>{max} {unit}</span>
|
<span>{max} {unit}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input numérique direct */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-sm text-muted-foreground flex-shrink-0">Valeur exacte :</label>
|
<label className="text-sm text-muted-foreground flex-shrink-0">Valeur exacte :</label>
|
||||||
<input
|
<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 & 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 & 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 & 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 & 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() {
|
export default function Parametres() {
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
const { parametres, setParametres, resetParametres } = useParametres();
|
const { parametres, setParametres, resetParametres } = useParametres();
|
||||||
const [draft, setDraft] = useState<FieldState>({ ...parametres });
|
const [draft, setDraft] = useState<FieldState>({ ...parametres });
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<ActiveTab>('parametres');
|
||||||
|
|
||||||
// Sync si les paramètres changent depuis l'extérieur
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraft({ ...parametres });
|
setDraft({ ...parametres });
|
||||||
}, [parametres]);
|
}, [parametres]);
|
||||||
@@ -150,11 +726,16 @@ export default function Parametres() {
|
|||||||
setDraft((d) => ({ ...d, [key]: v }));
|
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 (
|
return (
|
||||||
<div className="min-h-screen flex bg-background">
|
<div className="min-h-screen flex bg-background">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
|
|
||||||
{/* Contenu principal */}
|
|
||||||
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
{/* En-tête */}
|
{/* En-tête */}
|
||||||
<header className="bg-card border-b border-border px-6 py-4 flex-shrink-0">
|
<header className="bg-card border-b border-border px-6 py-4 flex-shrink-0">
|
||||||
@@ -163,19 +744,20 @@ export default function Parametres() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => navigate('/')}
|
onClick={() => navigate('/')}
|
||||||
className="p-2 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
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" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
<h1 className="text-xl font-bold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
Paramètres de calcul
|
Paramètres
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{activeTab === 'parametres' && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
@@ -196,26 +778,41 @@ export default function Parametres() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{saved ? (
|
{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>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Contenu */}
|
{/* Contenu */}
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||||
|
{activeTab === 'parametres' && (
|
||||||
<div className="max-w-2xl space-y-8">
|
<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">
|
<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" />
|
<Info className="w-4 h-4 mt-0.5 flex-shrink-0 text-blue-500" />
|
||||||
<p>
|
<p>
|
||||||
@@ -225,92 +822,37 @@ export default function Parametres() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section vétusté */}
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
||||||
<Settings className="w-4 h-4 text-muted-foreground" />
|
<Settings className="w-4 h-4 text-muted-foreground" />
|
||||||
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>Seuils de vétusté</h2>
|
||||||
Seuils de vétusté
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Un équipement est considéré à renouveler si son âge (calculé au 01/01/2027) est
|
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.
|
<strong> supérieur ou égal</strong> au seuil défini ci-dessous.
|
||||||
</p>
|
</p>
|
||||||
|
<SliderInput label="PC Fixes" icon={Monitor} value={draft.seuilFixesAns} min={1} max={15} step={1} unit="ans"
|
||||||
<SliderInput
|
description="Âge minimum pour renouveler un PC de bureau" onChange={update('seuilFixesAns')} accentClass="bg-blue-100 text-blue-700" />
|
||||||
label="PC Fixes"
|
<SliderInput label="PC Portables" icon={Laptop} value={draft.seuilPortablesAns} min={1} max={15} step={1} unit="ans"
|
||||||
icon={Monitor}
|
description="Âge minimum pour renouveler un PC portable" onChange={update('seuilPortablesAns')} accentClass="bg-orange-100 text-orange-700" />
|
||||||
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>
|
||||||
|
|
||||||
{/* Section coûts */}
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
||||||
<Euro className="w-4 h-4 text-muted-foreground" />
|
<Euro className="w-4 h-4 text-muted-foreground" />
|
||||||
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>Coûts unitaires TTC</h2>
|
||||||
Coûts unitaires TTC
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">Prix d'achat unitaire estimé utilisé pour le calcul du budget de renouvellement.</p>
|
||||||
Prix d'achat unitaire estimé utilisé pour le calcul du budget de renouvellement.
|
<SliderInput label="Coût d'un PC Fixe" icon={Monitor} value={draft.coutFixe} min={100} max={2000} step={50} unit="€ TTC"
|
||||||
</p>
|
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"
|
||||||
<SliderInput
|
description="Prix unitaire estimé pour un PC portable neuf" onChange={update('coutPortable')} accentClass="bg-orange-100 text-orange-700" />
|
||||||
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>
|
||||||
|
|
||||||
{/* Section année par défaut */}
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
||||||
<Calendar className="w-4 h-4 text-muted-foreground" />
|
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||||
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>Année d'ouverture par défaut</h2>
|
||||||
Année d'ouverture par défaut
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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.
|
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>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{ANNEES_DISPONIBLES.map(a => (
|
{ANNEES_DISPONIBLES.map(a => (
|
||||||
<button
|
<button key={a} onClick={() => setDraft(d => ({ ...d, anneeDefaut: a }))}
|
||||||
key={a}
|
|
||||||
onClick={() => setDraft(d => ({ ...d, anneeDefaut: a }))}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
|
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
|
||||||
draft.anneeDefaut === a
|
draft.anneeDefaut === a ? 'bg-indigo-600 text-white shadow-sm' : 'bg-muted text-muted-foreground hover:bg-muted/70'
|
||||||
? 'bg-indigo-600 text-white shadow-sm'
|
}`}>
|
||||||
: 'bg-muted text-muted-foreground hover:bg-muted/70'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{a}
|
{a}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -343,13 +880,10 @@ export default function Parametres() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Aperçu de l'impact */}
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
||||||
<BarChart3 className="w-4 h-4 text-muted-foreground" />
|
<BarChart3 className="w-4 h-4 text-muted-foreground" />
|
||||||
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>Paramètres en cours d'édition</h2>
|
||||||
Paramètres en cours d'édition
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<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: '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 },
|
{ label: 'Année par défaut', value: `${draft.anneeDefaut}`, changed: draft.anneeDefaut !== parametres.anneeDefaut },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div
|
<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'}`}>
|
||||||
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="text-xs text-muted-foreground">{item.label}</p>
|
||||||
<p className={`font-bold mt-0.5 ${item.changed ? 'text-orange-700' : 'text-foreground'}`}>
|
<p className={`font-bold mt-0.5 ${item.changed ? 'text-orange-700' : 'text-foreground'}`}>
|
||||||
{item.value}
|
{item.value}
|
||||||
{item.changed && (
|
{item.changed && <span className="ml-2 text-[10px] font-normal text-orange-500 uppercase tracking-wide">modifié</span>}
|
||||||
<span className="ml-2 text-[10px] font-normal text-orange-500 uppercase tracking-wide">
|
|
||||||
modifié
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -386,8 +909,20 @@ export default function Parametres() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'etablissements' && (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<OngletEtablissements />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'utilisateurs' && (
|
||||||
|
<div className="max-w-5xl">
|
||||||
|
<OngletUtilisateurs />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user