diff --git a/client/src/App.tsx b/client/src/App.tsx
index 29cc16e..5748093 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -11,6 +11,8 @@ import FicheEtablissement from "./pages/FicheEtablissement";
import Admin from "./pages/Admin";
import Login from "./pages/Login";
import LoginLocal from "./pages/LoginLocal";
+import MesSolutions from "./pages/MesSolutions";
+import SolutionsLogicielles from "./pages/SolutionsLogicielles";
function Router() {
return (
@@ -25,6 +27,8 @@ function Router() {
+
+
{/* Fallback */}
diff --git a/client/src/components/SonumLayout.tsx b/client/src/components/SonumLayout.tsx
index 68e7988..4f11b34 100644
--- a/client/src/components/SonumLayout.tsx
+++ b/client/src/components/SonumLayout.tsx
@@ -2,6 +2,7 @@ import { useAuth } from "@/_core/hooks/useAuth";
import { getLoginUrl } from "@/const";
import { trpc } from "@/lib/trpc";
import {
+ BarChart2,
Bell,
Building2,
ChevronRight,
@@ -10,6 +11,7 @@ import {
LogOut,
Mail,
Menu,
+ Package,
Search,
Settings,
Shield,
@@ -29,6 +31,8 @@ interface NavItem {
const navItems: NavItem[] = [
{ label: "Moteur de recherche", href: "/", icon: },
{ label: "Mes Établissements", href: "/mes-etablissements", icon: },
+ { label: "Mes Solutions Numériques", href: "/mes-solutions", icon: },
+ { label: "Solutions Logicielles", href: "/solutions", icon: },
{ label: "Mes Demandes de Contact", href: "/mes-demandes", icon: },
];
@@ -203,7 +207,7 @@ export default function SonumLayout({ children }: { children: React.ReactNode })
- {navItems.find((n) => n.href === location)?.label ?? "SONUM"}
+ {[...navItems, ...adminNavItems].find((n) => n.href === location)?.label ?? "SONUM"}
diff --git a/client/src/pages/Admin.tsx b/client/src/pages/Admin.tsx
index 610474a..ec857ca 100644
--- a/client/src/pages/Admin.tsx
+++ b/client/src/pages/Admin.tsx
@@ -2,22 +2,27 @@ import { useAuth } from "@/_core/hooks/useAuth";
import SonumLayout from "@/components/SonumLayout";
import { trpc } from "@/lib/trpc";
import {
+ AlertCircle,
Building2,
Check,
+ CheckCircle,
ChevronDown,
ChevronUp,
+ Download,
Eye,
EyeOff,
+ FileText,
Key,
Pencil,
Plus,
Shield,
Trash2,
+ Upload,
UserCheck,
Users,
X,
} from "lucide-react";
-import { useState, useMemo, Fragment } from "react";
+import { useState, useMemo, Fragment, useRef, useCallback } from "react";
import { toast } from "sonner";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -40,7 +45,7 @@ const ROLE_COLORS: Record = {
export default function Admin() {
const { user } = useAuth();
- const [activeTab, setActiveTab] = useState<"users" | "etablissements">("users");
+ const [activeTab, setActiveTab] = useState<"users" | "etablissements" | "import-etab" | "import-contacts">("users");
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
if (!isGestionnaire) {
@@ -66,25 +71,32 @@ export default function Admin() {
{/* Onglets */}
-
- {(["users", "etablissements"] as const).map((tab) => (
+
+ {([
+ { id: "users" as const, label: "Utilisateurs", icon: },
+ { id: "etablissements" as const, label: "Établissements", icon: },
+ { id: "import-etab" as const, label: "Import Établissements", icon: },
+ { id: "import-contacts" as const, label: "Import Contacts", icon: },
+ ]).map((tab) => (
))}
{activeTab === "users" &&
}
{activeTab === "etablissements" &&
}
+ {activeTab === "import-etab" &&
}
+ {activeTab === "import-contacts" &&
}
);
@@ -796,3 +808,431 @@ function EtablissementsPanel() {
);
}
+
+// ─── Import Établissements ────────────────────────────────────────────────────
+
+type EtabRow = {
+ nom: string;
+ finess?: string;
+ region?: string;
+ typeActivite?: string;
+ taille?: string;
+ commune?: string;
+ statut?: string;
+};
+
+function ImportEtablissementsPanel() {
+ const utils = trpc.useUtils();
+ const [rows, setRows] = useState([]);
+ const [errors, setErrors] = useState([]);
+ const [importing, setImporting] = useState(false);
+ const [done, setDone] = useState(0);
+ const fileRef = useRef(null);
+
+ const createEtabMutation = trpc.etablissements.create.useMutation();
+
+ const parseCSV = useCallback((text: string) => {
+ const lines = text.split(/\r?\n/).filter((l) => l.trim());
+ if (lines.length < 2) { setErrors(["Le fichier est vide ou ne contient pas d'en-têtes."]); return; }
+ const headers = lines[0].split(/[;,\t]/).map((h) => h.trim().toLowerCase().replace(/[^a-z0-9]/g, ""));
+ const parsed: EtabRow[] = [];
+ const errs: string[] = [];
+ for (let i = 1; i < lines.length; i++) {
+ const cols = lines[i].split(/[;,\t]/);
+ const get = (keys: string[]) => {
+ for (const k of keys) {
+ const idx = headers.findIndex((h) => h.includes(k));
+ if (idx >= 0) return cols[idx]?.trim() ?? "";
+ }
+ return "";
+ };
+ const nom = get(["nom", "name", "etablissement"]);
+ if (!nom) { errs.push(`Ligne ${i + 1} : nom manquant`); continue; }
+ parsed.push({
+ nom,
+ finess: get(["finess", "numero"]),
+ region: get(["region"]),
+ typeActivite: get(["type", "activite"]),
+ taille: get(["taille", "taille"]),
+ commune: get(["commune", "ville"]),
+ statut: get(["statut", "juridique"]),
+ });
+ }
+ setRows(parsed);
+ setErrors(errs);
+ setDone(0);
+ }, []);
+
+ const handleFile = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = (ev) => parseCSV(ev.target?.result as string);
+ reader.readAsText(file, "UTF-8");
+ };
+
+ const handleImport = async () => {
+ setImporting(true);
+ let count = 0;
+ const errs: string[] = [];
+ for (const row of rows) {
+ try {
+ await createEtabMutation.mutateAsync({
+ nom: row.nom,
+ finess: row.finess || undefined,
+ region: row.region || undefined,
+ typeActivite: row.typeActivite || undefined,
+ tailleEffectifs: row.taille || undefined,
+ });
+ count++;
+ setDone(count);
+ } catch (err: any) {
+ errs.push(`${row.nom} : ${err.message}`);
+ }
+ }
+ setErrors(errs);
+ setImporting(false);
+ if (count > 0) {
+ toast.success(`${count} établissement(s) importé(s) avec succès`);
+ utils.etablissements.all.invalidate();
+ setRows([]);
+ if (fileRef.current) fileRef.current.value = "";
+ }
+ };
+
+ const downloadTemplate = () => {
+ const csv = "nom;finess;region;typeActivite;taille;commune;statut\nExemple Clinique;123456789;Île-de-France;SSR;100-200 lits;Paris;ESPIC\n";
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url; a.download = "modele_etablissements.csv"; a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ return (
+
+
+
+
+
+
+ Import d'établissements par CSV
+
+
+ Importez une liste d'établissements depuis un fichier CSV ou Excel (enregistré en CSV).
+
+
+
+
+
+ {/* Zone de dépôt */}
+
fileRef.current?.click()}
+ >
+
+
Cliquez pour sélectionner un fichier CSV
+
Séparateur : point-virgule, virgule ou tabulation
+
+
+
+ {/* Colonnes attendues */}
+
+
Colonnes reconnues :
+
+ {["nom *", "finess", "region", "typeActivite", "taille", "commune", "statut"].map((col) => (
+
+ {col}
+
+ ))}
+
+
+
+
+ {/* Erreurs de parsing */}
+ {errors.length > 0 && (
+
+
+
+
{errors.length} erreur(s) détectée(s)
+
+
+ {errors.map((e, i) => (
+ - {e}
+ ))}
+
+
+ )}
+
+ {/* Prévisualisation */}
+ {rows.length > 0 && (
+
+
+
+ {rows.length} établissement(s) à importer
+ {importing && ` — ${done}/${rows.length} traités`}
+
+
+
+
+
+
+
+ {["Nom", "FINESS", "Région", "Type d'activité", "Taille", "Commune", "Statut"].map((h) => (
+ | {h} |
+ ))}
+
+
+
+ {rows.map((row, i) => (
+
+ | {row.nom} |
+ {row.finess || "—"} |
+ {row.region || "—"} |
+ {row.typeActivite || "—"} |
+ {row.taille || "—"} |
+ {row.commune || "—"} |
+ {row.statut || "—"} |
+
+ ))}
+
+
+
+
+ )}
+
+ );
+}
+
+// ─── Import Contacts ──────────────────────────────────────────────────────────
+
+type ContactRow = {
+ nom: string;
+ email: string;
+ sonumRole: "referent" | "adherent";
+ etablissements?: string;
+ password?: string;
+};
+
+function ImportContactsPanel() {
+ const utils = trpc.useUtils();
+ const [rows, setRows] = useState([]);
+ const [errors, setErrors] = useState([]);
+ const [importing, setImporting] = useState(false);
+ const [done, setDone] = useState(0);
+ const fileRef = useRef(null);
+
+ const createUserMutation = trpc.admin.createUser.useMutation();
+
+ const parseCSV = useCallback((text: string) => {
+ const lines = text.split(/\r?\n/).filter((l) => l.trim());
+ if (lines.length < 2) { setErrors(["Le fichier est vide ou ne contient pas d'en-têtes."]); return; }
+ const headers = lines[0].split(/[;,\t]/).map((h) => h.trim().toLowerCase().replace(/[^a-z0-9]/g, ""));
+ const parsed: ContactRow[] = [];
+ const errs: string[] = [];
+ for (let i = 1; i < lines.length; i++) {
+ const cols = lines[i].split(/[;,\t]/);
+ const get = (keys: string[]) => {
+ for (const k of keys) {
+ const idx = headers.findIndex((h) => h.includes(k));
+ if (idx >= 0) return cols[idx]?.trim() ?? "";
+ }
+ return "";
+ };
+ const nom = get(["nom", "name", "prenom"]);
+ const email = get(["email", "mail", "courriel"]);
+ if (!nom) { errs.push(`Ligne ${i + 1} : nom manquant`); continue; }
+ if (!email || !email.includes("@")) { errs.push(`Ligne ${i + 1} : email invalide`); continue; }
+ const roleRaw = get(["role", "profil"]).toLowerCase();
+ const sonumRole: "referent" | "adherent" = roleRaw.includes("adh") ? "adherent" : "referent";
+ parsed.push({
+ nom,
+ email,
+ sonumRole,
+ etablissements: get(["etablissement", "structure"]),
+ password: get(["password", "motdepasse", "mdp"]) || "Sonum2024!",
+ });
+ }
+ setRows(parsed);
+ setErrors(errs);
+ setDone(0);
+ }, []);
+
+ const handleFile = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = (ev) => parseCSV(ev.target?.result as string);
+ reader.readAsText(file, "UTF-8");
+ };
+
+ const handleImport = async () => {
+ setImporting(true);
+ let count = 0;
+ const errs: string[] = [];
+ for (const row of rows) {
+ try {
+ await createUserMutation.mutateAsync({
+ name: row.nom,
+ email: row.email,
+ sonumRole: row.sonumRole,
+ password: row.password ?? "Sonum2024!",
+ });
+ count++;
+ setDone(count);
+ } catch (err: any) {
+ errs.push(`${row.email} : ${err.message}`);
+ }
+ }
+ setErrors(errs);
+ setImporting(false);
+ if (count > 0) {
+ toast.success(`${count} contact(s) importé(s) avec succès`);
+ utils.admin.users.invalidate();
+ setRows([]);
+ if (fileRef.current) fileRef.current.value = "";
+ }
+ };
+
+ const downloadTemplate = () => {
+ const csv = "nom;email;role;etablissements;password\nMarie Dupont;m.dupont@clinique.fr;referent;Clinique du Val;Sonum2024!\nPierre Martin;p.martin@fehap.fr;adherent;;Sonum2024!\n";
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url; a.download = "modele_contacts.csv"; a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ return (
+
+
+
+
+
+
+ Import de contacts (utilisateurs) par CSV
+
+
+ Importez une liste de référents ou adhérents. Un mot de passe par défaut "Sonum2024!" sera attribué si non renseigné.
+
+
+
+
+
+ {/* Zone de dépôt */}
+
fileRef.current?.click()}
+ >
+
+
Cliquez pour sélectionner un fichier CSV
+
Séparateur : point-virgule, virgule ou tabulation
+
+
+
+ {/* Colonnes attendues */}
+
+
Colonnes reconnues :
+
+ {["nom *", "email *", "role", "etablissements", "password"].map((col) => (
+
+ {col}
+
+ ))}
+
+
+ role : "referent" ou "adherent" (défaut : referent) — password : si vide, "Sonum2024!" sera utilisé
+
+
+
+
+ {/* Erreurs de parsing */}
+ {errors.length > 0 && (
+
+
+
+
{errors.length} erreur(s) détectée(s)
+
+
+ {errors.map((e, i) => (
+ - {e}
+ ))}
+
+
+ )}
+
+ {/* Prévisualisation */}
+ {rows.length > 0 && (
+
+
+
+ {rows.length} contact(s) à importer
+ {importing && ` — ${done}/${rows.length} traités`}
+
+
+
+
+
+
+
+ {["Nom", "Email", "Profil", "Établissements", "Mot de passe"].map((h) => (
+ | {h} |
+ ))}
+
+
+
+ {rows.map((row, i) => (
+
+ | {row.nom} |
+ {row.email} |
+
+
+ {row.sonumRole === "adherent" ? "Adhérent FEHAP" : "Référent numérique"}
+
+ |
+ {row.etablissements || "—"} |
+ {row.password} |
+
+ ))}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx
index cfa31fc..0be84fc 100644
--- a/client/src/pages/Home.tsx
+++ b/client/src/pages/Home.tsx
@@ -46,10 +46,13 @@ export default function Home() {
};
const cguQuery = trpc.cgu.status.useQuery(undefined, { enabled: isAuthenticated });
- // La CGU doit être acceptée à chaque nouvelle session (sessionStorage vidé à la fermeture du navigateur)
- const [sessionCguAccepted, setSessionCguAccepted] = useState(() =>
- typeof window !== "undefined" && sessionStorage.getItem("sonum_cgu_accepted") === "1"
- );
+ // La CGU doit être acceptée à chaque connexion : on stocke la clé "userId_cgu" dans sessionStorage
+ // sessionStorage est vidé à la fermeture du navigateur ET on utilise l'userId pour distinguer les sessions
+ const sessionKey = cguQuery.data?.userId ? `sonum_cgu_${cguQuery.data.userId}` : null;
+ const [sessionCguAccepted, setSessionCguAccepted] = useState(() => {
+ if (typeof window === "undefined" || !sessionKey) return false;
+ return sessionStorage.getItem(sessionKey) === "1";
+ });
const blocsQuery = trpc.referentiel.blocsFonctionnels.useQuery();
const editeursQuery = trpc.referentiel.editeurs.useQuery();
const solutionsQuery = trpc.referentiel.solutions.useQuery({ search: searchText.length >= 2 ? searchText : undefined });
@@ -103,7 +106,11 @@ export default function Home() {
if (!cguFullyAccepted) {
return (
- { setSessionCguAccepted(true); cguQuery.refetch(); }} />
+ {
+ if (sessionKey) sessionStorage.setItem(sessionKey, "1");
+ setSessionCguAccepted(true);
+ cguQuery.refetch();
+ }} />
);
}
diff --git a/client/src/pages/MesSolutions.tsx b/client/src/pages/MesSolutions.tsx
new file mode 100644
index 0000000..78bdee3
--- /dev/null
+++ b/client/src/pages/MesSolutions.tsx
@@ -0,0 +1,179 @@
+import { trpc } from "@/lib/trpc";
+import { useAuth } from "@/_core/hooks/useAuth";
+import SonumLayout from "@/components/SonumLayout";
+import { ChevronDown, ChevronRight, Building2, Package, Search, Filter } from "lucide-react";
+import { useState, useMemo } from "react";
+import { EtatBadge } from "@/components/EtatBadge";
+
+export default function MesSolutions() {
+ const { isAuthenticated } = useAuth();
+ const [openIds, setOpenIds] = useState>(new Set());
+ const [search, setSearch] = useState("");
+ const [filterBloc, setFilterBloc] = useState("");
+
+ const { data: solutions, isLoading } = trpc.logiciels.mesSolutions.useQuery(undefined, {
+ enabled: isAuthenticated,
+ });
+ const { data: blocs } = trpc.referentiel.blocsFonctionnels.useQuery();
+
+ const toggle = (id: number) => {
+ setOpenIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ const filtered = useMemo(() => {
+ if (!solutions) return [];
+ return solutions.filter((s) => {
+ const matchSearch =
+ !search ||
+ s.solutionNom.toLowerCase().includes(search.toLowerCase()) ||
+ s.editeurNom.toLowerCase().includes(search.toLowerCase());
+ const matchBloc = !filterBloc || s.blocFonctionnelNom === filterBloc;
+ return matchSearch && matchBloc;
+ });
+ }, [solutions, search, filterBloc]);
+
+ const blocsUniques = useMemo(() => {
+ if (!solutions) return [];
+ const set = new Set(solutions.map((s) => s.blocFonctionnelNom).filter(Boolean) as string[]);
+ return Array.from(set);
+ }, [solutions]);
+
+ return (
+
+
+ {/* En-tête */}
+
+
+
+
+
Mes Solutions Numériques
+
+ {solutions ? `${solutions.length} solution${solutions.length > 1 ? "s" : ""} référencée${solutions.length > 1 ? "s" : ""}` : "Chargement…"}
+
+
+
+
+
+ {/* Filtres */}
+
+
+
+ setSearch(e.target.value)}
+ className="w-full pl-9 pr-4 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
+ />
+
+
+
+
+
+
+
+ {/* Liste */}
+ {isLoading ? (
+
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+ ) : filtered.length === 0 ? (
+
+
+
Aucune solution trouvée
+
Rattachez des solutions à vos établissements pour les voir apparaître ici.
+
+ ) : (
+
+ {filtered.map((sol) => {
+ const isOpen = openIds.has(sol.solutionId);
+ return (
+
+ {/* En-tête accordéon */}
+
+
+ {/* Contenu accordéon */}
+ {isOpen && (
+
+
+
+ Établissements équipés
+
+
+ {sol.etablissements.map((etab) => (
+
+
+
+ {etab.nom}
+ {etab.region && (
+ — {etab.region}
+ )}
+
+
+
+ ))}
+
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/client/src/pages/SolutionsLogicielles.tsx b/client/src/pages/SolutionsLogicielles.tsx
new file mode 100644
index 0000000..5bef92a
--- /dev/null
+++ b/client/src/pages/SolutionsLogicielles.tsx
@@ -0,0 +1,221 @@
+import { trpc } from "@/lib/trpc";
+import { useAuth } from "@/_core/hooks/useAuth";
+import SonumLayout from "@/components/SonumLayout";
+import { ChevronDown, ChevronRight, Building2, Package, Search, Filter, BarChart2 } from "lucide-react";
+import { useState, useMemo } from "react";
+import { EtatBadge } from "@/components/EtatBadge";
+
+export default function SolutionsLogicielles() {
+ const { isAuthenticated } = useAuth();
+ const [openIds, setOpenIds] = useState>(new Set());
+ const [search, setSearch] = useState("");
+ const [filterBloc, setFilterBloc] = useState("");
+
+ const { data: solutions, isLoading } = trpc.logiciels.toutesLesSolutions.useQuery(undefined, {
+ enabled: isAuthenticated,
+ });
+
+ const toggle = (id: number) => {
+ setOpenIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ const filtered = useMemo(() => {
+ if (!solutions) return [];
+ return solutions.filter((s) => {
+ const matchSearch =
+ !search ||
+ s.solutionNom.toLowerCase().includes(search.toLowerCase()) ||
+ s.editeurNom.toLowerCase().includes(search.toLowerCase());
+ const matchBloc = !filterBloc || s.blocFonctionnelNom === filterBloc;
+ return matchSearch && matchBloc;
+ });
+ }, [solutions, search, filterBloc]);
+
+ const blocsUniques = useMemo(() => {
+ if (!solutions) return [];
+ const set = new Set(solutions.map((s) => s.blocFonctionnelNom).filter(Boolean) as string[]);
+ return Array.from(set).sort();
+ }, [solutions]);
+
+ const totalEtablissements = useMemo(() => {
+ if (!solutions) return 0;
+ const ids = new Set();
+ solutions.forEach((s) => s.etablissements.forEach((e) => ids.add(e.id)));
+ return ids.size;
+ }, [solutions]);
+
+ return (
+
+
+ {/* En-tête */}
+
+
+
+
+
+
+
Solutions Logicielles
+
Référentiel complet des solutions numériques FEHAP
+
+
+
+ {/* Statistiques */}
+ {solutions && (
+
+
+
{solutions.length}
+
Solutions référencées
+
+
+
{totalEtablissements}
+
Établissements équipés
+
+
+
{blocsUniques.length}
+
Blocs fonctionnels
+
+
+ )}
+
+
+ {/* Filtres */}
+
+
+
+ setSearch(e.target.value)}
+ className="w-full pl-9 pr-4 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
+ />
+
+
+
+
+
+
+
+ {/* Résultat filtré */}
+ {search || filterBloc ? (
+
+ {filtered.length} solution{filtered.length > 1 ? "s" : ""} correspondant aux critères
+
+ ) : null}
+
+ {/* Liste */}
+ {isLoading ? (
+
+ {[...Array(6)].map((_, i) => (
+
+ ))}
+
+ ) : filtered.length === 0 ? (
+
+
+
Aucune solution trouvée
+
+ ) : (
+
+ {filtered.map((sol) => {
+ const isOpen = openIds.has(sol.solutionId);
+ return (
+
+ {/* En-tête accordéon */}
+
+
+ {/* Contenu accordéon */}
+ {isOpen && (
+
+
+ {sol.etablissements.length === 0 ? (
+
+ Aucun établissement n'utilise encore cette solution.
+
+ ) : (
+ <>
+
+ Établissements équipés
+
+
+ {sol.etablissements.map((etab) => (
+
+
+
+ {etab.nom}
+ {etab.region && (
+ — {etab.region}
+ )}
+
+
+
+ ))}
+
+ >
+ )}
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/server/db.ts b/server/db.ts
index 49bef8f..925a2ad 100644
--- a/server/db.ts
+++ b/server/db.ts
@@ -1,4 +1,4 @@
-import { and, desc, eq, ilike, like, or, sql } from "drizzle-orm";
+import { and, desc, eq, ilike, inArray, like, or, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
InsertUser,
@@ -177,19 +177,40 @@ export async function searchEtablissements(filters: {
}) {
const db = await getDb();
if (!db) return [];
-
- const conditions = [];
+ const conditions: any[] = [];
// Visibilité : si pas gestionnaire, on ne montre que les fiches "tous"
if (filters.sonumRole !== "gestionnaire") {
conditions.push(eq(etablissements.visibilite, "tous"));
}
-
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));
- let query = db
+ // Filtres sur les logiciels (nécessitent une jointure)
+ const needsJoin = filters.solutionId || filters.editeurId || filters.blocFonctionnelId || filters.etatDeploiement;
+
+ if (needsJoin) {
+ // Filtres sur la table logiciels_etablissements
+ const leConditions: any[] = [eq(logicielsEtablissements.etablissementId, etablissements.id)];
+ if (filters.solutionId) leConditions.push(eq(logicielsEtablissements.solutionId, filters.solutionId));
+ if (filters.etatDeploiement) leConditions.push(eq(logicielsEtablissements.etatDeploiement, filters.etatDeploiement as any));
+
+ // Filtres sur les solutions (editeurId, blocFonctionnelId)
+ const solConditions: any[] = [eq(solutions.id, logicielsEtablissements.solutionId)];
+ if (filters.editeurId) solConditions.push(eq(solutions.editeurId, filters.editeurId));
+ if (filters.blocFonctionnelId) solConditions.push(eq(solutions.blocFonctionnelId, filters.blocFonctionnelId));
+
+ const subquery = db
+ .select({ etablissementId: logicielsEtablissements.etablissementId })
+ .from(logicielsEtablissements)
+ .innerJoin(solutions, and(...solConditions))
+ .where(and(...leConditions));
+
+ conditions.push(inArray(etablissements.id, subquery));
+ }
+
+ const result = await db
.select({
id: etablissements.id,
finess: etablissements.finess,
@@ -205,8 +226,7 @@ export async function searchEtablissements(filters: {
.from(etablissements)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(etablissements.nom);
-
- return query;
+ return result;
}
// ─── Logiciels par Établissement ─────────────────────────────────────────────
@@ -595,3 +615,140 @@ export async function getAllUsersWithAffectations() {
hasLocalCredentials: false, // sera enrichi côté router si besoin
}));
}
+
+// ─── Mes Solutions Numériques ─────────────────────────────────────────────────
+
+/**
+ * Retourne toutes les solutions utilisées par les établissements dont l'utilisateur est référent/adhérent,
+ * groupées par solution avec la liste des établissements équipés.
+ */
+export async function getMesSolutionsGroupees(userId: number, sonumRole: string) {
+ const db = await getDb();
+ if (!db) return [];
+
+ // Récupérer les établissements accessibles selon le rôle
+ let etablissementIds: number[] = [];
+ if (sonumRole === "gestionnaire") {
+ const all = await db.select({ id: etablissements.id }).from(etablissements);
+ etablissementIds = all.map((e) => e.id);
+ } else if (sonumRole === "adherent") {
+ etablissementIds = await getAffectationsByUser(userId);
+ } else {
+ // référent : établissements dont il est référent
+ const refs = await db
+ .select({ id: etablissements.id })
+ .from(etablissements)
+ .where(eq(etablissements.referentId, userId));
+ etablissementIds = refs.map((e) => e.id);
+ }
+
+ if (etablissementIds.length === 0) return [];
+
+ const rows = await db
+ .select({
+ solutionId: solutions.id,
+ solutionNom: solutions.nom,
+ editeurNom: editeurs.nom,
+ blocFonctionnelNom: blocsFonctionnels.nom,
+ etablissementId: etablissements.id,
+ etablissementNom: etablissements.nom,
+ etablissementRegion: etablissements.region,
+ etatDeploiement: logicielsEtablissements.etatDeploiement,
+ })
+ .from(logicielsEtablissements)
+ .innerJoin(solutions, eq(logicielsEtablissements.solutionId, solutions.id))
+ .innerJoin(editeurs, eq(solutions.editeurId, editeurs.id))
+ .leftJoin(blocsFonctionnels, eq(solutions.blocFonctionnelId, blocsFonctionnels.id))
+ .innerJoin(etablissements, eq(logicielsEtablissements.etablissementId, etablissements.id))
+ .where(inArray(logicielsEtablissements.etablissementId, etablissementIds))
+ .orderBy(solutions.nom, etablissements.nom);
+
+ // Grouper par solution
+ const map = new Map();
+
+ for (const row of rows) {
+ if (!map.has(row.solutionId)) {
+ map.set(row.solutionId, {
+ solutionId: row.solutionId,
+ solutionNom: row.solutionNom ?? "",
+ editeurNom: row.editeurNom ?? "",
+ blocFonctionnelNom: row.blocFonctionnelNom ?? null,
+ etablissements: [],
+ });
+ }
+ map.get(row.solutionId)!.etablissements.push({
+ id: row.etablissementId,
+ nom: row.etablissementNom ?? "",
+ region: row.etablissementRegion ?? null,
+ etatDeploiement: row.etatDeploiement ?? "",
+ });
+ }
+
+ return Array.from(map.values());
+}
+
+/**
+ * Retourne toutes les solutions du référentiel avec les établissements équipés (vue globale).
+ * Accessible à tous les utilisateurs connectés.
+ */
+export async function getToutesLesSolutionsGroupees() {
+ const db = await getDb();
+ if (!db) return [];
+
+ const rows = await db
+ .select({
+ solutionId: solutions.id,
+ solutionNom: solutions.nom,
+ editeurNom: editeurs.nom,
+ blocFonctionnelNom: blocsFonctionnels.nom,
+ etablissementId: etablissements.id,
+ etablissementNom: etablissements.nom,
+ etablissementRegion: etablissements.region,
+ etatDeploiement: logicielsEtablissements.etatDeploiement,
+ })
+ .from(solutions)
+ .leftJoin(editeurs, eq(solutions.editeurId, editeurs.id))
+ .leftJoin(blocsFonctionnels, eq(solutions.blocFonctionnelId, blocsFonctionnels.id))
+ .leftJoin(logicielsEtablissements, eq(logicielsEtablissements.solutionId, solutions.id))
+ .leftJoin(etablissements, eq(logicielsEtablissements.etablissementId, etablissements.id))
+ .orderBy(solutions.nom, etablissements.nom);
+
+ const map = new Map();
+
+ for (const row of rows) {
+ if (!map.has(row.solutionId)) {
+ map.set(row.solutionId, {
+ solutionId: row.solutionId,
+ solutionNom: row.solutionNom ?? "",
+ editeurNom: row.editeurNom ?? "",
+ blocFonctionnelNom: row.blocFonctionnelNom ?? null,
+ nbEtablissements: 0,
+ etablissements: [],
+ });
+ }
+ if (row.etablissementId) {
+ map.get(row.solutionId)!.etablissements.push({
+ id: row.etablissementId,
+ nom: row.etablissementNom ?? "",
+ region: row.etablissementRegion ?? null,
+ etatDeploiement: row.etatDeploiement ?? "",
+ });
+ map.get(row.solutionId)!.nbEtablissements++;
+ }
+ }
+
+ return Array.from(map.values());
+}
diff --git a/server/routers.ts b/server/routers.ts
index 811d441..28cc7b1 100644
--- a/server/routers.ts
+++ b/server/routers.ts
@@ -25,6 +25,8 @@ import {
getEtablissementsByAdherent,
getEtablissementsByReferent,
getLogicielsByEtablissement,
+ getMesSolutionsGroupees,
+ getToutesLesSolutionsGroupees,
getSolutions,
recordConsultation,
removeEtablissementFromUser,
@@ -300,6 +302,12 @@ export const appRouter = router({
await deleteLogicielEtablissement(input.id);
return { success: true };
}),
+ mesSolutions: protectedProcedure
+ .query(({ ctx }) =>
+ getMesSolutionsGroupees(ctx.user.id, ctx.user.sonumRole ?? "referent")
+ ),
+ toutesLesSolutions: protectedProcedure
+ .query(() => getToutesLesSolutionsGroupees()),
}),
// ─── Traçabilité ───────────────────────────────────────────────────────────
diff --git a/todo.md b/todo.md
index 3d782b9..1555362 100644
--- a/todo.md
+++ b/todo.md
@@ -61,3 +61,12 @@
- [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
+
+## Évolution v4
+
+- [ ] CGU : afficher à chaque connexion (réinitialiser à chaque login, pas seulement à la fermeture du navigateur)
+- [ ] Moteur de recherche : corriger les filtres multicritères qui ne fonctionnent pas
+- [ ] Page "Mes Solutions Numériques" : liste de toutes mes solutions avec les établissements les utilisant (accordéon)
+- [ ] Page "Solutions Logicielles" : liste globale des solutions avec établissements équipés (accordéon), accessible depuis la sidebar
+- [ ] Admin : onglet import établissements (CSV/Excel)
+- [ ] Admin : onglet import contacts (CSV/Excel)