Checkpoint: v4 : Ajout pages Mes Solutions Numériques et Solutions Logicielles, onglets Import Établissements et Import Contacts avec CSV dans Admin, correction moteur de recherche (filtres solutionId/editeurId/blocFonctionnelId), CGU réaffichée à chaque session. 33 tests passés, 0 erreur TypeScript.
This commit is contained in:
@@ -11,6 +11,8 @@ import FicheEtablissement from "./pages/FicheEtablissement";
|
|||||||
import Admin from "./pages/Admin";
|
import Admin from "./pages/Admin";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import LoginLocal from "./pages/LoginLocal";
|
import LoginLocal from "./pages/LoginLocal";
|
||||||
|
import MesSolutions from "./pages/MesSolutions";
|
||||||
|
import SolutionsLogicielles from "./pages/SolutionsLogicielles";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
return (
|
return (
|
||||||
@@ -25,6 +27,8 @@ function Router() {
|
|||||||
<Route path="/mes-demandes" component={MesDemandes} />
|
<Route path="/mes-demandes" component={MesDemandes} />
|
||||||
<Route path="/etablissement/:id" component={FicheEtablissement} />
|
<Route path="/etablissement/:id" component={FicheEtablissement} />
|
||||||
<Route path="/admin" component={Admin} />
|
<Route path="/admin" component={Admin} />
|
||||||
|
<Route path="/mes-solutions" component={MesSolutions} />
|
||||||
|
<Route path="/solutions" component={SolutionsLogicielles} />
|
||||||
|
|
||||||
{/* Fallback */}
|
{/* Fallback */}
|
||||||
<Route path="/404" component={NotFound} />
|
<Route path="/404" component={NotFound} />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useAuth } from "@/_core/hooks/useAuth";
|
|||||||
import { getLoginUrl } from "@/const";
|
import { getLoginUrl } from "@/const";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import {
|
import {
|
||||||
|
BarChart2,
|
||||||
Bell,
|
Bell,
|
||||||
Building2,
|
Building2,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Mail,
|
Mail,
|
||||||
Menu,
|
Menu,
|
||||||
|
Package,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -29,6 +31,8 @@ interface NavItem {
|
|||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ label: "Moteur de recherche", href: "/", icon: <Search size={18} /> },
|
{ label: "Moteur de recherche", href: "/", icon: <Search size={18} /> },
|
||||||
{ label: "Mes Établissements", href: "/mes-etablissements", icon: <Building2 size={18} /> },
|
{ label: "Mes Établissements", href: "/mes-etablissements", icon: <Building2 size={18} /> },
|
||||||
|
{ label: "Mes Solutions Numériques", href: "/mes-solutions", icon: <Package size={18} /> },
|
||||||
|
{ label: "Solutions Logicielles", href: "/solutions", icon: <BarChart2 size={18} /> },
|
||||||
{ label: "Mes Demandes de Contact", href: "/mes-demandes", icon: <Mail size={18} /> },
|
{ label: "Mes Demandes de Contact", href: "/mes-demandes", icon: <Mail size={18} /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -203,7 +207,7 @@ export default function SonumLayout({ children }: { children: React.ReactNode })
|
|||||||
|
|
||||||
<div className="hidden lg:flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="hidden lg:flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{navItems.find((n) => n.href === location)?.label ?? "SONUM"}
|
{[...navItems, ...adminNavItems].find((n) => n.href === location)?.label ?? "SONUM"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,22 +2,27 @@ import { useAuth } from "@/_core/hooks/useAuth";
|
|||||||
import SonumLayout from "@/components/SonumLayout";
|
import SonumLayout from "@/components/SonumLayout";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import {
|
import {
|
||||||
|
AlertCircle,
|
||||||
Building2,
|
Building2,
|
||||||
Check,
|
Check,
|
||||||
|
CheckCircle,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
FileText,
|
||||||
Key,
|
Key,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Shield,
|
Shield,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Upload,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
Users,
|
Users,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState, useMemo, Fragment } from "react";
|
import { useState, useMemo, Fragment, useRef, useCallback } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -40,7 +45,7 @@ const ROLE_COLORS: Record<SonumRole, string> = {
|
|||||||
|
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
const { user } = useAuth();
|
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";
|
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
|
||||||
|
|
||||||
if (!isGestionnaire) {
|
if (!isGestionnaire) {
|
||||||
@@ -66,25 +71,32 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Onglets */}
|
{/* Onglets */}
|
||||||
<div className="flex gap-1 p-1 bg-muted rounded-xl mb-6 w-fit">
|
<div className="flex flex-wrap gap-1 p-1 bg-muted rounded-xl mb-6">
|
||||||
{(["users", "etablissements"] as const).map((tab) => (
|
{([
|
||||||
|
{ id: "users" as const, label: "Utilisateurs", icon: <Users size={15} /> },
|
||||||
|
{ id: "etablissements" as const, label: "Établissements", icon: <Building2 size={15} /> },
|
||||||
|
{ id: "import-etab" as const, label: "Import Établissements", icon: <Upload size={15} /> },
|
||||||
|
{ id: "import-contacts" as const, label: "Import Contacts", icon: <Upload size={15} /> },
|
||||||
|
]).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
activeTab === tab
|
activeTab === tab.id
|
||||||
? "bg-card text-foreground shadow-sm"
|
? "bg-card text-foreground shadow-sm"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab === "users" ? <Users size={15} /> : <Building2 size={15} />}
|
{tab.icon}
|
||||||
{tab === "users" ? "Utilisateurs" : "Établissements"}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === "users" && <UsersPanel />}
|
{activeTab === "users" && <UsersPanel />}
|
||||||
{activeTab === "etablissements" && <EtablissementsPanel />}
|
{activeTab === "etablissements" && <EtablissementsPanel />}
|
||||||
|
{activeTab === "import-etab" && <ImportEtablissementsPanel />}
|
||||||
|
{activeTab === "import-contacts" && <ImportContactsPanel />}
|
||||||
</div>
|
</div>
|
||||||
</SonumLayout>
|
</SonumLayout>
|
||||||
);
|
);
|
||||||
@@ -796,3 +808,431 @@ function EtablissementsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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<EtabRow[]>([]);
|
||||||
|
const [errors, setErrors] = useState<string[]>([]);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [done, setDone] = useState(0);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-card border border-border rounded-xl p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<Upload size={16} className="text-primary" />
|
||||||
|
Import d'établissements par CSV
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Importez une liste d'établissements depuis un fichier CSV ou Excel (enregistré en CSV).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={downloadTemplate}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium border border-border rounded-lg text-foreground hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Download size={13} />
|
||||||
|
Modèle CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zone de dépôt */}
|
||||||
|
<div
|
||||||
|
className="border-2 border-dashed border-border rounded-xl p-8 text-center cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-colors"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
>
|
||||||
|
<FileText size={32} className="mx-auto text-muted-foreground/40 mb-2" />
|
||||||
|
<p className="text-sm font-medium text-foreground">Cliquez pour sélectionner un fichier CSV</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Séparateur : point-virgule, virgule ou tabulation</p>
|
||||||
|
<input ref={fileRef} type="file" accept=".csv,.txt" className="hidden" onChange={handleFile} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Colonnes attendues */}
|
||||||
|
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground mb-1">Colonnes reconnues :</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{["nom *", "finess", "region", "typeActivite", "taille", "commune", "statut"].map((col) => (
|
||||||
|
<span key={col} className={`text-xs px-2 py-0.5 rounded-full border ${col.includes("*") ? "bg-primary/10 text-primary border-primary/20 font-semibold" : "bg-background text-muted-foreground border-border"}`}>
|
||||||
|
{col}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Erreurs de parsing */}
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<div className="bg-destructive/5 border border-destructive/20 rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<AlertCircle size={15} className="text-destructive" />
|
||||||
|
<span className="text-sm font-semibold text-destructive">{errors.length} erreur(s) détectée(s)</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{errors.map((e, i) => (
|
||||||
|
<li key={i} className="text-xs text-destructive/80">{e}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Prévisualisation */}
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-border bg-muted/30">
|
||||||
|
<span className="text-sm font-semibold text-foreground">
|
||||||
|
{rows.length} établissement(s) à importer
|
||||||
|
{importing && ` — ${done}/${rows.length} traités`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={importing || rows.length === 0}
|
||||||
|
className="flex items-center gap-2 px-4 py-1.5 text-xs font-medium bg-primary text-white rounded-lg hover:bg-primary/90 disabled:opacity-50 shadow-sm"
|
||||||
|
>
|
||||||
|
{importing ? (
|
||||||
|
<span className="animate-spin inline-block w-3 h-3 border-2 border-white/30 border-t-white rounded-full" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle size={13} />
|
||||||
|
)}
|
||||||
|
{importing ? "Import en cours…" : "Lancer l'import"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto max-h-80">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-muted/50 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
{["Nom", "FINESS", "Région", "Type d'activité", "Taille", "Commune", "Statut"].map((h) => (
|
||||||
|
<th key={h} className="text-left px-3 py-2 font-semibold text-muted-foreground">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<tr key={i} className="border-t border-border hover:bg-muted/20">
|
||||||
|
<td className="px-3 py-2 font-medium text-foreground">{row.nom}</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">{row.finess || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">{row.region || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">{row.typeActivite || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">{row.taille || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">{row.commune || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">{row.statut || "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<ContactRow[]>([]);
|
||||||
|
const [errors, setErrors] = useState<string[]>([]);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [done, setDone] = useState(0);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-card border border-border rounded-xl p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<Upload size={16} className="text-primary" />
|
||||||
|
Import de contacts (utilisateurs) par CSV
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Importez une liste de référents ou adhérents. Un mot de passe par défaut "Sonum2024!" sera attribué si non renseigné.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={downloadTemplate}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium border border-border rounded-lg text-foreground hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Download size={13} />
|
||||||
|
Modèle CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zone de dépôt */}
|
||||||
|
<div
|
||||||
|
className="border-2 border-dashed border-border rounded-xl p-8 text-center cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-colors"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
>
|
||||||
|
<FileText size={32} className="mx-auto text-muted-foreground/40 mb-2" />
|
||||||
|
<p className="text-sm font-medium text-foreground">Cliquez pour sélectionner un fichier CSV</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Séparateur : point-virgule, virgule ou tabulation</p>
|
||||||
|
<input ref={fileRef} type="file" accept=".csv,.txt" className="hidden" onChange={handleFile} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Colonnes attendues */}
|
||||||
|
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground mb-1">Colonnes reconnues :</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{["nom *", "email *", "role", "etablissements", "password"].map((col) => (
|
||||||
|
<span key={col} className={`text-xs px-2 py-0.5 rounded-full border ${col.includes("*") ? "bg-primary/10 text-primary border-primary/20 font-semibold" : "bg-background text-muted-foreground border-border"}`}>
|
||||||
|
{col}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
<strong>role</strong> : "referent" ou "adherent" (défaut : referent) — <strong>password</strong> : si vide, "Sonum2024!" sera utilisé
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Erreurs de parsing */}
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<div className="bg-destructive/5 border border-destructive/20 rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<AlertCircle size={15} className="text-destructive" />
|
||||||
|
<span className="text-sm font-semibold text-destructive">{errors.length} erreur(s) détectée(s)</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{errors.map((e, i) => (
|
||||||
|
<li key={i} className="text-xs text-destructive/80">{e}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Prévisualisation */}
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-border bg-muted/30">
|
||||||
|
<span className="text-sm font-semibold text-foreground">
|
||||||
|
{rows.length} contact(s) à importer
|
||||||
|
{importing && ` — ${done}/${rows.length} traités`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={importing || rows.length === 0}
|
||||||
|
className="flex items-center gap-2 px-4 py-1.5 text-xs font-medium bg-primary text-white rounded-lg hover:bg-primary/90 disabled:opacity-50 shadow-sm"
|
||||||
|
>
|
||||||
|
{importing ? (
|
||||||
|
<span className="animate-spin inline-block w-3 h-3 border-2 border-white/30 border-t-white rounded-full" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle size={13} />
|
||||||
|
)}
|
||||||
|
{importing ? "Import en cours…" : "Lancer l'import"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto max-h-80">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-muted/50 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
{["Nom", "Email", "Profil", "Établissements", "Mot de passe"].map((h) => (
|
||||||
|
<th key={h} className="text-left px-3 py-2 font-semibold text-muted-foreground">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<tr key={i} className="border-t border-border hover:bg-muted/20">
|
||||||
|
<td className="px-3 py-2 font-medium text-foreground">{row.nom}</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">{row.email}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full border ${row.sonumRole === "adherent" ? "bg-emerald-50 text-emerald-700 border-emerald-200" : "bg-primary/10 text-primary border-primary/20"}`}>
|
||||||
|
{row.sonumRole === "adherent" ? "Adhérent FEHAP" : "Référent numérique"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">{row.etablissements || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground font-mono">{row.password}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,10 +46,13 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cguQuery = trpc.cgu.status.useQuery(undefined, { enabled: isAuthenticated });
|
const cguQuery = trpc.cgu.status.useQuery(undefined, { enabled: isAuthenticated });
|
||||||
// La CGU doit être acceptée à chaque nouvelle session (sessionStorage vidé à la fermeture du navigateur)
|
// La CGU doit être acceptée à chaque connexion : on stocke la clé "userId_cgu" dans sessionStorage
|
||||||
const [sessionCguAccepted, setSessionCguAccepted] = useState(() =>
|
// sessionStorage est vidé à la fermeture du navigateur ET on utilise l'userId pour distinguer les sessions
|
||||||
typeof window !== "undefined" && sessionStorage.getItem("sonum_cgu_accepted") === "1"
|
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 blocsQuery = trpc.referentiel.blocsFonctionnels.useQuery();
|
||||||
const editeursQuery = trpc.referentiel.editeurs.useQuery();
|
const editeursQuery = trpc.referentiel.editeurs.useQuery();
|
||||||
const solutionsQuery = trpc.referentiel.solutions.useQuery({ search: searchText.length >= 2 ? searchText : undefined });
|
const solutionsQuery = trpc.referentiel.solutions.useQuery({ search: searchText.length >= 2 ? searchText : undefined });
|
||||||
@@ -103,7 +106,11 @@ export default function Home() {
|
|||||||
if (!cguFullyAccepted) {
|
if (!cguFullyAccepted) {
|
||||||
return (
|
return (
|
||||||
<SonumLayout>
|
<SonumLayout>
|
||||||
<CguModal onAccepted={() => { setSessionCguAccepted(true); cguQuery.refetch(); }} />
|
<CguModal onAccepted={() => {
|
||||||
|
if (sessionKey) sessionStorage.setItem(sessionKey, "1");
|
||||||
|
setSessionCguAccepted(true);
|
||||||
|
cguQuery.refetch();
|
||||||
|
}} />
|
||||||
</SonumLayout>
|
</SonumLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
179
client/src/pages/MesSolutions.tsx
Normal file
179
client/src/pages/MesSolutions.tsx
Normal file
@@ -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<Set<number>>(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 (
|
||||||
|
<SonumLayout>
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
|
{/* En-tête */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||||
|
<Package size={20} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Mes Solutions Numériques</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{solutions ? `${solutions.length} solution${solutions.length > 1 ? "s" : ""} référencée${solutions.length > 1 ? "s" : ""}` : "Chargement…"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtres */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Rechercher par solution ou éditeur…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Filter size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<select
|
||||||
|
value={filterBloc}
|
||||||
|
onChange={(e) => setFilterBloc(e.target.value)}
|
||||||
|
className="pl-9 pr-8 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 appearance-none min-w-[200px]"
|
||||||
|
>
|
||||||
|
<option value="">Tous les blocs fonctionnels</option>
|
||||||
|
{blocsUniques.map((b) => (
|
||||||
|
<option key={b} value={b}>{b}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className="h-16 bg-muted/30 rounded-xl animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-muted-foreground">
|
||||||
|
<Package size={40} className="mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="font-medium">Aucune solution trouvée</p>
|
||||||
|
<p className="text-sm mt-1">Rattachez des solutions à vos établissements pour les voir apparaître ici.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filtered.map((sol) => {
|
||||||
|
const isOpen = openIds.has(sol.solutionId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={sol.solutionId}
|
||||||
|
className="border border-border rounded-xl overflow-hidden bg-card shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
{/* En-tête accordéon */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(sol.solutionId)}
|
||||||
|
className="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 min-w-0">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Package size={16} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold text-foreground truncate">{sol.solutionNom}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{sol.editeurNom}</div>
|
||||||
|
</div>
|
||||||
|
{sol.blocFonctionnelNom && (
|
||||||
|
<span className="hidden sm:inline-flex text-xs bg-secondary text-secondary-foreground px-2.5 py-1 rounded-full border border-border flex-shrink-0">
|
||||||
|
{sol.blocFonctionnelNom}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 flex-shrink-0 ml-3">
|
||||||
|
<span className="text-xs text-muted-foreground bg-muted px-2.5 py-1 rounded-full">
|
||||||
|
{sol.etablissements.length} établissement{sol.etablissements.length > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown size={16} className="text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={16} className="text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Contenu accordéon */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="border-t border-border bg-muted/10">
|
||||||
|
<div className="px-5 py-3">
|
||||||
|
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">
|
||||||
|
Établissements équipés
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sol.etablissements.map((etab) => (
|
||||||
|
<div
|
||||||
|
key={etab.id}
|
||||||
|
className="flex items-center justify-between py-2 px-3 bg-background rounded-lg border border-border"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Building2 size={14} className="text-muted-foreground flex-shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-foreground truncate">{etab.nom}</span>
|
||||||
|
{etab.region && (
|
||||||
|
<span className="text-xs text-muted-foreground hidden sm:block">— {etab.region}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<EtatBadge etat={etab.etatDeploiement} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SonumLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
client/src/pages/SolutionsLogicielles.tsx
Normal file
221
client/src/pages/SolutionsLogicielles.tsx
Normal file
@@ -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<Set<number>>(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<number>();
|
||||||
|
solutions.forEach((s) => s.etablissements.forEach((e) => ids.add(e.id)));
|
||||||
|
return ids.size;
|
||||||
|
}, [solutions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SonumLayout>
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
|
{/* En-tête */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center">
|
||||||
|
<BarChart2 size={20} className="text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Solutions Logicielles</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Référentiel complet des solutions numériques FEHAP</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistiques */}
|
||||||
|
{solutions && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-2">
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-primary">{solutions.length}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">Solutions référencées</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{totalEtablissements}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">Établissements équipés</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4 text-center col-span-2 sm:col-span-1">
|
||||||
|
<div className="text-2xl font-bold text-green-600">{blocsUniques.length}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">Blocs fonctionnels</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtres */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Rechercher par solution ou éditeur…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Filter size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<select
|
||||||
|
value={filterBloc}
|
||||||
|
onChange={(e) => setFilterBloc(e.target.value)}
|
||||||
|
className="pl-9 pr-8 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 appearance-none min-w-[200px]"
|
||||||
|
>
|
||||||
|
<option value="">Tous les blocs fonctionnels</option>
|
||||||
|
{blocsUniques.map((b) => (
|
||||||
|
<option key={b} value={b}>{b}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Résultat filtré */}
|
||||||
|
{search || filterBloc ? (
|
||||||
|
<p className="text-xs text-muted-foreground mb-4">
|
||||||
|
{filtered.length} solution{filtered.length > 1 ? "s" : ""} correspondant aux critères
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Liste */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<div key={i} className="h-16 bg-muted/30 rounded-xl animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-muted-foreground">
|
||||||
|
<Package size={40} className="mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="font-medium">Aucune solution trouvée</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filtered.map((sol) => {
|
||||||
|
const isOpen = openIds.has(sol.solutionId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={sol.solutionId}
|
||||||
|
className="border border-border rounded-xl overflow-hidden bg-card shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
{/* En-tête accordéon */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(sol.solutionId)}
|
||||||
|
className="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 min-w-0">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Package size={16} className="text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold text-foreground truncate">{sol.solutionNom}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{sol.editeurNom}</div>
|
||||||
|
</div>
|
||||||
|
{sol.blocFonctionnelNom && (
|
||||||
|
<span className="hidden sm:inline-flex text-xs bg-secondary text-secondary-foreground px-2.5 py-1 rounded-full border border-border flex-shrink-0">
|
||||||
|
{sol.blocFonctionnelNom}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 flex-shrink-0 ml-3">
|
||||||
|
{sol.nbEtablissements > 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground bg-muted px-2.5 py-1 rounded-full">
|
||||||
|
{sol.nbEtablissements} établissement{sol.nbEtablissements > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground/50 bg-muted/50 px-2.5 py-1 rounded-full italic">
|
||||||
|
Non déployée
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown size={16} className="text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={16} className="text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Contenu accordéon */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="border-t border-border bg-muted/10">
|
||||||
|
<div className="px-5 py-3">
|
||||||
|
{sol.etablissements.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground italic py-2">
|
||||||
|
Aucun établissement n'utilise encore cette solution.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">
|
||||||
|
Établissements équipés
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sol.etablissements.map((etab) => (
|
||||||
|
<div
|
||||||
|
key={etab.id}
|
||||||
|
className="flex items-center justify-between py-2 px-3 bg-background rounded-lg border border-border"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Building2 size={14} className="text-muted-foreground flex-shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-foreground truncate">{etab.nom}</span>
|
||||||
|
{etab.region && (
|
||||||
|
<span className="text-xs text-muted-foreground hidden sm:block">— {etab.region}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<EtatBadge etat={etab.etatDeploiement} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SonumLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
server/db.ts
171
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 { drizzle } from "drizzle-orm/mysql2";
|
||||||
import {
|
import {
|
||||||
InsertUser,
|
InsertUser,
|
||||||
@@ -177,19 +177,40 @@ export async function searchEtablissements(filters: {
|
|||||||
}) {
|
}) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return [];
|
if (!db) return [];
|
||||||
|
const conditions: any[] = [];
|
||||||
const conditions = [];
|
|
||||||
|
|
||||||
// Visibilité : si pas gestionnaire, on ne montre que les fiches "tous"
|
// Visibilité : si pas gestionnaire, on ne montre que les fiches "tous"
|
||||||
if (filters.sonumRole !== "gestionnaire") {
|
if (filters.sonumRole !== "gestionnaire") {
|
||||||
conditions.push(eq(etablissements.visibilite, "tous"));
|
conditions.push(eq(etablissements.visibilite, "tous"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.region) conditions.push(eq(etablissements.region, filters.region));
|
if (filters.region) conditions.push(eq(etablissements.region, filters.region));
|
||||||
if (filters.typeActivite) conditions.push(eq(etablissements.typeActivite, filters.typeActivite));
|
if (filters.typeActivite) conditions.push(eq(etablissements.typeActivite, filters.typeActivite));
|
||||||
if (filters.tailleEffectifs) conditions.push(eq(etablissements.tailleEffectifs, filters.tailleEffectifs));
|
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({
|
.select({
|
||||||
id: etablissements.id,
|
id: etablissements.id,
|
||||||
finess: etablissements.finess,
|
finess: etablissements.finess,
|
||||||
@@ -205,8 +226,7 @@ export async function searchEtablissements(filters: {
|
|||||||
.from(etablissements)
|
.from(etablissements)
|
||||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
.orderBy(etablissements.nom);
|
.orderBy(etablissements.nom);
|
||||||
|
return result;
|
||||||
return query;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Logiciels par Établissement ─────────────────────────────────────────────
|
// ─── Logiciels par Établissement ─────────────────────────────────────────────
|
||||||
@@ -595,3 +615,140 @@ export async function getAllUsersWithAffectations() {
|
|||||||
hasLocalCredentials: false, // sera enrichi côté router si besoin
|
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<number, {
|
||||||
|
solutionId: number;
|
||||||
|
solutionNom: string;
|
||||||
|
editeurNom: string;
|
||||||
|
blocFonctionnelNom: string | null;
|
||||||
|
etablissements: { id: number; nom: string; region: string | null; etatDeploiement: string }[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
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<number, {
|
||||||
|
solutionId: number;
|
||||||
|
solutionNom: string;
|
||||||
|
editeurNom: string;
|
||||||
|
blocFonctionnelNom: string | null;
|
||||||
|
nbEtablissements: number;
|
||||||
|
etablissements: { id: number; nom: string; region: string | null; etatDeploiement: string }[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
getEtablissementsByAdherent,
|
getEtablissementsByAdherent,
|
||||||
getEtablissementsByReferent,
|
getEtablissementsByReferent,
|
||||||
getLogicielsByEtablissement,
|
getLogicielsByEtablissement,
|
||||||
|
getMesSolutionsGroupees,
|
||||||
|
getToutesLesSolutionsGroupees,
|
||||||
getSolutions,
|
getSolutions,
|
||||||
recordConsultation,
|
recordConsultation,
|
||||||
removeEtablissementFromUser,
|
removeEtablissementFromUser,
|
||||||
@@ -300,6 +302,12 @@ export const appRouter = router({
|
|||||||
await deleteLogicielEtablissement(input.id);
|
await deleteLogicielEtablissement(input.id);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
mesSolutions: protectedProcedure
|
||||||
|
.query(({ ctx }) =>
|
||||||
|
getMesSolutionsGroupees(ctx.user.id, ctx.user.sonumRole ?? "referent")
|
||||||
|
),
|
||||||
|
toutesLesSolutions: protectedProcedure
|
||||||
|
.query(() => getToutesLesSolutionsGroupees()),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ─── Traçabilité ───────────────────────────────────────────────────────────
|
// ─── Traçabilité ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
9
todo.md
9
todo.md
@@ -61,3 +61,12 @@
|
|||||||
- [x] Étendre admin.setAffectations pour accepter tous les rôles (référent + adhérent)
|
- [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] 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
|
- [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)
|
||||||
|
|||||||
Reference in New Issue
Block a user