Checkpoint: Application SONUM v1.0 - Cartographie des Solutions Numériques FEHAP. Toutes les fonctionnalités implémentées : charte CGU, moteur de recherche multicritères avec tri, Mes Établissements avec accordéons et édition inline, formulaire de saisie guidé en 3 étapes, fiche établissement avec compteur de consultation, prise de contact, Mes Demandes de Contact avec réponse directe, administration SONUM, gestion des rôles (Référent / Gestionnaire), traçabilité, lien espace adhérent FEHAP. 14 tests Vitest passés.
This commit is contained in:
369
client/src/pages/MesEtablissements.tsx
Normal file
369
client/src/pages/MesEtablissements.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { EtatBadge, HebergementBadge, FacturationBadge } 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 { useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { toast } from "sonner";
|
||||
import RattacherSolutionModal from "@/components/RattacherSolutionModal";
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
if (etablissementsQuery.isLoading) {
|
||||
return (
|
||||
<SonumLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
</SonumLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const etablissements = etablissementsQuery.data ?? [];
|
||||
|
||||
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>
|
||||
</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} />
|
||||
<span>{etablissements.length} établissement{etablissements.length > 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{etablissements.length === 0 ? (
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{etablissements.map((etab) => (
|
||||
<EtablissementAccordion
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rattacherEtab && (
|
||||
<RattacherSolutionModal
|
||||
etablissementId={rattacherEtab.id}
|
||||
etablissementNom={rattacherEtab.nom}
|
||||
onClose={() => setRattacherEtab(null)}
|
||||
onSuccess={() => {
|
||||
setRattacherEtab(null);
|
||||
utils.logiciels.byEtablissement.invalidate({ etablissementId: rattacherEtab.id });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SonumLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}) {
|
||||
const logicielsQuery = trpc.logiciels.byEtablissement.useQuery(
|
||||
{ etablissementId: etab.id },
|
||||
{ enabled: isOpen }
|
||||
);
|
||||
const deleteMutation = trpc.logiciels.delete.useMutation({
|
||||
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="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" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-foreground">{etab.nom}</div>
|
||||
<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>
|
||||
)}
|
||||
</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" />
|
||||
)}
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/20">
|
||||
<th className="text-left px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Solution</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden md:table-cell">Éditeur</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 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>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{logicielsQuery.data.map((logiciel) => (
|
||||
<LogicielRow
|
||||
key={logiciel.id}
|
||||
logiciel={logiciel}
|
||||
isEditMode={isEditMode}
|
||||
etablissementId={etab.id}
|
||||
onDelete={() => deleteMutation.mutate({ id: logiciel.id, etablissementId: etab.id })}
|
||||
onUpdated={() => logicielsQuery.refetch()}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogicielRow({
|
||||
logiciel,
|
||||
isEditMode,
|
||||
etablissementId,
|
||||
onDelete,
|
||||
onUpdated,
|
||||
}: {
|
||||
logiciel: any;
|
||||
isEditMode: boolean;
|
||||
etablissementId: number;
|
||||
onDelete: () => void;
|
||||
onUpdated: () => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [etat, setEtat] = useState(logiciel.etatDeploiement);
|
||||
const [commentaire, setCommentaire] = useState(logiciel.commentaire ?? "");
|
||||
|
||||
const updateMutation = trpc.logiciels.upsert.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Logiciel mis à jour");
|
||||
setEditing(false);
|
||||
onUpdated();
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 && (
|
||||
<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>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user