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:
Manus
2026-04-16 12:20:42 -04:00
parent b86977eae4
commit a86806fbb7
9 changed files with 1051 additions and 22 deletions

View File

@@ -11,6 +11,8 @@ import FicheEtablissement from "./pages/FicheEtablissement";
import Admin from "./pages/Admin";
import Login from "./pages/Login";
import LoginLocal from "./pages/LoginLocal";
import MesSolutions from "./pages/MesSolutions";
import SolutionsLogicielles from "./pages/SolutionsLogicielles";
function Router() {
return (
@@ -25,6 +27,8 @@ function Router() {
<Route path="/mes-demandes" component={MesDemandes} />
<Route path="/etablissement/:id" component={FicheEtablissement} />
<Route path="/admin" component={Admin} />
<Route path="/mes-solutions" component={MesSolutions} />
<Route path="/solutions" component={SolutionsLogicielles} />
{/* Fallback */}
<Route path="/404" component={NotFound} />

View File

@@ -2,6 +2,7 @@ import { useAuth } from "@/_core/hooks/useAuth";
import { getLoginUrl } from "@/const";
import { trpc } from "@/lib/trpc";
import {
BarChart2,
Bell,
Building2,
ChevronRight,
@@ -10,6 +11,7 @@ import {
LogOut,
Mail,
Menu,
Package,
Search,
Settings,
Shield,
@@ -29,6 +31,8 @@ interface NavItem {
const navItems: NavItem[] = [
{ label: "Moteur de recherche", href: "/", icon: <Search 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} /> },
];
@@ -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">
<span className="font-medium text-foreground">
{navItems.find((n) => n.href === location)?.label ?? "SONUM"}
{[...navItems, ...adminNavItems].find((n) => n.href === location)?.label ?? "SONUM"}
</span>
</div>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}