Checkpoint: Le panneau d'affectations des établissements dans la page Admin est désormais disponible pour tous les utilisateurs (référents et adhérents), pas uniquement les adhérents. Ajout d'une barre de recherche dans le panneau, affichage du numéro FINESS, et composant AffectationsPanel réutilisable. Zéro erreur TypeScript.

This commit is contained in:
Manus
2026-04-16 11:07:29 -04:00
parent 249aa80e38
commit 77839a08ff
4 changed files with 293 additions and 43 deletions

View File

@@ -17,7 +17,7 @@ import {
Users,
X,
} from "lucide-react";
import { useState } from "react";
import { useState, useMemo } from "react";
import { toast } from "sonner";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -360,7 +360,7 @@ function UsersPanel() {
</span>
</td>
<td className="px-4 py-3.5 hidden xl:table-cell">
{u.sonumRole === "adherent" ? (
{u.sonumRole !== "gestionnaire" ? (
<button
onClick={() => setExpandedAffectations(isExpanded ? null : u.id)}
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 font-medium transition-colors"
@@ -409,11 +409,11 @@ function UsersPanel() {
>
<Key size={14} />
</button>
{u.sonumRole === "adherent" && (
{u.sonumRole !== "gestionnaire" && (
<button
onClick={() => setExpandedAffectations(isExpanded ? null : u.id)}
className="p-1.5 rounded-lg text-muted-foreground hover:bg-emerald-50 hover:text-emerald-600 transition-colors xl:hidden"
title="Gérer les affectations"
title="Gérer les établissements"
>
<Building2 size={14} />
</button>
@@ -435,44 +435,17 @@ function UsersPanel() {
</td>
</tr>
{/* Panneau d'affectations (adhérent uniquement) */}
{isExpanded && u.sonumRole === "adherent" && (
<tr key={`aff-${u.id}`}>
<td colSpan={6} className="px-5 py-4 bg-emerald-50/50 border-b border-emerald-100">
<div className="max-w-2xl">
<p className="text-xs font-semibold text-emerald-800 mb-3 flex items-center gap-1.5">
<Building2 size={13} />
Établissements affectés à {u.name}
</p>
{etablissementsQuery.isLoading ? (
<div className="text-xs text-muted-foreground">Chargement...</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{etablissementsQuery.data?.map((etab) => {
const isAffected = affectedIds.includes(etab.id);
return (
<button
key={etab.id}
onClick={() => toggleAffectation(u.id, etab.id, affectedIds)}
disabled={setAffectationsMutation.isPending}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-xs font-medium transition-all text-left ${
isAffected
? "bg-emerald-100 border-emerald-300 text-emerald-800"
: "bg-white border-border text-muted-foreground hover:border-emerald-200 hover:text-foreground"
}`}
>
<div className={`w-4 h-4 rounded flex items-center justify-center flex-shrink-0 ${isAffected ? "bg-emerald-600" : "bg-muted border border-border"}`}>
{isAffected && <Check size={10} className="text-white" />}
</div>
<span className="truncate">{etab.nom}</span>
</button>
);
})}
</div>
)}
</div>
</td>
</tr>
{/* Panneau d'affectations (tous sauf gestionnaire) */}
{isExpanded && u.sonumRole !== "gestionnaire" && (
<AffectationsPanel
key={`aff-${u.id}`}
user={u}
affectedIds={affectedIds}
onToggle={(etabId) => toggleAffectation(u.id, etabId, affectedIds)}
isPending={setAffectationsMutation.isPending}
etablissements={etablissementsQuery.data ?? []}
isLoading={etablissementsQuery.isLoading}
/>
)}
</>
);
@@ -532,8 +505,114 @@ function UsersPanel() {
);
}
// ─── Panel Établissements ─────────────────────────────────────────────────────
// ─── Panneau d'affectations (composant réutilisable) ────────────────────────────────────────────────────────────────────────────────
function AffectationsPanel({
user,
affectedIds,
onToggle,
isPending,
etablissements,
isLoading,
}: {
user: any;
affectedIds: number[];
onToggle: (etabId: number) => void;
isPending: boolean;
etablissements: any[];
isLoading: boolean;
}) {
const [search, setSearch] = useState("");
const filtered = useMemo(() => {
const q = search.toLowerCase().trim();
if (!q) return etablissements;
return etablissements.filter(
(e) =>
e.nom?.toLowerCase().includes(q) ||
e.finess?.toLowerCase().includes(q) ||
e.region?.toLowerCase().includes(q)
);
}, [etablissements, search]);
const roleLabel =
user.sonumRole === "adherent" ? "Adhérent FEHAP" : "Référent numérique";
return (
<tr>
<td colSpan={6} className="px-5 py-4 bg-emerald-50/50 border-b border-emerald-100">
<div>
{/* En-tête */}
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<p className="text-xs font-semibold text-emerald-800 flex items-center gap-1.5">
<Building2 size={13} />
Établissements de {user.name}
<span className="font-normal text-emerald-600">({roleLabel})</span>
<span className="ml-1 bg-emerald-200 text-emerald-800 rounded-full px-2 py-0.5">
{affectedIds.length} sélectionné{affectedIds.length !== 1 ? "s" : ""}
</span>
</p>
{/* Barre de recherche */}
<div className="relative">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Rechercher un établissement..."
className="pl-7 pr-3 py-1.5 text-xs bg-white border border-emerald-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-emerald-400 w-56"
/>
<svg className="absolute left-2 top-1/2 -translate-y-1/2 text-emerald-400" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
</div>
</div>
{isLoading ? (
<div className="text-xs text-muted-foreground">Chargement...</div>
) : filtered.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
{search ? "Aucun établissement ne correspond à la recherche." : "Aucun établissement disponible."}
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
{filtered.map((etab) => {
const isAffected = affectedIds.includes(etab.id);
return (
<button
key={etab.id}
onClick={() => onToggle(etab.id)}
disabled={isPending}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-xs font-medium transition-all text-left ${
isAffected
? "bg-emerald-100 border-emerald-300 text-emerald-800"
: "bg-white border-border text-muted-foreground hover:border-emerald-200 hover:text-foreground"
}`}
>
<div
className={`w-4 h-4 rounded flex items-center justify-center flex-shrink-0 ${
isAffected ? "bg-emerald-600" : "bg-muted border border-border"
}`}
>
{isAffected && <Check size={10} className="text-white" />}
</div>
<div className="min-w-0">
<div className="truncate">{etab.nom}</div>
{etab.finess && (
<div className="text-[10px] text-muted-foreground truncate">{etab.finess}</div>
)}
</div>
</button>
);
})}
</div>
)}
</div>
</td>
</tr>
);
}
// ─── Panel Établissements ────────────────────────────────────────────────────────────────────────────────
function EtablissementsPanel() {
const etablissementsQuery = trpc.etablissements.all.useQuery();
const usersQuery = trpc.admin.users.useQuery();