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:
@@ -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();
|
||||
|
||||
93
scripts/create-admin.mjs
Normal file
93
scripts/create-admin.mjs
Normal 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);
|
||||
});
|
||||
73
scripts/set-local-password.mjs
Normal file
73
scripts/set-local-password.mjs
Normal 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);
|
||||
});
|
||||
5
todo.md
5
todo.md
@@ -56,3 +56,8 @@
|
||||
- [x] Page admin : affectations établissements inline dans le tableau utilisateurs
|
||||
- [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)
|
||||
|
||||
## É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
|
||||
|
||||
Reference in New Issue
Block a user