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, Users,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useState, useMemo } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -360,7 +360,7 @@ function UsersPanel() {
</span> </span>
</td> </td>
<td className="px-4 py-3.5 hidden xl:table-cell"> <td className="px-4 py-3.5 hidden xl:table-cell">
{u.sonumRole === "adherent" ? ( {u.sonumRole !== "gestionnaire" ? (
<button <button
onClick={() => setExpandedAffectations(isExpanded ? null : u.id)} 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" 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} /> <Key size={14} />
</button> </button>
{u.sonumRole === "adherent" && ( {u.sonumRole !== "gestionnaire" && (
<button <button
onClick={() => setExpandedAffectations(isExpanded ? null : u.id)} 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" 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} /> <Building2 size={14} />
</button> </button>
@@ -435,44 +435,17 @@ function UsersPanel() {
</td> </td>
</tr> </tr>
{/* Panneau d'affectations (adhérent uniquement) */} {/* Panneau d'affectations (tous sauf gestionnaire) */}
{isExpanded && u.sonumRole === "adherent" && ( {isExpanded && u.sonumRole !== "gestionnaire" && (
<tr key={`aff-${u.id}`}> <AffectationsPanel
<td colSpan={6} className="px-5 py-4 bg-emerald-50/50 border-b border-emerald-100"> key={`aff-${u.id}`}
<div className="max-w-2xl"> user={u}
<p className="text-xs font-semibold text-emerald-800 mb-3 flex items-center gap-1.5"> affectedIds={affectedIds}
<Building2 size={13} /> onToggle={(etabId) => toggleAffectation(u.id, etabId, affectedIds)}
Établissements affectés à {u.name} isPending={setAffectationsMutation.isPending}
</p> etablissements={etablissementsQuery.data ?? []}
{etablissementsQuery.isLoading ? ( isLoading={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>
)} )}
</> </>
); );
@@ -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() { function EtablissementsPanel() {
const etablissementsQuery = trpc.etablissements.all.useQuery(); const etablissementsQuery = trpc.etablissements.all.useQuery();
const usersQuery = trpc.admin.users.useQuery(); const usersQuery = trpc.admin.users.useQuery();

93
scripts/create-admin.mjs Normal file
View File

@@ -0,0 +1,93 @@
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import * as schema from "../drizzle/schema.ts";
import dotenv from "dotenv";
dotenv.config();
const { users, localCredentials } = schema;
const EMAIL = "adminItinova"; // login (utilisé comme email)
const PASSWORD = "Itinova69!";
const NAME = "Gestionnaire SONUM (Itinova)";
const SONUM_ROLE = "gestionnaire";
const OPEN_ID = "local-admin-itinova"; // openId unique pour compte local
async function main() {
if (!process.env.DATABASE_URL) {
console.error("DATABASE_URL manquant");
process.exit(1);
}
const connection = await mysql.createConnection(process.env.DATABASE_URL);
const db = drizzle(connection);
// Vérifier si l'utilisateur existe déjà
const existing = await db
.select()
.from(users)
.where(eq(users.openId, OPEN_ID))
.limit(1);
let userId;
if (existing.length > 0) {
userId = existing[0].id;
console.log(`Utilisateur existant trouvé (id=${userId}), mise à jour du mot de passe...`);
// Mettre à jour le rôle au cas où
await db
.update(users)
.set({ sonumRole: SONUM_ROLE, name: NAME, email: EMAIL, updatedAt: new Date() })
.where(eq(users.id, userId));
} else {
// Créer l'utilisateur
const result = await db.insert(users).values({
openId: OPEN_ID,
name: NAME,
email: EMAIL,
loginMethod: "local",
role: "user",
sonumRole: SONUM_ROLE,
cguAccepted: true,
cguAcceptedAt: new Date(),
lastSignedIn: new Date(),
});
userId = Number(result[0].insertId);
console.log(`Utilisateur créé (id=${userId})`);
}
// Hasher le mot de passe
const passwordHash = await bcrypt.hash(PASSWORD, 12);
// Insérer ou mettre à jour les credentials locaux
const existingCreds = await db
.select()
.from(localCredentials)
.where(eq(localCredentials.userId, userId))
.limit(1);
if (existingCreds.length > 0) {
await db
.update(localCredentials)
.set({ passwordHash, updatedAt: new Date() })
.where(eq(localCredentials.userId, userId));
console.log("Mot de passe mis à jour.");
} else {
await db.insert(localCredentials).values({ userId, passwordHash });
console.log("Credentials locaux créés.");
}
console.log("\n✅ Compte générique gestionnaire SONUM prêt :");
console.log(` Login : ${EMAIL}`);
console.log(` Rôle : ${SONUM_ROLE}`);
console.log(` Nom : ${NAME}`);
await connection.end();
}
main().catch((err) => {
console.error("Erreur :", err);
process.exit(1);
});

View File

@@ -0,0 +1,73 @@
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import * as schema from "../drizzle/schema.ts";
import dotenv from "dotenv";
dotenv.config();
const { users, localCredentials } = schema;
// ─── Paramètres ───────────────────────────────────────────────────────────────
const TARGET_EMAIL = "o.pareige@itinova.org";
const NEW_PASSWORD = "Itinova69!";
// ─────────────────────────────────────────────────────────────────────────────
async function main() {
if (!process.env.DATABASE_URL) {
console.error("DATABASE_URL manquant");
process.exit(1);
}
const connection = await mysql.createConnection(process.env.DATABASE_URL);
const db = drizzle(connection);
// Trouver l'utilisateur par email
const result = await db
.select()
.from(users)
.where(eq(users.email, TARGET_EMAIL))
.limit(1);
if (!result.length) {
console.error(`Aucun utilisateur trouvé avec l'email : ${TARGET_EMAIL}`);
await connection.end();
process.exit(1);
}
const user = result[0];
console.log(`Utilisateur trouvé : ${user.name} (id=${user.id}, rôle=${user.sonumRole})`);
// Hasher le mot de passe
const passwordHash = await bcrypt.hash(NEW_PASSWORD, 12);
// Insérer ou mettre à jour les credentials locaux
const existingCreds = await db
.select()
.from(localCredentials)
.where(eq(localCredentials.userId, user.id))
.limit(1);
if (existingCreds.length > 0) {
await db
.update(localCredentials)
.set({ passwordHash, updatedAt: new Date() })
.where(eq(localCredentials.userId, user.id));
console.log("Mot de passe mis à jour.");
} else {
await db.insert(localCredentials).values({ userId: user.id, passwordHash });
console.log("Credentials locaux créés.");
}
console.log(`\n✅ Connexion locale activée pour ${TARGET_EMAIL}`);
console.log(` Login : ${TARGET_EMAIL}`);
console.log(` Rôle : ${user.sonumRole}`);
await connection.end();
}
main().catch((err) => {
console.error("Erreur :", err);
process.exit(1);
});

View File

@@ -56,3 +56,8 @@
- [x] Page admin : affectations établissements inline dans le tableau utilisateurs - [x] Page admin : affectations établissements inline dans le tableau utilisateurs
- [x] Badge "Adhérent FEHAP" dans le header et la sidebar - [x] Badge "Adhérent FEHAP" dans le header et la sidebar
- [x] Vue "Mes Établissements" filtrée pour les adhérents (uniquement les établissements affectés) - [x] Vue "Mes Établissements" filtrée pour les adhérents (uniquement les établissements affectés)
## Évolution v3 — Gestion des établissements par utilisateur
- [x] Étendre admin.setAffectations pour accepter tous les rôles (référent + adhérent)
- [x] Interface Admin : panneau dédié "Établissements" par utilisateur avec liste complète, barre de recherche et cases à cocher
- [x] Afficher le nombre d'établissements affectés dans le tableau utilisateurs