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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
14
server/db.ts
14
server/db.ts
@@ -752,3 +752,17 @@ export async function getToutesLesSolutionsGroupees() {
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
createEditeur,
|
||||
createLocalUser,
|
||||
createSolution,
|
||||
updateSolution,
|
||||
deleteSolution,
|
||||
deleteLogicielEtablissement,
|
||||
deleteUser,
|
||||
getAllDemandes,
|
||||
@@ -150,6 +152,23 @@ export const appRouter = router({
|
||||
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user