Checkpoint: - Bouton crayon dans MesEtablissements ouvre un Dialog modal (sans mode édition préalable)

- Page Solutions Logicielles : bouton Ajouter, crayon Modifier, poubelle Supprimer (gestionnaires uniquement)
- Lien espace adhérent FEHAP mis à jour vers l'URL exacte
- Serveur redémarré, cache esbuild purgé, 0 erreur TypeScript
This commit is contained in:
Manus
2026-04-17 07:27:41 -04:00
parent 9aad377c49
commit 6edf1f6724
5 changed files with 272 additions and 314 deletions

View File

@@ -176,7 +176,7 @@ export default function SonumLayout({ children }: { children: React.ReactNode })
{/* Footer sidebar */}
<div className="px-3 py-4 border-t border-sidebar-border space-y-2">
<a
href="https://www.fehap.fr/espace-adherent"
href="https://www.fehap.fr/jcms/espace-candidats/mon-espace-glo_5528"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-white/70 hover:bg-white/10 hover:text-white transition-all duration-150"

View File

@@ -1,20 +1,11 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { EtatBadge, HebergementBadge, FacturationBadge } from "@/components/EtatBadge";
import { EtatBadge, HebergementBadge } from "@/components/EtatBadge";
import SonumLayout from "@/components/SonumLayout";
import { trpc } from "@/lib/trpc";
import {
Building2,
ChevronDown,
ChevronRight,
Edit3,
Eye,
EyeOff,
Pencil,
Plus,
Save,
Trash2,
X,
} from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Building2, ChevronDown, ChevronRight, Eye, Pencil, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { useLocation } from "wouter";
import { toast } from "sonner";
@@ -24,16 +15,11 @@ export default function MesEtablissements() {
const { user } = useAuth();
const [, navigate] = useLocation();
const [openEtab, setOpenEtab] = useState<number | null>(null);
const [editMode, setEditMode] = useState<number | null>(null);
const [rattacherEtab, setRattacherEtab] = useState<{ id: number; nom: string } | null>(null);
const etablissementsQuery = trpc.etablissements.mesEtablissements.useQuery();
const utils = trpc.useUtils();
const toggleAccordion = (id: number) => {
setOpenEtab(openEtab === id ? null : id);
setEditMode(null);
};
const toggleAccordion = (id: number) => setOpenEtab(openEtab === id ? null : id);
if (etablissementsQuery.isLoading) {
return (
@@ -50,13 +36,10 @@ export default function MesEtablissements() {
return (
<SonumLayout>
<div className="p-6 lg:p-8 max-w-5xl mx-auto">
{/* En-tête */}
<div className="mb-8 flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground mb-1">Mes Établissements</h1>
<p className="text-muted-foreground text-sm">
Gérez les logiciels de vos établissements rattachés
</p>
<p className="text-muted-foreground text-sm">Gérez les logiciels de vos établissements rattachés</p>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted px-3 py-1.5 rounded-lg border border-border">
<Building2 size={15} />
@@ -68,9 +51,7 @@ export default function MesEtablissements() {
<div className="text-center py-16 bg-card rounded-xl border border-border">
<Building2 size={48} className="mx-auto text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground font-medium">Aucun établissement rattaché</p>
<p className="text-muted-foreground text-sm mt-1">
Contactez un gestionnaire SONUM pour rattacher vos établissements.
</p>
<p className="text-muted-foreground text-sm mt-1">Contactez un gestionnaire SONUM pour rattacher vos établissements.</p>
</div>
) : (
<div className="space-y-3">
@@ -79,12 +60,9 @@ export default function MesEtablissements() {
key={etab.id}
etab={etab}
isOpen={openEtab === etab.id}
isEditMode={editMode === etab.id}
onToggle={() => toggleAccordion(etab.id)}
onEditMode={() => setEditMode(editMode === etab.id ? null : etab.id)}
onRattacher={() => setRattacherEtab({ id: etab.id, nom: etab.nom })}
onViewFiche={() => navigate(`/etablissement/${etab.id}`)}
onRefresh={() => utils.etablissements.mesEtablissements.invalidate()}
/>
))}
</div>
@@ -106,44 +84,17 @@ export default function MesEtablissements() {
);
}
function EtablissementAccordion({
etab,
isOpen,
isEditMode,
onToggle,
onEditMode,
onRattacher,
onViewFiche,
onRefresh,
}: {
etab: any;
isOpen: boolean;
isEditMode: boolean;
onToggle: () => void;
onEditMode: () => void;
onRattacher: () => void;
onViewFiche: () => void;
onRefresh: () => void;
function EtablissementAccordion({ etab, isOpen, onToggle, onRattacher, onViewFiche }: {
etab: any; isOpen: boolean; onToggle: () => void; onRattacher: () => void; onViewFiche: () => void;
}) {
const logicielsQuery = trpc.logiciels.byEtablissement.useQuery(
{ etablissementId: etab.id },
{ enabled: isOpen }
);
const logicielsQuery = trpc.logiciels.byEtablissement.useQuery({ etablissementId: etab.id }, { enabled: isOpen });
const deleteMutation = trpc.logiciels.delete.useMutation({
onSuccess: () => {
toast.success("Logiciel supprimé");
logicielsQuery.refetch();
},
onSuccess: () => { toast.success("Logiciel supprimé"); logicielsQuery.refetch(); },
});
const utils = trpc.useUtils();
return (
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden transition-all duration-200">
{/* En-tête accordéon */}
<button
className="w-full flex items-center justify-between px-5 py-4 hover:bg-muted/30 transition-colors text-left"
onClick={onToggle}
>
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
<button className="w-full flex items-center justify-between px-5 py-4 hover:bg-muted/30 transition-colors text-left" onClick={onToggle}>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Building2 size={17} className="text-primary" />
@@ -153,71 +104,35 @@ function EtablissementAccordion({
<div className="flex items-center gap-3 mt-0.5">
{etab.finess && <span className="text-xs text-muted-foreground">FINESS : {etab.finess}</span>}
{etab.region && <span className="text-xs text-muted-foreground">{etab.region}</span>}
{etab.typeActivite && (
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded border border-border">
{etab.typeActivite}
</span>
)}
{etab.typeActivite && <span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded border border-border">{etab.typeActivite}</span>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground hidden sm:block">
{isOpen ? "Masquer" : "Voir les logiciels"}
</span>
{isOpen ? (
<ChevronDown size={18} className="text-muted-foreground transition-transform" />
) : (
<ChevronRight size={18} className="text-muted-foreground" />
)}
<span className="text-xs text-muted-foreground hidden sm:block">{isOpen ? "Masquer" : "Voir les logiciels"}</span>
{isOpen ? <ChevronDown size={18} className="text-muted-foreground" /> : <ChevronRight size={18} className="text-muted-foreground" />}
</div>
</button>
{/* Contenu accordéon */}
{isOpen && (
<div className="border-t border-border">
{/* Barre d'actions */}
<div className="flex items-center justify-between px-5 py-3 bg-muted/20 border-b border-border">
<div className="flex items-center gap-2">
<button
onClick={onRattacher}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors shadow-sm"
>
<Plus size={13} />
Rattacher une solution
</button>
<button
onClick={onEditMode}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors border ${
isEditMode
? "bg-amber-50 text-amber-700 border-amber-200"
: "bg-background text-foreground border-border hover:bg-muted"
}`}
>
<Edit3 size={13} />
{isEditMode ? "Mode édition actif" : "Édition complète"}
</button>
</div>
<button
onClick={onViewFiche}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors border border-border"
>
<Eye size={13} />
Voir la fiche
<button onClick={onRattacher} className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors shadow-sm">
<Plus size={13} /> Rattacher une solution
</button>
<button onClick={onViewFiche} className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors border border-border">
<Eye size={13} /> Voir la fiche
</button>
</div>
{/* Tableau des logiciels */}
{logicielsQuery.isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-primary border-t-transparent" />
</div>
) : !logicielsQuery.data?.length ? (
<div className="text-center py-8 text-muted-foreground text-sm">
<p>Aucun logiciel renseigné pour cet établissement.</p>
<button onClick={onRattacher} className="mt-2 text-primary hover:underline text-sm font-medium">
Rattacher une première solution
</button>
<p>Aucun logiciel renseigné.</p>
<button onClick={onRattacher} className="mt-2 text-primary hover:underline text-sm font-medium">Rattacher une première solution </button>
</div>
) : (
<div className="overflow-x-auto">
@@ -229,9 +144,7 @@ function EtablissementAccordion({
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Bloc fonctionnel</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">État</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden xl:table-cell">Hébergement</th>
{isEditMode && (
<th className="text-right px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Actions</th>
)}
<th className="text-right px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
@@ -239,7 +152,6 @@ function EtablissementAccordion({
<LogicielRow
key={logiciel.id}
logiciel={logiciel}
isEditMode={isEditMode}
etablissementId={etab.id}
onDelete={() => deleteMutation.mutate({ id: logiciel.id, etablissementId: etab.id })}
onUpdated={() => logicielsQuery.refetch()}
@@ -255,115 +167,101 @@ function EtablissementAccordion({
);
}
function LogicielRow({
logiciel,
isEditMode,
etablissementId,
onDelete,
onUpdated,
}: {
logiciel: any;
isEditMode: boolean;
etablissementId: number;
onDelete: () => void;
onUpdated: () => void;
function LogicielRow({ logiciel, etablissementId, onDelete, onUpdated }: {
logiciel: any; etablissementId: number; onDelete: () => void; onUpdated: () => void;
}) {
const [editing, setEditing] = useState(false);
const [etat, setEtat] = useState(logiciel.etatDeploiement);
const [open, setOpen] = useState(false);
const [etat, setEtat] = useState(logiciel.etatDeploiement ?? "operationnel");
const [hebergement, setHebergement] = useState(logiciel.modeHebergement ?? "");
const [facturation, setFacturation] = useState(logiciel.modeFacturation ?? "");
const [commentaire, setCommentaire] = useState(logiciel.commentaire ?? "");
const updateMutation = trpc.logiciels.upsert.useMutation({
onSuccess: () => {
toast.success("Logiciel mis à jour");
setEditing(false);
onUpdated();
},
onSuccess: () => { toast.success("Logiciel mis à jour"); setOpen(false); onUpdated(); },
onError: (e) => toast.error("Erreur : " + e.message),
});
if (editing && isEditMode) {
return (
<tr className="bg-amber-50/50">
<td className="px-5 py-3 font-medium">{logiciel.solutionNom}</td>
<td className="px-4 py-3 hidden md:table-cell text-muted-foreground">{logiciel.editeurNom}</td>
<td className="px-4 py-3 hidden lg:table-cell text-muted-foreground text-xs">{logiciel.blocFonctionnelNom}</td>
<td className="px-4 py-3">
<select
value={etat}
onChange={(e) => setEtat(e.target.value)}
className="text-xs border border-border rounded px-2 py-1 bg-background"
>
<option value="demarrage">Démarrage</option>
<option value="en_cours">En cours</option>
<option value="operationnel">Opérationnel</option>
<option value="en_remplacement">En remplacement</option>
</select>
</td>
<td className="px-4 py-3 hidden xl:table-cell">
<input
value={commentaire}
onChange={(e) => setCommentaire(e.target.value)}
placeholder="Commentaire..."
className="text-xs border border-border rounded px-2 py-1 bg-background w-full"
/>
</td>
<td className="px-5 py-3 text-right">
<div className="flex items-center justify-end gap-1.5">
<button
onClick={() => updateMutation.mutate({
id: logiciel.id,
etablissementId,
solutionId: logiciel.solutionId,
etatDeploiement: etat,
commentaire,
})}
className="p-1.5 rounded bg-green-100 text-green-700 hover:bg-green-200 transition-colors"
>
<Save size={13} />
</button>
<button onClick={() => setEditing(false)} className="p-1.5 rounded bg-muted text-muted-foreground hover:bg-secondary transition-colors">
<X size={13} />
</button>
</div>
</td>
</tr>
);
}
const handleOpen = () => {
setEtat(logiciel.etatDeploiement ?? "operationnel");
setHebergement(logiciel.modeHebergement ?? "");
setFacturation(logiciel.modeFacturation ?? "");
setCommentaire(logiciel.commentaire ?? "");
setOpen(true);
};
return (
<tr className="hover:bg-muted/20 transition-colors">
<td className="px-5 py-3.5 font-medium text-foreground">{logiciel.solutionNom}</td>
<td className="px-4 py-3.5 hidden md:table-cell text-muted-foreground">{logiciel.editeurNom}</td>
<td className="px-4 py-3.5 hidden lg:table-cell">
{logiciel.blocFonctionnelNom && (
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded border border-border">
{logiciel.blocFonctionnelNom}
</span>
)}
</td>
<td className="px-4 py-3.5">
<EtatBadge etat={logiciel.etatDeploiement} />
</td>
<td className="px-4 py-3.5 hidden xl:table-cell">
{logiciel.modeHebergement ? <HebergementBadge mode={logiciel.modeHebergement} /> : <span className="text-muted-foreground text-xs"></span>}
</td>
{isEditMode && (
<>
<tr className="hover:bg-muted/20 transition-colors">
<td className="px-5 py-3.5 font-medium text-foreground">{logiciel.solutionNom}</td>
<td className="px-4 py-3.5 hidden md:table-cell text-muted-foreground">{logiciel.editeurNom}</td>
<td className="px-4 py-3.5 hidden lg:table-cell">
{logiciel.blocFonctionnelNom && <span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded border border-border">{logiciel.blocFonctionnelNom}</span>}
</td>
<td className="px-4 py-3.5"><EtatBadge etat={logiciel.etatDeploiement} /></td>
<td className="px-4 py-3.5 hidden xl:table-cell">
{logiciel.modeHebergement ? <HebergementBadge mode={logiciel.modeHebergement} /> : <span className="text-muted-foreground text-xs"></span>}
</td>
<td className="px-5 py-3.5 text-right">
<div className="flex items-center justify-end gap-1.5">
<button
onClick={() => setEditing(true)}
className="p-1.5 rounded bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors"
>
<button onClick={handleOpen} className="p-1.5 rounded bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors" title="Modifier">
<Pencil size={13} />
</button>
<button
onClick={onDelete}
className="p-1.5 rounded bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
>
<button onClick={onDelete} className="p-1.5 rounded bg-red-50 text-red-600 hover:bg-red-100 transition-colors" title="Supprimer">
<Trash2 size={13} />
</button>
</div>
</td>
)}
</tr>
</tr>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Modifier {logiciel.solutionNom}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label className="text-xs font-semibold uppercase text-muted-foreground mb-1.5 block">État de déploiement</Label>
<select value={etat} onChange={(e) => setEtat(e.target.value)} className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background">
<option value="demarrage">Démarrage</option>
<option value="en_cours">En cours</option>
<option value="operationnel">Opérationnel</option>
<option value="en_remplacement">En remplacement</option>
</select>
</div>
<div>
<Label className="text-xs font-semibold uppercase text-muted-foreground mb-1.5 block">Mode d'hébergement</Label>
<select value={hebergement} onChange={(e) => setHebergement(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="hds">HDS (Cloud)</option>
<option value="on_premise">On Premise</option>
<option value="hybride">Hybride</option>
</select>
</div>
<div>
<Label className="text-xs font-semibold uppercase text-muted-foreground mb-1.5 block">Mode de facturation</Label>
<select value={facturation} onChange={(e) => setFacturation(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="saas">SaaS</option>
<option value="achat_maintenance">Achat + Maintenance</option>
<option value="location">Location</option>
</select>
</div>
<div>
<Label className="text-xs font-semibold uppercase text-muted-foreground mb-1.5 block">Commentaire</Label>
<textarea value={commentaire} onChange={(e) => setCommentaire(e.target.value)} rows={3} placeholder="Informations complémentaires..." className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background resize-none" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Annuler</Button>
<Button
onClick={() => updateMutation.mutate({ id: logiciel.id, etablissementId, solutionId: logiciel.solutionId, etatDeploiement: etat as any, modeHebergement: hebergement as any || null, modeFacturation: facturation as any || null, commentaire: commentaire || null })}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,36 +1,63 @@
import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth";
import SonumLayout from "@/components/SonumLayout";
import { ChevronDown, ChevronRight, Building2, Package, Search, Filter, BarChart2 } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { ChevronDown, ChevronRight, Building2, Package, Search, Filter, BarChart2, Plus, Pencil, Trash2 } from "lucide-react";
import { useState, useMemo } from "react";
import { EtatBadge } from "@/components/EtatBadge";
import { toast } from "sonner";
export default function SolutionsLogicielles() {
const { isAuthenticated } = useAuth();
const { user, isAuthenticated } = useAuth();
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
const [openIds, setOpenIds] = useState<Set<number>>(new Set());
const [search, setSearch] = useState("");
const [filterBloc, setFilterBloc] = useState("");
const { data: solutions, isLoading } = trpc.logiciels.toutesLesSolutions.useQuery(undefined, {
enabled: isAuthenticated,
const [dialogMode, setDialogMode] = useState<"add" | "edit" | null>(null);
const [editTarget, setEditTarget] = useState<any>(null);
const [formNom, setFormNom] = useState("");
const [formEditeurId, setFormEditeurId] = useState<number | "">("");
const [formBlocId, setFormBlocId] = useState<number | "">("");
const { data: solutions, isLoading, refetch } = trpc.logiciels.toutesLesSolutions.useQuery(undefined, { enabled: isAuthenticated });
const { data: editeursList } = trpc.referentiel.editeurs.useQuery();
const { data: blocsList } = trpc.referentiel.blocsFonctionnels.useQuery();
const createMutation = trpc.referentiel.createSolution.useMutation({
onSuccess: () => { toast.success("Solution ajoutée"); setDialogMode(null); refetch(); },
onError: (e) => toast.error(e.message),
});
const updateMutation = trpc.referentiel.updateSolution.useMutation({
onSuccess: () => { toast.success("Solution mise à jour"); setDialogMode(null); refetch(); },
onError: (e) => toast.error(e.message),
});
const deleteMutation = trpc.referentiel.deleteSolution.useMutation({
onSuccess: () => { toast.success("Solution supprimée"); refetch(); },
onError: (e) => toast.error(e.message),
});
const toggle = (id: number) => {
setOpenIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
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 openEdit = (sol: any) => { setFormNom(sol.solutionNom); setFormEditeurId(sol.editeurId ?? ""); setFormBlocId(sol.blocFonctionnelId ?? ""); setEditTarget(sol); setDialogMode("edit"); };
const handleSubmit = () => {
if (!formNom.trim() || !formEditeurId) { toast.error("Nom et éditeur obligatoires"); return; }
if (dialogMode === "add") {
createMutation.mutate({ nom: formNom.trim(), editeurId: Number(formEditeurId), blocFonctionnelId: formBlocId ? Number(formBlocId) : null });
} else if (dialogMode === "edit" && editTarget) {
updateMutation.mutate({ id: editTarget.solutionId, nom: formNom.trim(), editeurId: Number(formEditeurId), blocFonctionnelId: formBlocId ? Number(formBlocId) : null });
}
};
const filtered = useMemo(() => {
if (!solutions) return [];
return solutions.filter((s) => {
const matchSearch =
!search ||
s.solutionNom.toLowerCase().includes(search.toLowerCase()) ||
s.editeurNom.toLowerCase().includes(search.toLowerCase());
const matchSearch = !search || s.solutionNom.toLowerCase().includes(search.toLowerCase()) || s.editeurNom.toLowerCase().includes(search.toLowerCase());
const matchBloc = !filterBloc || s.blocFonctionnelNom === filterBloc;
return matchSearch && matchBloc;
});
@@ -38,8 +65,7 @@ export default function SolutionsLogicielles() {
const blocsUniques = useMemo(() => {
if (!solutions) return [];
const set = new Set(solutions.map((s) => s.blocFonctionnelNom).filter(Boolean) as string[]);
return Array.from(set).sort();
return Array.from(new Set(solutions.map((s) => s.blocFonctionnelNom).filter(Boolean) as string[])).sort();
}, [solutions]);
const totalEtablissements = useMemo(() => {
@@ -52,19 +78,24 @@ export default function SolutionsLogicielles() {
return (
<SonumLayout>
<div className="max-w-5xl mx-auto px-4 py-8">
{/* En-tête */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center">
<BarChart2 size={20} className="text-blue-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">Solutions Logicielles</h1>
<p className="text-sm text-muted-foreground">Référentiel complet des solutions numériques FEHAP</p>
<div className="flex items-center justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center">
<BarChart2 size={20} className="text-blue-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">Solutions Logicielles</h1>
<p className="text-sm text-muted-foreground">Référentiel complet des solutions numériques FEHAP</p>
</div>
</div>
{isGestionnaire && (
<Button onClick={openAdd} className="flex items-center gap-2">
<Plus size={15} /> Ajouter une solution
</Button>
)}
</div>
{/* Statistiques */}
{solutions && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-2">
<div className="bg-card border border-border rounded-xl p-4 text-center">
@@ -83,47 +114,24 @@ export default function SolutionsLogicielles() {
)}
</div>
{/* Filtres */}
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<div className="relative flex-1">
<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 className="relative">
<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>
{blocsUniques.map((b) => (
<option key={b} value={b}>{b}</option>
))}
{blocsUniques.map((b) => <option key={b} value={b}>{b}</option>)}
</select>
</div>
</div>
{/* Résultat filtré */}
{search || filterBloc ? (
<p className="text-xs text-muted-foreground mb-4">
{filtered.length} solution{filtered.length > 1 ? "s" : ""} correspondant aux critères
</p>
) : null}
{(search || filterBloc) && <p className="text-xs text-muted-foreground mb-4">{filtered.length} solution{filtered.length > 1 ? "s" : ""} correspondant aux critères</p>}
{/* Liste */}
{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>
) : filtered.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Package size={40} className="mx-auto mb-3 opacity-30" />
@@ -134,72 +142,57 @@ export default function SolutionsLogicielles() {
{filtered.map((sol) => {
const isOpen = openIds.has(sol.solutionId);
return (
<div
key={sol.solutionId}
className="border border-border rounded-xl overflow-hidden bg-card shadow-sm hover:shadow-md transition-shadow"
>
{/* En-tête accordéon */}
<button
onClick={() => toggle(sol.solutionId)}
className="w-full 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="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" />
<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">
<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="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" />
</div>
<div className="min-w-0">
<div className="font-semibold text-foreground truncate">{sol.solutionNom}</div>
<div className="text-xs text-muted-foreground">{sol.editeurNom}</div>
</div>
{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>
)}
</div>
<div className="min-w-0">
<div className="font-semibold text-foreground truncate">{sol.solutionNom}</div>
<div className="text-xs text-muted-foreground">{sol.editeurNom}</div>
<div className="flex items-center gap-3 flex-shrink-0 ml-3">
{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/50 bg-muted/50 px-2.5 py-1 rounded-full italic">Non déployée</span>
)}
{isOpen ? <ChevronDown size={16} className="text-muted-foreground" /> : <ChevronRight size={16} className="text-muted-foreground" />}
</div>
{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>
)}
</div>
<div className="flex items-center gap-3 flex-shrink-0 ml-3">
{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/50 bg-muted/50 px-2.5 py-1 rounded-full italic">
Non déployée
</span>
)}
{isOpen ? (
<ChevronDown size={16} className="text-muted-foreground" />
) : (
<ChevronRight size={16} className="text-muted-foreground" />
)}
</div>
</button>
</button>
{isGestionnaire && (
<div className="flex items-center gap-1 pr-4 flex-shrink-0">
<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} />
</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">
<Trash2 size={13} />
</button>
</div>
)}
</div>
{/* Contenu accordéon */}
{isOpen && (
<div className="border-t border-border bg-muted/10">
<div className="px-5 py-3">
{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">
{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">
<Building2 size={14} className="text-muted-foreground flex-shrink-0" />
<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>
@@ -216,6 +209,40 @@ export default function SolutionsLogicielles() {
</div>
)}
</div>
<Dialog open={dialogMode !== null} onOpenChange={(o) => !o && setDialogMode(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{dialogMode === "add" ? "Ajouter une solution" : `Modifier — ${editTarget?.solutionNom}`}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<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" />
</div>
<div>
<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">
<option value=""> Sélectionner un éditeur </option>
{editeursList?.map((e: any) => <option key={e.id} value={e.id}>{e.nom}</option>)}
</select>
</div>
<div>
<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">
<option value=""> Non renseigné </option>
{blocsList?.map((b: any) => <option key={b.id} value={b.id}>{b.nom}</option>)}
</select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogMode(null)}>Annuler</Button>
<Button onClick={handleSubmit} disabled={createMutation.isPending || updateMutation.isPending}>
{createMutation.isPending || updateMutation.isPending ? "Enregistrement..." : dialogMode === "add" ? "Ajouter" : "Enregistrer"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</SonumLayout>
);
}