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 */}
|
{/* 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"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
<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 && (
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<Pencil size={13} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onDelete}
|
|
||||||
className="p-1.5 rounded bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<div>
|
||||||
)}
|
<Label className="text-xs font-semibold uppercase text-muted-foreground mb-1.5 block">Mode de facturation</Label>
|
||||||
</tr>
|
<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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
14
server/db.ts
14
server/db.ts
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user