v11: affectation établissements depuis Admin + filtrage par rôle + icônes solutions vertes [1777031753]

This commit is contained in:
Manus Deploy
2026-04-24 07:55:53 -04:00
parent 968b6ca2a3
commit 43a85f6150
4 changed files with 371 additions and 36 deletions

1
VERSION Normal file
View File

@@ -0,0 +1 @@
v11-1777031516

View File

@@ -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,9 +1004,11 @@ 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">
<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>}
@@ -829,27 +1021,57 @@ function EtablissementsPanel() {
</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">
<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="italic">Non assigné</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>
)}
</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">
{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-xs text-muted-foreground italic">Aucun</span>
<span className="text-[10px] text-muted-foreground italic">Aucun adhérent</span>
)}
</div>
</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>

View File

@@ -183,6 +183,21 @@ export async function searchEtablissements(filters: {
if (filters.sonumRole !== "gestionnaire") {
conditions.push(eq(etablissements.visibilite, "tous"));
}
// Filtrage par affectations : les adhérents ne voient que leurs établissements affectés
if (filters.sonumRole === "adherent" && filters.userId) {
const affectations = await getAffectationsByUser(filters.userId);
if (affectations.length === 0) {
return []; // Aucun établissement affecté
}
conditions.push(inArray(etablissements.id, affectations));
}
// Filtrage par référent : les référents ne voient que leurs établissements
if (filters.sonumRole === "referent" && filters.userId) {
conditions.push(eq(etablissements.referentId, filters.userId));
}
if (filters.region) conditions.push(eq(etablissements.region, filters.region));
if (filters.typeActivite) conditions.push(eq(etablissements.typeActivite, filters.typeActivite));
if (filters.tailleEffectifs) conditions.push(eq(etablissements.tailleEffectifs, filters.tailleEffectifs));
@@ -949,3 +964,70 @@ export async function getStatistiques() {
})),
};
}
// ─── Affectations depuis l'établissement ─────────────────────────────────────
/**
* Retourne les utilisateurs (adhérents + référent) associés à un établissement.
*/
export async function getUsersForEtablissement(etablissementId: number) {
const db = await getDb();
if (!db) return { adherents: [], referentId: null as number | null };
// Adhérents affectés via user_etablissements
const adherentsRows = await db
.select({
id: users.id,
name: users.name,
firstName: users.firstName,
lastName: users.lastName,
email: users.email,
sonumRole: users.sonumRole,
})
.from(userEtablissements)
.innerJoin(users, eq(userEtablissements.userId, users.id))
.where(eq(userEtablissements.etablissementId, etablissementId));
// Référent via etablissements.referentId
const etabRow = await db
.select({ referentId: etablissements.referentId })
.from(etablissements)
.where(eq(etablissements.id, etablissementId))
.limit(1);
return {
adherents: adherentsRows,
referentId: etabRow[0]?.referentId ?? null,
};
}
/**
* Définit le référent numérique d'un établissement.
*/
export async function setReferentForEtablissement(etablissementId: number, referentId: number | null) {
const db = await getDb();
if (!db) return;
await db
.update(etablissements)
.set({ referentId: referentId ?? undefined })
.where(eq(etablissements.id, etablissementId));
}
/**
* Remplace tous les adhérents affectés à un établissement.
*/
export async function setAdherentsForEtablissement(etablissementId: number, userIds: number[]) {
const db = await getDb();
if (!db) return;
// Supprimer les affectations existantes pour cet établissement (seulement les adhérents)
// On supprime toutes les lignes user_etablissements pour cet établissement
await db
.delete(userEtablissements)
.where(eq(userEtablissements.etablissementId, etablissementId));
// Réinsérer
if (userIds.length > 0) {
await db.insert(userEtablissements).values(
userIds.map((uid) => ({ userId: uid, etablissementId }))
);
}
}

View File

@@ -39,6 +39,9 @@ import {
removeEtablissementFromUser,
repondreDemandeContact,
setAffectationsForUser,
getUsersForEtablissement,
setReferentForEtablissement,
setAdherentsForEtablissement,
updateLocalPassword,
updateUser,
updateUserCgu,
@@ -564,6 +567,33 @@ export const appRouter = router({
await removeEtablissementFromUser(input.userId, input.etablissementId);
return { success: true };
}),
/** Retourne les utilisateurs (adhérents + référent) d'un établissement */
getUsersForEtablissement: gestionnaireProcedure
.input(z.object({ etablissementId: z.number().int() }))
.query(({ input }) => getUsersForEtablissement(input.etablissementId)),
/** Définit le référent numérique d'un établissement */
setReferentForEtablissement: gestionnaireProcedure
.input(z.object({
etablissementId: z.number().int(),
referentId: z.number().int().nullable(),
}))
.mutation(async ({ input }) => {
await setReferentForEtablissement(input.etablissementId, input.referentId);
return { success: true };
}),
/** Remplace tous les adhérents affectés à un établissement */
setAdherentsForEtablissement: gestionnaireProcedure
.input(z.object({
etablissementId: z.number().int(),
userIds: z.array(z.number().int()),
}))
.mutation(async ({ input }) => {
await setAdherentsForEtablissement(input.etablissementId, input.userIds);
return { success: true };
}),
}),
});