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) => ( + + ))} + + + + {rows.map((row, i) => ( + + + + + + + + + + ))} + +
{h}
{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) => ( + + ))} + + + + {rows.map((row, i) => ( + + + + + + + + ))} + +
{h}
{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)