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:
@@ -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<SonumRole, string> = {
|
||||
|
||||
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() {
|
||||
</div>
|
||||
|
||||
{/* Onglets */}
|
||||
<div className="flex gap-1 p-1 bg-muted rounded-xl mb-6 w-fit">
|
||||
{(["users", "etablissements"] as const).map((tab) => (
|
||||
<div className="flex flex-wrap gap-1 p-1 bg-muted rounded-xl mb-6">
|
||||
{([
|
||||
{ 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
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
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"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab === "users" ? <Users size={15} /> : <Building2 size={15} />}
|
||||
{tab === "users" ? "Utilisateurs" : "Établissements"}
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === "users" && <UsersPanel />}
|
||||
{activeTab === "etablissements" && <EtablissementsPanel />}
|
||||
{activeTab === "import-etab" && <ImportEtablissementsPanel />}
|
||||
{activeTab === "import-contacts" && <ImportContactsPanel />}
|
||||
</div>
|
||||
</SonumLayout>
|
||||
);
|
||||
@@ -796,3 +808,431 @@ function EtablissementsPanel() {
|
||||
</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 });
|
||||
// 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 (
|
||||
<SonumLayout>
|
||||
<CguModal onAccepted={() => { setSessionCguAccepted(true); cguQuery.refetch(); }} />
|
||||
<CguModal onAccepted={() => {
|
||||
if (sessionKey) sessionStorage.setItem(sessionKey, "1");
|
||||
setSessionCguAccepted(true);
|
||||
cguQuery.refetch();
|
||||
}} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user