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

View File

@@ -1,36 +1,63 @@
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth"; import { useAuth } from "@/_core/hooks/useAuth";
import SonumLayout from "@/components/SonumLayout"; 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 { useState, useMemo } from "react";
import { EtatBadge } from "@/components/EtatBadge"; import { EtatBadge } from "@/components/EtatBadge";
import { toast } from "sonner";
export default function SolutionsLogicielles() { 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 [openIds, setOpenIds] = useState<Set<number>>(new Set());
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [filterBloc, setFilterBloc] = useState(""); const [filterBloc, setFilterBloc] = useState("");
const { data: solutions, isLoading } = trpc.logiciels.toutesLesSolutions.useQuery(undefined, { const [dialogMode, setDialogMode] = useState<"add" | "edit" | null>(null);
enabled: isAuthenticated, 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) => { const toggle = (id: number) => setOpenIds((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
setOpenIds((prev) => {
const next = new Set(prev); const openAdd = () => { setFormNom(""); setFormEditeurId(""); setFormBlocId(""); setEditTarget(null); setDialogMode("add"); };
if (next.has(id)) next.delete(id); const openEdit = (sol: any) => { setFormNom(sol.solutionNom); setFormEditeurId(sol.editeurId ?? ""); setFormBlocId(sol.blocFonctionnelId ?? ""); setEditTarget(sol); setDialogMode("edit"); };
else next.add(id);
return next; 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(() => { const filtered = useMemo(() => {
if (!solutions) return []; if (!solutions) return [];
return solutions.filter((s) => { return solutions.filter((s) => {
const matchSearch = const matchSearch = !search || s.solutionNom.toLowerCase().includes(search.toLowerCase()) || s.editeurNom.toLowerCase().includes(search.toLowerCase());
!search ||
s.solutionNom.toLowerCase().includes(search.toLowerCase()) ||
s.editeurNom.toLowerCase().includes(search.toLowerCase());
const matchBloc = !filterBloc || s.blocFonctionnelNom === filterBloc; const matchBloc = !filterBloc || s.blocFonctionnelNom === filterBloc;
return matchSearch && matchBloc; return matchSearch && matchBloc;
}); });
@@ -38,8 +65,7 @@ export default function SolutionsLogicielles() {
const blocsUniques = useMemo(() => { const blocsUniques = useMemo(() => {
if (!solutions) return []; if (!solutions) return [];
const set = new Set(solutions.map((s) => s.blocFonctionnelNom).filter(Boolean) as string[]); return Array.from(new Set(solutions.map((s) => s.blocFonctionnelNom).filter(Boolean) as string[])).sort();
return Array.from(set).sort();
}, [solutions]); }, [solutions]);
const totalEtablissements = useMemo(() => { const totalEtablissements = useMemo(() => {
@@ -52,9 +78,9 @@ export default function SolutionsLogicielles() {
return ( return (
<SonumLayout> <SonumLayout>
<div className="max-w-5xl mx-auto px-4 py-8"> <div className="max-w-5xl mx-auto px-4 py-8">
{/* En-tête */}
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center gap-3 mb-4"> <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"> <div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center">
<BarChart2 size={20} className="text-blue-600" /> <BarChart2 size={20} className="text-blue-600" />
</div> </div>
@@ -63,8 +89,13 @@ export default function SolutionsLogicielles() {
<p className="text-sm text-muted-foreground">Référentiel complet des solutions numériques FEHAP</p> <p className="text-sm text-muted-foreground">Référentiel complet des solutions numériques FEHAP</p>
</div> </div>
</div> </div>
{isGestionnaire && (
<Button onClick={openAdd} className="flex items-center gap-2">
<Plus size={15} /> Ajouter une solution
</Button>
)}
</div>
{/* Statistiques */}
{solutions && ( {solutions && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-2"> <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"> <div className="bg-card border border-border rounded-xl p-4 text-center">
@@ -83,47 +114,24 @@ export default function SolutionsLogicielles() {
)} )}
</div> </div>
{/* Filtres */}
<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 <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" />
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 <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]">
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) => ( {blocsUniques.map((b) => <option key={b} value={b}>{b}</option>)}
<option key={b} value={b}>{b}</option>
))}
</select> </select>
</div> </div>
</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>}
{search || filterBloc ? (
<p className="text-xs text-muted-foreground mb-4">
{filtered.length} solution{filtered.length > 1 ? "s" : ""} correspondant aux critères
</p>
) : null}
{/* Liste */}
{isLoading ? ( {isLoading ? (
<div className="space-y-3"> <div className="space-y-3">{[...Array(6)].map((_, i) => <div key={i} className="h-16 bg-muted/30 rounded-xl animate-pulse" />)}</div>
{[...Array(6)].map((_, i) => (
<div key={i} className="h-16 bg-muted/30 rounded-xl animate-pulse" />
))}
</div>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className="text-center py-16 text-muted-foreground"> <div className="text-center py-16 text-muted-foreground">
<Package size={40} className="mx-auto mb-3 opacity-30" /> <Package size={40} className="mx-auto mb-3 opacity-30" />
@@ -134,15 +142,9 @@ export default function SolutionsLogicielles() {
{filtered.map((sol) => { {filtered.map((sol) => {
const isOpen = openIds.has(sol.solutionId); const isOpen = openIds.has(sol.solutionId);
return ( return (
<div <div key={sol.solutionId} className="border border-border rounded-xl overflow-hidden bg-card shadow-sm hover:shadow-md transition-shadow">
key={sol.solutionId} <div className="flex items-center">
className="border border-border rounded-xl overflow-hidden bg-card shadow-sm hover:shadow-md transition-shadow" <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">
>
{/* 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="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" />
@@ -152,54 +154,45 @@ 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"> <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>
{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"> <span className="text-xs text-muted-foreground bg-muted px-2.5 py-1 rounded-full">{sol.nbEtablissements} établissement{sol.nbEtablissements > 1 ? "s" : ""}</span>
{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"> <span className="text-xs text-muted-foreground/50 bg-muted/50 px-2.5 py-1 rounded-full italic">Non déployée</span>
Non déployée
</span>
)}
{isOpen ? (
<ChevronDown size={16} className="text-muted-foreground" />
) : (
<ChevronRight size={16} className="text-muted-foreground" />
)} )}
{isOpen ? <ChevronDown size={16} className="text-muted-foreground" /> : <ChevronRight size={16} className="text-muted-foreground" />}
</div> </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 && ( {isOpen && (
<div className="border-t border-border bg-muted/10"> <div className="border-t border-border bg-muted/10">
<div className="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"> <p className="text-sm text-muted-foreground italic py-2">Aucun établissement n'utilise encore cette solution.</p>
Aucun établissement n'utilise encore cette solution.
</p>
) : ( ) : (
<> <>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3"> <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Établissements équipés</div>
Établissements équipés
</div>
<div className="space-y-2"> <div className="space-y-2">
{sol.etablissements.map((etab) => ( {sol.etablissements.map((etab) => (
<div <div key={etab.id} className="flex items-center justify-between py-2 px-3 bg-background rounded-lg border border-border">
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 && ( {etab.region && <span className="text-xs text-muted-foreground hidden sm:block"> {etab.region}</span>}
<span className="text-xs text-muted-foreground hidden sm:block"> {etab.region}</span>
)}
</div> </div>
<EtatBadge etat={etab.etatDeploiement} /> <EtatBadge etat={etab.etatDeploiement} />
</div> </div>
@@ -216,6 +209,40 @@ export default function SolutionsLogicielles() {
</div> </div>
)} )}
</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> </SonumLayout>
); );
} }

View File

@@ -752,3 +752,17 @@ export async function getToutesLesSolutionsGroupees() {
return Array.from(map.values()); return Array.from(map.values());
} }
export async function updateSolution(id: number, nom: string, editeurId: number, blocFonctionnelId: number | null) {
const db = await getDb();
if (!db) throw new Error("DB unavailable");
await db.update(solutions).set({ nom, editeurId, blocFonctionnelId }).where(eq(solutions.id, id));
return { id };
}
export async function deleteSolution(id: number) {
const db = await getDb();
if (!db) throw new Error("DB unavailable");
await db.delete(solutions).where(eq(solutions.id, id));
return { success: true };
}

View File

@@ -8,6 +8,8 @@ import {
createEditeur, createEditeur,
createLocalUser, createLocalUser,
createSolution, createSolution,
updateSolution,
deleteSolution,
deleteLogicielEtablissement, deleteLogicielEtablissement,
deleteUser, deleteUser,
getAllDemandes, getAllDemandes,
@@ -150,6 +152,23 @@ export const appRouter = router({
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin"; const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
return createSolution(input.nom, input.editeurId, input.blocFonctionnelId, isGestionnaire); return createSolution(input.nom, input.editeurId, input.blocFonctionnelId, isGestionnaire);
}), }),
updateSolution: protectedProcedure
.input(z.object({
id: z.number().int(),
nom: z.string().min(1),
editeurId: z.number().int(),
blocFonctionnelId: z.number().int().optional().nullable(),
}))
.mutation(async ({ input, ctx }) => {
if (ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") throw new TRPCError({ code: "FORBIDDEN" });
return updateSolution(input.id, input.nom, input.editeurId, input.blocFonctionnelId ?? null);
}),
deleteSolution: protectedProcedure
.input(z.object({ id: z.number().int() }))
.mutation(async ({ input, ctx }) => {
if (ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") throw new TRPCError({ code: "FORBIDDEN" });
return deleteSolution(input.id);
}),
}), }),
// ─── Établissements ──────────────────────────────────────────────────────── // ─── Établissements ────────────────────────────────────────────────────────