Checkpoint: Page Solutions Logicielles : toggle Accordéon / Liste. La vue Liste affiche toutes les solutions avec leurs établissements directement dans un tableau (solution, éditeur, bloc fonctionnel, établissement, région, état) sans aucune interaction. Les rowSpan groupent les lignes par solution. 0 erreur TypeScript.

This commit is contained in:
Manus
2026-04-17 08:21:04 -04:00
parent 6edf1f6724
commit df8c5ee2c1
2 changed files with 190 additions and 38 deletions

View File

@@ -4,15 +4,21 @@ import SonumLayout from "@/components/SonumLayout";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { ChevronDown, ChevronRight, Building2, Package, Search, Filter, BarChart2, Plus, Pencil, Trash2 } from "lucide-react"; import {
ChevronDown, ChevronRight, Building2, Package, Search, Filter,
BarChart2, Plus, Pencil, Trash2, LayoutList, Rows3
} from "lucide-react";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { EtatBadge } from "@/components/EtatBadge"; import { EtatBadge } from "@/components/EtatBadge";
import { toast } from "sonner"; import { toast } from "sonner";
type ViewMode = "accordion" | "flat";
export default function SolutionsLogicielles() { export default function SolutionsLogicielles() {
const { user, isAuthenticated } = useAuth(); const { user, isAuthenticated } = useAuth();
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin"; const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
const [viewMode, setViewMode] = useState<ViewMode>("accordion");
const [openIds, setOpenIds] = useState<Set<number>>(new Set()); const [openIds, setOpenIds] = useState<Set<number>>(new Set());
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [filterBloc, setFilterBloc] = useState(""); const [filterBloc, setFilterBloc] = useState("");
@@ -43,7 +49,13 @@ export default function SolutionsLogicielles() {
const toggle = (id: number) => setOpenIds((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; }); const toggle = (id: number) => setOpenIds((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
const openAdd = () => { setFormNom(""); setFormEditeurId(""); setFormBlocId(""); setEditTarget(null); setDialogMode("add"); }; const openAdd = () => { setFormNom(""); setFormEditeurId(""); setFormBlocId(""); setEditTarget(null); setDialogMode("add"); };
const openEdit = (sol: any) => { setFormNom(sol.solutionNom); setFormEditeurId(sol.editeurId ?? ""); setFormBlocId(sol.blocFonctionnelId ?? ""); setEditTarget(sol); setDialogMode("edit"); }; const openEdit = (sol: any) => {
setFormNom(sol.solutionNom);
setFormEditeurId(sol.editeurId ?? "");
setFormBlocId(sol.blocFonctionnelId ?? "");
setEditTarget(sol);
setDialogMode("edit");
};
const handleSubmit = () => { const handleSubmit = () => {
if (!formNom.trim() || !formEditeurId) { toast.error("Nom et éditeur obligatoires"); return; } if (!formNom.trim() || !formEditeurId) { toast.error("Nom et éditeur obligatoires"); return; }
@@ -77,7 +89,8 @@ export default function SolutionsLogicielles() {
return ( return (
<SonumLayout> <SonumLayout>
<div className="max-w-5xl mx-auto px-4 py-8"> <div className="max-w-6xl mx-auto px-4 py-8">
{/* En-tête */}
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center justify-between gap-3 mb-4"> <div className="flex items-center justify-between gap-3 mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -97,7 +110,7 @@ export default function SolutionsLogicielles() {
</div> </div>
{solutions && ( {solutions && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-2"> <div className="grid grid-cols-3 gap-3 mb-2">
<div className="bg-card border border-border rounded-xl p-4 text-center"> <div className="bg-card border border-border rounded-xl p-4 text-center">
<div className="text-2xl font-bold text-primary">{solutions.length}</div> <div className="text-2xl font-bold text-primary">{solutions.length}</div>
<div className="text-xs text-muted-foreground mt-0.5">Solutions référencées</div> <div className="text-xs text-muted-foreground mt-0.5">Solutions référencées</div>
@@ -106,7 +119,7 @@ export default function SolutionsLogicielles() {
<div className="text-2xl font-bold text-blue-600">{totalEtablissements}</div> <div className="text-2xl font-bold text-blue-600">{totalEtablissements}</div>
<div className="text-xs text-muted-foreground mt-0.5">Établissements équipés</div> <div className="text-xs text-muted-foreground mt-0.5">Établissements équipés</div>
</div> </div>
<div className="bg-card border border-border rounded-xl p-4 text-center col-span-2 sm:col-span-1"> <div className="bg-card border border-border rounded-xl p-4 text-center">
<div className="text-2xl font-bold text-green-600">{blocsUniques.length}</div> <div className="text-2xl font-bold text-green-600">{blocsUniques.length}</div>
<div className="text-xs text-muted-foreground mt-0.5">Blocs fonctionnels</div> <div className="text-xs text-muted-foreground mt-0.5">Blocs fonctionnels</div>
</div> </div>
@@ -114,21 +127,53 @@ export default function SolutionsLogicielles() {
)} )}
</div> </div>
{/* Barre de filtres + toggle vue */}
<div className="flex flex-col sm:flex-row gap-3 mb-6"> <div className="flex flex-col sm:flex-row gap-3 mb-6">
<div className="relative flex-1"> <div className="relative flex-1">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" /> <Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input type="text" placeholder="Rechercher par solution ou éditeur…" value={search} onChange={(e) => setSearch(e.target.value)} className="w-full pl-9 pr-4 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary" /> <input
type="text"
placeholder="Rechercher par solution ou éditeur…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
/>
</div> </div>
<div className="relative"> <div className="relative">
<Filter size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" /> <Filter size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<select value={filterBloc} onChange={(e) => setFilterBloc(e.target.value)} className="pl-9 pr-8 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 appearance-none min-w-[200px]"> <select
value={filterBloc}
onChange={(e) => setFilterBloc(e.target.value)}
className="pl-9 pr-8 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 appearance-none min-w-[200px]"
>
<option value="">Tous les blocs fonctionnels</option> <option value="">Tous les blocs fonctionnels</option>
{blocsUniques.map((b) => <option key={b} value={b}>{b}</option>)} {blocsUniques.map((b) => <option key={b} value={b}>{b}</option>)}
</select> </select>
</div> </div>
{/* Toggle vue */}
<div className="flex items-center bg-muted rounded-lg p-1 gap-1 flex-shrink-0">
<button
onClick={() => setViewMode("accordion")}
title="Vue accordéon"
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${viewMode === "accordion" ? "bg-background shadow text-foreground" : "text-muted-foreground hover:text-foreground"}`}
>
<LayoutList size={14} /> Accordéon
</button>
<button
onClick={() => setViewMode("flat")}
title="Vue liste à plat"
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${viewMode === "flat" ? "bg-background shadow text-foreground" : "text-muted-foreground hover:text-foreground"}`}
>
<Rows3 size={14} /> Liste
</button>
</div>
</div> </div>
{(search || filterBloc) && <p className="text-xs text-muted-foreground mb-4">{filtered.length} solution{filtered.length > 1 ? "s" : ""} correspondant aux critères</p>} {(search || filterBloc) && (
<p className="text-xs text-muted-foreground mb-4">
{filtered.length} solution{filtered.length > 1 ? "s" : ""} correspondant aux critères
</p>
)}
{isLoading ? ( {isLoading ? (
<div className="space-y-3">{[...Array(6)].map((_, i) => <div key={i} className="h-16 bg-muted/30 rounded-xl animate-pulse" />)}</div> <div className="space-y-3">{[...Array(6)].map((_, i) => <div key={i} className="h-16 bg-muted/30 rounded-xl animate-pulse" />)}</div>
@@ -137,14 +182,18 @@ export default function SolutionsLogicielles() {
<Package size={40} className="mx-auto mb-3 opacity-30" /> <Package size={40} className="mx-auto mb-3 opacity-30" />
<p className="font-medium">Aucune solution trouvée</p> <p className="font-medium">Aucune solution trouvée</p>
</div> </div>
) : ( ) : viewMode === "accordion" ? (
/* ── VUE ACCORDÉON ─────────────────────────────────────────── */
<div className="space-y-2"> <div className="space-y-2">
{filtered.map((sol) => { {filtered.map((sol) => {
const isOpen = openIds.has(sol.solutionId); const isOpen = openIds.has(sol.solutionId);
return ( return (
<div key={sol.solutionId} className="border border-border rounded-xl overflow-hidden bg-card shadow-sm hover:shadow-md transition-shadow"> <div key={sol.solutionId} className="border border-border rounded-xl overflow-hidden bg-card shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-center"> <div className="flex items-center">
<button onClick={() => toggle(sol.solutionId)} className="flex-1 flex items-center justify-between px-5 py-4 text-left hover:bg-muted/30 transition-colors"> <button
onClick={() => toggle(sol.solutionId)}
className="flex-1 flex items-center justify-between px-5 py-4 text-left hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-4 min-w-0"> <div className="flex items-center gap-4 min-w-0">
<div className="w-9 h-9 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0"> <div className="w-9 h-9 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0">
<Package size={16} className="text-blue-600" /> <Package size={16} className="text-blue-600" />
@@ -154,12 +203,16 @@ export default function SolutionsLogicielles() {
<div className="text-xs text-muted-foreground">{sol.editeurNom}</div> <div className="text-xs text-muted-foreground">{sol.editeurNom}</div>
</div> </div>
{sol.blocFonctionnelNom && ( {sol.blocFonctionnelNom && (
<span className="hidden sm:inline-flex text-xs bg-secondary text-secondary-foreground px-2.5 py-1 rounded-full border border-border flex-shrink-0">{sol.blocFonctionnelNom}</span> <span className="hidden sm:inline-flex text-xs bg-secondary text-secondary-foreground px-2.5 py-1 rounded-full border border-border flex-shrink-0">
{sol.blocFonctionnelNom}
</span>
)} )}
</div> </div>
<div className="flex items-center gap-3 flex-shrink-0 ml-3"> <div className="flex items-center gap-3 flex-shrink-0 ml-3">
{sol.nbEtablissements > 0 ? ( {sol.nbEtablissements > 0 ? (
<span className="text-xs text-muted-foreground bg-muted px-2.5 py-1 rounded-full">{sol.nbEtablissements} établissement{sol.nbEtablissements > 1 ? "s" : ""}</span> <span className="text-xs text-muted-foreground bg-muted px-2.5 py-1 rounded-full">
{sol.nbEtablissements} établissement{sol.nbEtablissements > 1 ? "s" : ""}
</span>
) : ( ) : (
<span className="text-xs text-muted-foreground/50 bg-muted/50 px-2.5 py-1 rounded-full italic">Non déployée</span> <span className="text-xs text-muted-foreground/50 bg-muted/50 px-2.5 py-1 rounded-full italic">Non déployée</span>
)} )}
@@ -171,45 +224,127 @@ export default function SolutionsLogicielles() {
<button onClick={(e) => { e.stopPropagation(); openEdit(sol); }} className="p-1.5 rounded bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors" title="Modifier"> <button onClick={(e) => { e.stopPropagation(); openEdit(sol); }} className="p-1.5 rounded bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors" title="Modifier">
<Pencil size={13} /> <Pencil size={13} />
</button> </button>
<button onClick={(e) => { e.stopPropagation(); if (confirm(`Supprimer "${sol.solutionNom}" ?`)) deleteMutation.mutate({ id: sol.solutionId }); }} className="p-1.5 rounded bg-red-50 text-red-600 hover:bg-red-100 transition-colors" title="Supprimer"> <button
onClick={(e) => { e.stopPropagation(); if (confirm(`Supprimer "${sol.solutionNom}" ?`)) deleteMutation.mutate({ id: sol.solutionId }); }}
className="p-1.5 rounded bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
title="Supprimer"
>
<Trash2 size={13} /> <Trash2 size={13} />
</button> </button>
</div> </div>
)} )}
</div> </div>
{isOpen && ( {isOpen && (
<div className="border-t border-border bg-muted/10"> <div className="border-t border-border bg-muted/10 px-5 py-3">
<div className="px-5 py-3"> {sol.etablissements.length === 0 ? (
{sol.etablissements.length === 0 ? ( <p className="text-sm text-muted-foreground italic py-2">Aucun établissement n'utilise encore cette solution.</p>
<p className="text-sm text-muted-foreground italic py-2">Aucun établissement n'utilise encore cette solution.</p> ) : (
) : ( <>
<> <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Établissements équipés</div>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Établissements équipés</div> <div className="space-y-2">
<div className="space-y-2"> {sol.etablissements.map((etab) => (
{sol.etablissements.map((etab) => ( <div key={etab.id} className="flex items-center justify-between py-2 px-3 bg-background rounded-lg border border-border">
<div key={etab.id} className="flex items-center justify-between py-2 px-3 bg-background rounded-lg border border-border"> <div className="flex items-center gap-2 min-w-0">
<div className="flex items-center gap-2 min-w-0"> <Building2 size={14} className="text-muted-foreground flex-shrink-0" />
<Building2 size={14} className="text-muted-foreground flex-shrink-0" /> <span className="text-sm font-medium text-foreground truncate">{etab.nom}</span>
<span className="text-sm font-medium text-foreground truncate">{etab.nom}</span> {etab.region && <span className="text-xs text-muted-foreground hidden sm:block"> {etab.region}</span>}
{etab.region && <span className="text-xs text-muted-foreground hidden sm:block"> {etab.region}</span>}
</div>
<EtatBadge etat={etab.etatDeploiement} />
</div> </div>
))} <EtatBadge etat={etab.etatDeploiement} />
</div> </div>
</> ))}
)} </div>
</div> </>
)}
</div> </div>
)} )}
</div> </div>
); );
})} })}
</div> </div>
) : (
/* ── VUE LISTE À PLAT ──────────────────────────────────────── */
<div className="border border-border rounded-xl overflow-hidden bg-card shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-4 py-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Solution</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Éditeur</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider hidden sm:table-cell">Bloc fonctionnel</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Établissement</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider hidden md:table-cell">Région</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">État</th>
{isGestionnaire && <th className="px-4 py-3 w-16"></th>}
</tr>
</thead>
<tbody>
{filtered.map((sol) => {
if (sol.etablissements.length === 0) {
return (
<tr key={`sol-${sol.solutionId}-empty`} className="border-b border-border last:border-0 hover:bg-muted/20 transition-colors">
<td className="px-4 py-3 font-medium text-foreground">{sol.solutionNom}</td>
<td className="px-4 py-3 text-muted-foreground">{sol.editeurNom}</td>
<td className="px-4 py-3 hidden sm:table-cell">
{sol.blocFonctionnelNom && (
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full border border-border">{sol.blocFonctionnelNom}</span>
)}
</td>
<td className="px-4 py-3 text-muted-foreground italic text-xs" colSpan={2}>Non déployée</td>
<td className="px-4 py-3"></td>
{isGestionnaire && (
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button onClick={() => openEdit(sol)} className="p-1.5 rounded bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors" title="Modifier"><Pencil size={12} /></button>
<button onClick={() => { if (confirm(`Supprimer "${sol.solutionNom}" ?`)) deleteMutation.mutate({ id: sol.solutionId }); }} className="p-1.5 rounded bg-red-50 text-red-600 hover:bg-red-100 transition-colors" title="Supprimer"><Trash2 size={12} /></button>
</div>
</td>
)}
</tr>
);
}
return sol.etablissements.map((etab, idx) => (
<tr key={`sol-${sol.solutionId}-etab-${etab.id}`} className="border-b border-border last:border-0 hover:bg-muted/20 transition-colors">
{idx === 0 ? (
<>
<td className="px-4 py-3 font-semibold text-foreground align-top" rowSpan={sol.etablissements.length}>
<div className="flex items-center gap-2">
<Package size={13} className="text-blue-500 flex-shrink-0" />
{sol.solutionNom}
</div>
</td>
<td className="px-4 py-3 text-muted-foreground align-top" rowSpan={sol.etablissements.length}>{sol.editeurNom}</td>
<td className="px-4 py-3 hidden sm:table-cell align-top" rowSpan={sol.etablissements.length}>
{sol.blocFonctionnelNom && (
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full border border-border">{sol.blocFonctionnelNom}</span>
)}
</td>
</>
) : null}
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
<Building2 size={13} className="text-muted-foreground flex-shrink-0" />
<span className="text-foreground">{etab.nom}</span>
</div>
</td>
<td className="px-4 py-3 text-muted-foreground hidden md:table-cell">{etab.region ?? "—"}</td>
<td className="px-4 py-3"><EtatBadge etat={etab.etatDeploiement} /></td>
{isGestionnaire && idx === 0 ? (
<td className="px-4 py-3 align-top" rowSpan={sol.etablissements.length}>
<div className="flex items-center gap-1">
<button onClick={() => openEdit(sol)} className="p-1.5 rounded bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors" title="Modifier"><Pencil size={12} /></button>
<button onClick={() => { if (confirm(`Supprimer "${sol.solutionNom}" ?`)) deleteMutation.mutate({ id: sol.solutionId }); }} className="p-1.5 rounded bg-red-50 text-red-600 hover:bg-red-100 transition-colors" title="Supprimer"><Trash2 size={12} /></button>
</div>
</td>
) : isGestionnaire ? <td className="px-4 py-3"></td> : null}
</tr>
));
})}
</tbody>
</table>
</div>
)} )}
</div> </div>
{/* Dialog ajout / modification */}
<Dialog open={dialogMode !== null} onOpenChange={(o) => !o && setDialogMode(null)}> <Dialog open={dialogMode !== null} onOpenChange={(o) => !o && setDialogMode(null)}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
@@ -218,18 +353,31 @@ export default function SolutionsLogicielles() {
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div> <div>
<Label className="text-xs font-semibold uppercase text-muted-foreground mb-1.5 block">Nom de la solution *</Label> <Label className="text-xs font-semibold uppercase text-muted-foreground mb-1.5 block">Nom de la solution *</Label>
<input value={formNom} onChange={(e) => setFormNom(e.target.value)} placeholder="Ex : DPI, Logiciel RH…" className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background focus:outline-none focus:ring-2 focus:ring-primary/30" /> <input
value={formNom}
onChange={(e) => setFormNom(e.target.value)}
placeholder="Ex : DPI, Logiciel RH…"
className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div> </div>
<div> <div>
<Label className="text-xs font-semibold uppercase text-muted-foreground mb-1.5 block">Éditeur *</Label> <Label className="text-xs font-semibold uppercase text-muted-foreground mb-1.5 block">Éditeur *</Label>
<select value={formEditeurId} onChange={(e) => setFormEditeurId(e.target.value ? Number(e.target.value) : "")} className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background"> <select
value={formEditeurId}
onChange={(e) => setFormEditeurId(e.target.value ? Number(e.target.value) : "")}
className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background"
>
<option value=""> Sélectionner un éditeur </option> <option value=""> Sélectionner un éditeur </option>
{editeursList?.map((e: any) => <option key={e.id} value={e.id}>{e.nom}</option>)} {editeursList?.map((e: any) => <option key={e.id} value={e.id}>{e.nom}</option>)}
</select> </select>
</div> </div>
<div> <div>
<Label className="text-xs font-semibold uppercase text-muted-foreground mb-1.5 block">Bloc fonctionnel</Label> <Label className="text-xs font-semibold uppercase text-muted-foreground mb-1.5 block">Bloc fonctionnel</Label>
<select value={formBlocId} onChange={(e) => setFormBlocId(e.target.value ? Number(e.target.value) : "")} className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background"> <select
value={formBlocId}
onChange={(e) => setFormBlocId(e.target.value ? Number(e.target.value) : "")}
className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background"
>
<option value=""> Non renseigné </option> <option value=""> Non renseigné </option>
{blocsList?.map((b: any) => <option key={b.id} value={b.id}>{b.nom}</option>)} {blocsList?.map((b: any) => <option key={b.id} value={b.id}>{b.nom}</option>)}
</select> </select>

View File

@@ -70,3 +70,7 @@
- [x] Page "Solutions Logicielles" : liste globale des solutions avec établissements équipés (accordéon), accessible depuis la sidebar - [x] Page "Solutions Logicielles" : liste globale des solutions avec établissements équipés (accordéon), accessible depuis la sidebar
- [x] Admin : onglet import établissements (CSV/Excel) - [x] Admin : onglet import établissements (CSV/Excel)
- [x] Admin : onglet import contacts (CSV/Excel) - [x] Admin : onglet import contacts (CSV/Excel)
## Évolution v5
- [x] Solutions Logicielles : toggle vue accordéon / vue liste à plat (solutions + établissements visibles sans clic)