v11: affectation établissements depuis Admin + filtrage par rôle + icônes solutions vertes [1777031753]
This commit is contained in:
@@ -675,11 +675,200 @@ function AffectationsPanel({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sous-panneau d'affectation d'un établissement ───────────────────────────
|
||||
function EtabAffectationPanel({
|
||||
etab,
|
||||
allUsers,
|
||||
onClose,
|
||||
}: {
|
||||
etab: { id: number; nom: string; referentId?: number | null };
|
||||
allUsers: Array<{ id: number; name: string | null; email: string | null; firstName: string | null; lastName: string | null; sonumRole: string }>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const usersQuery = trpc.admin.getUsersForEtablissement.useQuery({ etablissementId: etab.id });
|
||||
|
||||
const [selectedReferentId, setSelectedReferentId] = useState<number | null>(etab.referentId ?? null);
|
||||
const [selectedAdherentIds, setSelectedAdherentIds] = useState<number[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// Initialiser les sélections depuis les données chargées
|
||||
if (usersQuery.data && !initialized) {
|
||||
setSelectedReferentId(usersQuery.data.referentId ?? null);
|
||||
setSelectedAdherentIds(usersQuery.data.adherents.map((a) => a.id));
|
||||
setInitialized(true);
|
||||
}
|
||||
|
||||
const setReferentMutation = trpc.admin.setReferentForEtablissement.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.etablissements.all.invalidate();
|
||||
utils.admin.getUsersForEtablissement.invalidate({ etablissementId: etab.id });
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const setAdherentsMutation = trpc.admin.setAdherentsForEtablissement.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.admin.users.invalidate();
|
||||
utils.admin.getUsersForEtablissement.invalidate({ etablissementId: etab.id });
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
await setReferentMutation.mutateAsync({ etablissementId: etab.id, referentId: selectedReferentId });
|
||||
await setAdherentsMutation.mutateAsync({ etablissementId: etab.id, userIds: selectedAdherentIds });
|
||||
toast.success("Affectations enregistrées");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const referents = allUsers.filter((u) => u.sonumRole === "referent" || u.sonumRole === "gestionnaire");
|
||||
const adherents = allUsers.filter((u) => u.sonumRole === "adherent");
|
||||
const filteredAdherents = search
|
||||
? adherents.filter((u) =>
|
||||
(u.name ?? u.email ?? "").toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: adherents;
|
||||
|
||||
const toggleAdherent = (id: number) => {
|
||||
setSelectedAdherentIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const isPending = setReferentMutation.isPending || setAdherentsMutation.isPending;
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-4 bg-blue-50/60 border-b border-blue-100">
|
||||
<div className="space-y-4">
|
||||
{/* En-tête */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-blue-800 flex items-center gap-1.5">
|
||||
<UserCheck size={14} />
|
||||
Affecter des utilisateurs à <span className="font-bold">{etab.nom}</span>
|
||||
</p>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{usersQuery.isLoading ? (
|
||||
<div className="text-xs text-muted-foreground">Chargement...</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Référent numérique */}
|
||||
<div className="bg-white rounded-lg border border-blue-200 p-3">
|
||||
<p className="text-xs font-semibold text-foreground mb-2 flex items-center gap-1.5">
|
||||
<Shield size={12} className="text-blue-600" />
|
||||
Référent numérique
|
||||
</p>
|
||||
<select
|
||||
value={selectedReferentId ?? ""}
|
||||
onChange={(e) => setSelectedReferentId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
>
|
||||
<option value="">— Non assigné —</option>
|
||||
{referents.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name ?? r.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] text-muted-foreground mt-1.5">
|
||||
Le référent numérique peut saisir et modifier les logiciels de cet établissement.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Adhérents FEHAP */}
|
||||
<div className="bg-white rounded-lg border border-emerald-200 p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs font-semibold text-foreground flex items-center gap-1.5">
|
||||
<Users size={12} className="text-emerald-600" />
|
||||
Adhérents FEHAP
|
||||
{selectedAdherentIds.length > 0 && (
|
||||
<span className="bg-emerald-100 text-emerald-700 rounded-full px-1.5 py-0.5 text-[10px]">
|
||||
{selectedAdherentIds.length}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Rechercher..."
|
||||
className="pl-2 pr-2 py-1 text-xs bg-background border border-border rounded focus:outline-none focus:ring-1 focus:ring-emerald-400 w-32"
|
||||
/>
|
||||
</div>
|
||||
{adherents.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">Aucun adhérent créé.</p>
|
||||
) : filteredAdherents.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">Aucun résultat.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto pr-1">
|
||||
{filteredAdherents.map((u) => {
|
||||
const isSelected = selectedAdherentIds.includes(u.id);
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => toggleAdherent(u.id)}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all text-left ${
|
||||
isSelected
|
||||
? "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-3.5 h-3.5 rounded flex items-center justify-center flex-shrink-0 ${
|
||||
isSelected ? "bg-emerald-600" : "bg-muted border border-border"
|
||||
}`}
|
||||
>
|
||||
{isSelected && <Check size={9} className="text-white" />}
|
||||
</div>
|
||||
<span className="truncate">{u.name ?? u.email}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground mt-1.5">
|
||||
Les adhérents sélectionnés pourront consulter la fiche de cet établissement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-xs font-medium border border-border rounded-lg text-foreground hover:bg-muted"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isPending || usersQuery.isLoading}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium bg-primary text-white rounded-lg hover:bg-primary/90 disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
<Check size={13} />
|
||||
{isPending ? "Enregistrement..." : "Enregistrer les affectations"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Panel Établissements ────────────────────────────────────────────────────────────────────────────────
|
||||
function EtablissementsPanel() {
|
||||
const etablissementsQuery = trpc.etablissements.all.useQuery();
|
||||
const usersQuery = trpc.admin.users.useQuery();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [expandedEtabId, setExpandedEtabId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
nom: "",
|
||||
finess: "",
|
||||
@@ -700,6 +889,7 @@ function EtablissementsPanel() {
|
||||
});
|
||||
|
||||
const referents = usersQuery.data?.filter((u) => u.sonumRole === "referent" || u.sonumRole === "gestionnaire") ?? [];
|
||||
const allUsers = usersQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
|
||||
@@ -760,7 +950,7 @@ function EtablissementsPanel() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-foreground mb-1">Référent numérique</label>
|
||||
<label className="block text-xs font-medium text-foreground mb-1">Référent numérique (optionnel)</label>
|
||||
<select
|
||||
value={form.referentId ?? ""}
|
||||
onChange={(e) => setForm((f) => ({ ...f, referentId: e.target.value ? Number(e.target.value) : undefined }))}
|
||||
@@ -797,15 +987,15 @@ function EtablissementsPanel() {
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
<div className="overflow-x-auto max-h-[600px] overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-left px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Établissement</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden md:table-cell">Région</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Type</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden xl:table-cell">Référent</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden xl:table-cell">Adhérents affectés</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Référent / Adhérents</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
@@ -814,42 +1004,74 @@ function EtablissementsPanel() {
|
||||
(u) => u.sonumRole === "adherent" && (u.etablissements ?? []).some((e: any) => e.id === etab.id)
|
||||
) ?? [];
|
||||
const referent = usersQuery.data?.find((u) => u.id === etab.referentId);
|
||||
const isExpanded = expandedEtabId === etab.id;
|
||||
|
||||
return (
|
||||
<tr key={etab.id} className="hover:bg-muted/20 transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<div className="font-medium text-foreground text-sm">{etab.nom}</div>
|
||||
{etab.finess && <div className="text-xs text-muted-foreground">FINESS : {etab.finess}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell text-muted-foreground text-sm">{etab.region ?? "—"}</td>
|
||||
<td className="px-4 py-3 hidden lg:table-cell">
|
||||
{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-muted-foreground text-xs">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden xl:table-cell text-muted-foreground text-xs">
|
||||
{referent ? (
|
||||
<span className="font-medium text-foreground">{referent.name ?? referent.email}</span>
|
||||
) : (
|
||||
<span className="italic">Non assigné</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden xl:table-cell">
|
||||
{adherentsAffectes.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{adherentsAffectes.map((a) => (
|
||||
<span key={a.id} className="text-xs bg-emerald-50 text-emerald-700 border border-emerald-200 px-2 py-0.5 rounded-full">
|
||||
{a.name ?? a.email}
|
||||
<Fragment key={etab.id}>
|
||||
<tr className={`transition-colors ${isExpanded ? "bg-blue-50/40" : "hover:bg-muted/20"}`}>
|
||||
<td className="px-5 py-3">
|
||||
<div className="font-medium text-foreground text-sm">{etab.nom}</div>
|
||||
{etab.finess && <div className="text-xs text-muted-foreground">FINESS : {etab.finess}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell text-muted-foreground text-sm">{etab.region ?? "—"}</td>
|
||||
<td className="px-4 py-3 hidden lg:table-cell">
|
||||
{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-muted-foreground text-xs">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
{referent ? (
|
||||
<span className="text-xs flex items-center gap-1">
|
||||
<Shield size={10} className="text-blue-500 flex-shrink-0" />
|
||||
<span className="font-medium text-foreground">{referent.name ?? referent.email}</span>
|
||||
</span>
|
||||
))}
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground italic flex items-center gap-1">
|
||||
<Shield size={10} className="flex-shrink-0" /> Pas de référent
|
||||
</span>
|
||||
)}
|
||||
{adherentsAffectes.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{adherentsAffectes.slice(0, 3).map((a) => (
|
||||
<span key={a.id} className="text-[10px] bg-emerald-50 text-emerald-700 border border-emerald-200 px-1.5 py-0.5 rounded-full">
|
||||
{a.name ?? a.email}
|
||||
</span>
|
||||
))}
|
||||
{adherentsAffectes.length > 3 && (
|
||||
<span className="text-[10px] text-muted-foreground">+{adherentsAffectes.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground italic">Aucun adhérent</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground italic">Aucun</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => setExpandedEtabId(isExpanded ? null : etab.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ml-auto ${
|
||||
isExpanded
|
||||
? "bg-blue-100 border-blue-300 text-blue-700"
|
||||
: "border-border text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<UserCheck size={12} />
|
||||
{isExpanded ? "Fermer" : "Affecter"}
|
||||
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<EtabAffectationPanel
|
||||
etab={etab}
|
||||
allUsers={allUsers}
|
||||
onClose={() => setExpandedEtabId(null)}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user