Checkpoint: Évolution v6 complète : onglets Éditeurs et Blocs fonctionnels dans Admin (CRUD complet), page Tableau de bord statistiques avec graphiques Recharts (KPIs, barres par bloc/région, camembert état déploiement, top 10 solutions), lien dans la sidebar réservé aux gestionnaires. 33 tests Vitest passés, 0 erreur TypeScript.

This commit is contained in:
Manus
2026-04-17 11:11:15 -04:00
parent cbd439bac7
commit 5801ba4add
7 changed files with 840 additions and 1 deletions

View File

@@ -13,6 +13,7 @@ import Login from "./pages/Login";
import LoginLocal from "./pages/LoginLocal"; import LoginLocal from "./pages/LoginLocal";
import MesSolutions from "./pages/MesSolutions"; import MesSolutions from "./pages/MesSolutions";
import SolutionsLogicielles from "./pages/SolutionsLogicielles"; import SolutionsLogicielles from "./pages/SolutionsLogicielles";
import Statistiques from "./pages/Statistiques";
function Router() { function Router() {
return ( return (
@@ -29,6 +30,7 @@ function Router() {
<Route path="/admin" component={Admin} /> <Route path="/admin" component={Admin} />
<Route path="/mes-solutions" component={MesSolutions} /> <Route path="/mes-solutions" component={MesSolutions} />
<Route path="/solutions" component={SolutionsLogicielles} /> <Route path="/solutions" component={SolutionsLogicielles} />
<Route path="/statistiques" component={Statistiques} />
{/* Fallback */} {/* Fallback */}
<Route path="/404" component={NotFound} /> <Route path="/404" component={NotFound} />

View File

@@ -37,6 +37,7 @@ const navItems: NavItem[] = [
]; ];
const adminNavItems: NavItem[] = [ const adminNavItems: NavItem[] = [
{ label: "Tableau de bord statistiques", href: "/statistiques", icon: <LayoutDashboard size={18} />, adminOnly: true },
{ label: "Administration", href: "/admin", icon: <Shield size={18} />, adminOnly: true }, { label: "Administration", href: "/admin", icon: <Shield size={18} />, adminOnly: true },
]; ];

View File

@@ -46,7 +46,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" | "import-etab" | "import-contacts">("users"); const [activeTab, setActiveTab] = useState<"users" | "etablissements" | "import-etab" | "import-contacts" | "editeurs" | "blocs">("users");
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin"; const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
if (!isGestionnaire) { if (!isGestionnaire) {
@@ -78,6 +78,8 @@ export default function Admin() {
{ id: "etablissements" as const, label: "Établissements", icon: <Building2 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-etab" as const, label: "Import Établissements", icon: <Upload size={15} /> },
{ id: "import-contacts" as const, label: "Import Contacts", icon: <Upload size={15} /> }, { id: "import-contacts" as const, label: "Import Contacts", icon: <Upload size={15} /> },
{ id: "editeurs" as const, label: "Éditeurs", icon: <Pencil size={15} /> },
{ id: "blocs" as const, label: "Blocs fonctionnels", icon: <FileText size={15} /> },
]).map((tab) => ( ]).map((tab) => (
<button <button
key={tab.id} key={tab.id}
@@ -98,6 +100,8 @@ export default function Admin() {
{activeTab === "etablissements" && <EtablissementsPanel />} {activeTab === "etablissements" && <EtablissementsPanel />}
{activeTab === "import-etab" && <ImportEtablissementsPanel />} {activeTab === "import-etab" && <ImportEtablissementsPanel />}
{activeTab === "import-contacts" && <ImportContactsPanel />} {activeTab === "import-contacts" && <ImportContactsPanel />}
{activeTab === "editeurs" && <EditeursPanel />}
{activeTab === "blocs" && <BlocsFonctionnelsPanel />}
</div> </div>
</SonumLayout> </SonumLayout>
); );
@@ -1259,3 +1263,333 @@ function ImportContactsPanel() {
</div> </div>
); );
} }
// ─── Panel Éditeurs ───────────────────────────────────────────────────────────
function EditeursPanel() {
const utils = trpc.useUtils();
const editeursQuery = trpc.referentiel.editeurs.useQuery();
const [showCreate, setShowCreate] = useState(false);
const [createNom, setCreateNom] = useState("");
const [editingId, setEditingId] = useState<number | null>(null);
const [editNom, setEditNom] = useState("");
const createMutation = trpc.referentiel.createEditeur.useMutation({
onSuccess: () => {
toast.success("Éditeur créé");
setShowCreate(false);
setCreateNom("");
utils.referentiel.editeurs.invalidate();
},
onError: (err) => toast.error(err.message),
});
const updateMutation = trpc.referentiel.updateEditeur.useMutation({
onSuccess: () => {
toast.success("Éditeur mis à jour");
setEditingId(null);
utils.referentiel.editeurs.invalidate();
},
onError: (err) => toast.error(err.message),
});
const deleteMutation = trpc.referentiel.deleteEditeur.useMutation({
onSuccess: () => {
toast.success("Éditeur supprimé");
utils.referentiel.editeurs.invalidate();
},
onError: (err) => toast.error(err.message),
});
const editeurs = editeursQuery.data ?? [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Éditeurs de logiciels</h2>
<p className="text-sm text-muted-foreground">{editeurs.length} éditeur{editeurs.length !== 1 ? "s" : ""} référencé{editeurs.length !== 1 ? "s" : ""}</p>
</div>
<button
onClick={() => setShowCreate(!showCreate)}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors shadow-sm"
>
<Plus size={15} />
Ajouter un éditeur
</button>
</div>
{showCreate && (
<div className="bg-card border border-primary/20 rounded-xl p-4 shadow-sm">
<h3 className="font-semibold text-foreground mb-3 text-sm">Nouvel éditeur</h3>
<div className="flex gap-2">
<input
value={createNom}
onChange={(e) => setCreateNom(e.target.value)}
className="flex-1 px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Nom de l'éditeur"
onKeyDown={(e) => e.key === "Enter" && createMutation.mutate({ nom: createNom })}
/>
<button
onClick={() => createMutation.mutate({ nom: createNom })}
disabled={!createNom.trim() || createMutation.isPending}
className="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
<Check size={15} />
</button>
<button
onClick={() => { setShowCreate(false); setCreateNom(""); }}
className="px-3 py-2 text-muted-foreground hover:text-foreground rounded-lg border border-border transition-colors"
>
<X size={15} />
</button>
</div>
</div>
)}
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Nom de l'éditeur</th>
<th className="text-right px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Actions</th>
</tr>
</thead>
<tbody>
{editeursQuery.isLoading ? (
<tr><td colSpan={2} className="px-4 py-8 text-center text-muted-foreground text-sm">Chargement</td></tr>
) : editeurs.length === 0 ? (
<tr><td colSpan={2} className="px-4 py-8 text-center text-muted-foreground text-sm">Aucun éditeur référencé</td></tr>
) : editeurs.map((e) => (
<tr key={e.id} className="border-t border-border hover:bg-muted/20 transition-colors">
<td className="px-4 py-3">
{editingId === e.id ? (
<input
value={editNom}
onChange={(ev) => setEditNom(ev.target.value)}
className="px-3 py-1.5 text-sm bg-background border border-primary/40 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 w-full max-w-xs"
autoFocus
onKeyDown={(ev) => {
if (ev.key === "Enter") updateMutation.mutate({ id: e.id, nom: editNom });
if (ev.key === "Escape") setEditingId(null);
}}
/>
) : (
<span className="font-medium text-foreground">{e.nom}</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
{editingId === e.id ? (
<>
<button
onClick={() => updateMutation.mutate({ id: e.id, nom: editNom })}
disabled={!editNom.trim() || updateMutation.isPending}
className="p-1.5 text-primary hover:bg-primary/10 rounded-lg transition-colors disabled:opacity-50"
title="Enregistrer"
>
<Check size={14} />
</button>
<button
onClick={() => setEditingId(null)}
className="p-1.5 text-muted-foreground hover:text-foreground rounded-lg transition-colors"
title="Annuler"
>
<X size={14} />
</button>
</>
) : (
<>
<button
onClick={() => { setEditingId(e.id); setEditNom(e.nom); }}
className="p-1.5 text-primary hover:bg-primary/10 rounded-lg transition-colors"
title="Modifier"
>
<Pencil size={14} />
</button>
<button
onClick={() => {
if (confirm(`Supprimer l'éditeur "${e.nom}" ?`)) deleteMutation.mutate({ id: e.id });
}}
className="p-1.5 text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
title="Supprimer"
>
<Trash2 size={14} />
</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ─── Panel Blocs Fonctionnels ─────────────────────────────────────────────────
function BlocsFonctionnelsPanel() {
const utils = trpc.useUtils();
const blocsQuery = trpc.referentiel.blocsFonctionnels.useQuery();
const [showCreate, setShowCreate] = useState(false);
const [createNom, setCreateNom] = useState("");
const [editingId, setEditingId] = useState<number | null>(null);
const [editNom, setEditNom] = useState("");
const createMutation = trpc.referentiel.createBlocFonctionnel.useMutation({
onSuccess: () => {
toast.success("Bloc fonctionnel créé");
setShowCreate(false);
setCreateNom("");
utils.referentiel.blocsFonctionnels.invalidate();
},
onError: (err) => toast.error(err.message),
});
const updateMutation = trpc.referentiel.updateBlocFonctionnel.useMutation({
onSuccess: () => {
toast.success("Bloc fonctionnel mis à jour");
setEditingId(null);
utils.referentiel.blocsFonctionnels.invalidate();
},
onError: (err) => toast.error(err.message),
});
const deleteMutation = trpc.referentiel.deleteBlocFonctionnel.useMutation({
onSuccess: () => {
toast.success("Bloc fonctionnel supprimé");
utils.referentiel.blocsFonctionnels.invalidate();
},
onError: (err) => toast.error(err.message),
});
const blocs = blocsQuery.data ?? [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Blocs fonctionnels</h2>
<p className="text-sm text-muted-foreground">{blocs.length} bloc{blocs.length !== 1 ? "s" : ""} référencé{blocs.length !== 1 ? "s" : ""}</p>
</div>
<button
onClick={() => setShowCreate(!showCreate)}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors shadow-sm"
>
<Plus size={15} />
Ajouter un bloc
</button>
</div>
{showCreate && (
<div className="bg-card border border-primary/20 rounded-xl p-4 shadow-sm">
<h3 className="font-semibold text-foreground mb-3 text-sm">Nouveau bloc fonctionnel</h3>
<div className="flex gap-2">
<input
value={createNom}
onChange={(e) => setCreateNom(e.target.value)}
className="flex-1 px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Nom du bloc fonctionnel"
onKeyDown={(e) => e.key === "Enter" && createMutation.mutate({ nom: createNom })}
/>
<button
onClick={() => createMutation.mutate({ nom: createNom })}
disabled={!createNom.trim() || createMutation.isPending}
className="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
<Check size={15} />
</button>
<button
onClick={() => { setShowCreate(false); setCreateNom(""); }}
className="px-3 py-2 text-muted-foreground hover:text-foreground rounded-lg border border-border transition-colors"
>
<X size={15} />
</button>
</div>
</div>
)}
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Nom du bloc fonctionnel</th>
<th className="text-right px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Actions</th>
</tr>
</thead>
<tbody>
{blocsQuery.isLoading ? (
<tr><td colSpan={2} className="px-4 py-8 text-center text-muted-foreground text-sm">Chargement</td></tr>
) : blocs.length === 0 ? (
<tr><td colSpan={2} className="px-4 py-8 text-center text-muted-foreground text-sm">Aucun bloc fonctionnel référencé</td></tr>
) : blocs.map((b) => (
<tr key={b.id} className="border-t border-border hover:bg-muted/20 transition-colors">
<td className="px-4 py-3">
{editingId === b.id ? (
<input
value={editNom}
onChange={(ev) => setEditNom(ev.target.value)}
className="px-3 py-1.5 text-sm bg-background border border-primary/40 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 w-full max-w-xs"
autoFocus
onKeyDown={(ev) => {
if (ev.key === "Enter") updateMutation.mutate({ id: b.id, nom: editNom });
if (ev.key === "Escape") setEditingId(null);
}}
/>
) : (
<span className="font-medium text-foreground">{b.nom}</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
{editingId === b.id ? (
<>
<button
onClick={() => updateMutation.mutate({ id: b.id, nom: editNom })}
disabled={!editNom.trim() || updateMutation.isPending}
className="p-1.5 text-primary hover:bg-primary/10 rounded-lg transition-colors disabled:opacity-50"
title="Enregistrer"
>
<Check size={14} />
</button>
<button
onClick={() => setEditingId(null)}
className="p-1.5 text-muted-foreground hover:text-foreground rounded-lg transition-colors"
title="Annuler"
>
<X size={14} />
</button>
</>
) : (
<>
<button
onClick={() => { setEditingId(b.id); setEditNom(b.nom); }}
className="p-1.5 text-primary hover:bg-primary/10 rounded-lg transition-colors"
title="Modifier"
>
<Pencil size={14} />
</button>
<button
onClick={() => {
if (confirm(`Supprimer le bloc "${b.nom}" ?`)) deleteMutation.mutate({ id: b.id });
}}
className="p-1.5 text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
title="Supprimer"
>
<Trash2 size={14} />
</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,348 @@
import { useAuth } from "@/_core/hooks/useAuth";
import SonumLayout from "@/components/SonumLayout";
import { trpc } from "@/lib/trpc";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
Legend,
} from "recharts";
import {
Building2,
LayoutGrid,
FileText,
TrendingUp,
Shield,
CheckCircle,
} from "lucide-react";
// ─── Palette de couleurs ──────────────────────────────────────────────────────
const COLORS = [
"#1e40af", // bleu foncé
"#3b82f6", // bleu
"#60a5fa", // bleu clair
"#93c5fd", // bleu très clair
"#1d4ed8",
"#2563eb",
"#6366f1",
"#818cf8",
"#a5b4fc",
"#c7d2fe",
];
const ETAT_COLORS: Record<string, string> = {
"en production": "#16a34a",
"en cours de déploiement": "#ca8a04",
"en projet": "#2563eb",
"abandonné": "#dc2626",
"Inconnu": "#9ca3af",
};
// ─── Composant carte KPI ──────────────────────────────────────────────────────
function KpiCard({
icon,
label,
value,
sub,
color = "primary",
}: {
icon: React.ReactNode;
label: string;
value: string | number;
sub?: string;
color?: "primary" | "green" | "amber" | "blue";
}) {
const colorMap = {
primary: "bg-primary/10 text-primary",
green: "bg-emerald-50 text-emerald-600",
amber: "bg-amber-50 text-amber-600",
blue: "bg-blue-50 text-blue-600",
};
return (
<div className="bg-card border border-border rounded-xl p-5 shadow-sm flex items-start gap-4">
<div className={`p-3 rounded-xl ${colorMap[color]} shrink-0`}>{icon}</div>
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-0.5">{label}</p>
<p className="text-2xl font-bold text-foreground">{value}</p>
{sub && <p className="text-xs text-muted-foreground mt-0.5">{sub}</p>}
</div>
</div>
);
}
// ─── Tooltip personnalisé ─────────────────────────────────────────────────────
function CustomTooltip({ active, payload, label }: any) {
if (active && payload && payload.length) {
return (
<div className="bg-card border border-border rounded-lg shadow-lg px-3 py-2 text-sm">
<p className="font-medium text-foreground mb-1">{label}</p>
<p className="text-primary font-semibold">{payload[0].value} établissement{payload[0].value !== 1 ? "s" : ""}</p>
</div>
);
}
return null;
}
// ─── Page principale ──────────────────────────────────────────────────────────
export default function Statistiques() {
const { user } = useAuth();
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
const statsQuery = trpc.referentiel.statistiques.useQuery(undefined, {
enabled: isGestionnaire,
});
if (!isGestionnaire) {
return (
<SonumLayout>
<div className="p-8 text-center">
<Shield size={48} className="mx-auto text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground font-medium">Accès réservé aux gestionnaires SONUM</p>
</div>
</SonumLayout>
);
}
const stats = statsQuery.data;
return (
<SonumLayout>
<div className="p-6 lg:p-8 max-w-7xl mx-auto">
{/* En-tête */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-foreground mb-1">Tableau de bord statistiques</h1>
<p className="text-muted-foreground text-sm">
Vue d'ensemble de la cartographie des solutions numériques FEHAP
</p>
</div>
{statsQuery.isLoading && (
<div className="flex items-center justify-center py-20">
<div className="animate-spin w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full" />
</div>
)}
{statsQuery.isError && (
<div className="bg-destructive/10 border border-destructive/20 rounded-xl p-6 text-center">
<p className="text-destructive font-medium">Erreur lors du chargement des statistiques</p>
<p className="text-sm text-muted-foreground mt-1">{statsQuery.error?.message}</p>
</div>
)}
{stats && (
<div className="space-y-8">
{/* KPIs */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<KpiCard
icon={<Building2 size={22} />}
label="Établissements"
value={stats.totalEtablissements}
sub="adhérents FEHAP référencés"
color="primary"
/>
<KpiCard
icon={<LayoutGrid size={22} />}
label="Solutions distinctes"
value={stats.totalSolutions}
sub="logiciels référencés"
color="blue"
/>
<KpiCard
icon={<FileText size={22} />}
label="Fiches logiciels"
value={stats.totalFiches}
sub="rattachements établissement / solution"
color="amber"
/>
<KpiCard
icon={<TrendingUp size={22} />}
label="Taux de remplissage"
value={`${stats.tauxRemplissage} %`}
sub={`${stats.etabAvecLogiciel} étab. avec au moins 1 logiciel`}
color="green"
/>
</div>
{/* Graphiques ligne 1 : Blocs fonctionnels + Régions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Répartition par bloc fonctionnel */}
<div className="bg-card border border-border rounded-xl p-5 shadow-sm">
<h2 className="text-base font-semibold text-foreground mb-4 flex items-center gap-2">
<LayoutGrid size={16} className="text-primary" />
Répartition par bloc fonctionnel
</h2>
{stats.parBloc.length === 0 ? (
<div className="flex items-center justify-center h-48 text-muted-foreground text-sm">
Aucune donnée disponible
</div>
) : (
<ResponsiveContainer width="100%" height={280}>
<BarChart
data={stats.parBloc}
layout="vertical"
margin={{ top: 0, right: 20, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="hsl(var(--border))" />
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} />
<YAxis
type="category"
dataKey="nom"
width={130}
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
/>
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Répartition par région */}
<div className="bg-card border border-border rounded-xl p-5 shadow-sm">
<h2 className="text-base font-semibold text-foreground mb-4 flex items-center gap-2">
<Building2 size={16} className="text-primary" />
Répartition par région
</h2>
{stats.parRegion.length === 0 ? (
<div className="flex items-center justify-center h-48 text-muted-foreground text-sm">
Aucune donnée disponible
</div>
) : (
<ResponsiveContainer width="100%" height={280}>
<BarChart
data={stats.parRegion}
layout="vertical"
margin={{ top: 0, right: 20, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="hsl(var(--border))" />
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} />
<YAxis
type="category"
dataKey="nom"
width={130}
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
/>
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
{stats.parRegion.map((_: { nom: string; count: number }, index: number) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Graphiques ligne 2 : État de déploiement + Top solutions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Répartition par état de déploiement */}
<div className="bg-card border border-border rounded-xl p-5 shadow-sm">
<h2 className="text-base font-semibold text-foreground mb-4 flex items-center gap-2">
<CheckCircle size={16} className="text-primary" />
État de déploiement
</h2>
{stats.parEtat.length === 0 ? (
<div className="flex items-center justify-center h-48 text-muted-foreground text-sm">
Aucune donnée disponible
</div>
) : (
<div className="flex items-center justify-center">
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={stats.parEtat}
dataKey="count"
nameKey="nom"
cx="50%"
cy="45%"
outerRadius={90}
innerRadius={50}
paddingAngle={3}
>
{stats.parEtat.map((entry: { nom: string; count: number }, index: number) => (
<Cell
key={`cell-${index}`}
fill={ETAT_COLORS[entry.nom] ?? COLORS[index % COLORS.length]}
/>
))}
</Pie>
<Tooltip
formatter={(value: number, name: string) => [`${value} fiche${value !== 1 ? "s" : ""}`, name]}
contentStyle={{
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: "12px",
}}
/>
<Legend
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: "12px", paddingTop: "8px" }}
/>
</PieChart>
</ResponsiveContainer>
</div>
)}
</div>
{/* Top 10 solutions */}
<div className="bg-card border border-border rounded-xl p-5 shadow-sm">
<h2 className="text-base font-semibold text-foreground mb-4 flex items-center gap-2">
<TrendingUp size={16} className="text-primary" />
Top 10 solutions les plus utilisées
</h2>
{stats.topSolutions.length === 0 ? (
<div className="flex items-center justify-center h-48 text-muted-foreground text-sm">
Aucune donnée disponible
</div>
) : (
<div className="space-y-2 overflow-y-auto max-h-64">
{stats.topSolutions.map((sol: { nom: string; editeur: string; count: number }, index: number) => (
<div key={index} className="flex items-center gap-3">
<span className="text-xs font-bold text-muted-foreground w-5 shrink-0 text-right">
{index + 1}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-0.5">
<span className="text-sm font-medium text-foreground truncate">{sol.nom}</span>
<span className="text-xs font-semibold text-primary shrink-0">
{sol.count} étab.
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted rounded-full h-1.5">
<div
className="bg-primary rounded-full h-1.5 transition-all"
style={{
width: `${Math.round((sol.count / (stats.topSolutions[0]?.count || 1)) * 100)}%`,
}}
/>
</div>
<span className="text-xs text-muted-foreground shrink-0">{sol.editeur}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
</SonumLayout>
);
}

View File

@@ -777,3 +777,129 @@ export async function deleteSolution(id: number) {
await db.delete(solutions).where(eq(solutions.id, id)); await db.delete(solutions).where(eq(solutions.id, id));
return { success: true }; return { success: true };
} }
// ─── CRUD Éditeurs ────────────────────────────────────────────────────────────
export async function updateEditeur(id: number, nom: string) {
const db = await getDb();
if (!db) throw new Error("DB unavailable");
await db.update(editeurs).set({ nom }).where(eq(editeurs.id, id));
return { id };
}
export async function deleteEditeur(id: number) {
const db = await getDb();
if (!db) throw new Error("DB unavailable");
await db.delete(editeurs).where(eq(editeurs.id, id));
return { id };
}
// ─── CRUD Blocs Fonctionnels ──────────────────────────────────────────────────
export async function updateBlocFonctionnel(id: number, nom: string) {
const db = await getDb();
if (!db) throw new Error("DB unavailable");
await db.update(blocsFonctionnels).set({ nom }).where(eq(blocsFonctionnels.id, id));
return { id };
}
export async function deleteBlocFonctionnel(id: number) {
const db = await getDb();
if (!db) throw new Error("DB unavailable");
await db.delete(blocsFonctionnels).where(eq(blocsFonctionnels.id, id));
return { id };
}
// ─── Statistiques ─────────────────────────────────────────────────────────────
export async function getStatistiques() {
const db = await getDb();
if (!db) return null;
// Total établissements
const [{ total: totalEtablissements }] = await db
.select({ total: sql<number>`COUNT(*)` })
.from(etablissements);
// Total solutions distinctes utilisées
const [{ total: totalSolutions }] = await db
.select({ total: sql<number>`COUNT(DISTINCT solutionId)` })
.from(logicielsEtablissements);
// Total fiches logiciels (lignes logiciels_etablissements)
const [{ total: totalFiches }] = await db
.select({ total: sql<number>`COUNT(*)` })
.from(logicielsEtablissements);
// Établissements avec au moins un logiciel
const [{ total: etabAvecLogiciel }] = await db
.select({ total: sql<number>`COUNT(DISTINCT etablissementId)` })
.from(logicielsEtablissements);
// Répartition par bloc fonctionnel
const parBloc = await db
.select({
blocNom: blocsFonctionnels.nom,
count: sql<number>`COUNT(DISTINCT ${logicielsEtablissements.etablissementId})`,
})
.from(logicielsEtablissements)
.innerJoin(solutions, eq(logicielsEtablissements.solutionId, solutions.id))
.leftJoin(blocsFonctionnels, eq(solutions.blocFonctionnelId, blocsFonctionnels.id))
.groupBy(blocsFonctionnels.nom)
.orderBy(sql`COUNT(DISTINCT ${logicielsEtablissements.etablissementId}) DESC`);
// Répartition par région
const parRegion = await db
.select({
region: etablissements.region,
count: sql<number>`COUNT(DISTINCT ${etablissements.id})`,
})
.from(etablissements)
.groupBy(etablissements.region)
.orderBy(sql`COUNT(DISTINCT ${etablissements.id}) DESC`);
// Répartition par état de déploiement
const parEtat = await db
.select({
etat: logicielsEtablissements.etatDeploiement,
count: sql<number>`COUNT(*)`,
})
.from(logicielsEtablissements)
.groupBy(logicielsEtablissements.etatDeploiement)
.orderBy(sql`COUNT(*) DESC`);
// Top 10 solutions les plus utilisées
const topSolutions = await db
.select({
solutionNom: solutions.nom,
editeurNom: editeurs.nom,
count: sql<number>`COUNT(DISTINCT ${logicielsEtablissements.etablissementId})`,
})
.from(logicielsEtablissements)
.innerJoin(solutions, eq(logicielsEtablissements.solutionId, solutions.id))
.leftJoin(editeurs, eq(solutions.editeurId, editeurs.id))
.groupBy(solutions.id, solutions.nom, editeurs.nom)
.orderBy(sql`COUNT(DISTINCT ${logicielsEtablissements.etablissementId}) DESC`)
.limit(10);
// Taux de remplissage (% établissements avec au moins 1 logiciel)
const tauxRemplissage = totalEtablissements > 0
? Math.round((Number(etabAvecLogiciel) / Number(totalEtablissements)) * 100)
: 0;
return {
totalEtablissements: Number(totalEtablissements),
totalSolutions: Number(totalSolutions),
totalFiches: Number(totalFiches),
etabAvecLogiciel: Number(etabAvecLogiciel),
tauxRemplissage,
parBloc: parBloc.map((r) => ({ nom: r.blocNom ?? "Non renseigné", count: Number(r.count) })),
parRegion: parRegion.map((r) => ({ nom: r.region ?? "Non renseignée", count: Number(r.count) })),
parEtat: parEtat.map((r) => ({ nom: r.etat ?? "Inconnu", count: Number(r.count) })),
topSolutions: topSolutions.map((r) => ({
nom: r.solutionNom ?? "",
editeur: r.editeurNom ?? "",
count: Number(r.count),
})),
};
}

View File

@@ -5,11 +5,16 @@ import {
authenticateLocalUser, authenticateLocalUser,
createDemandeContact, createDemandeContact,
createBlocFonctionnel, createBlocFonctionnel,
updateBlocFonctionnel,
deleteBlocFonctionnel,
createEditeur, createEditeur,
updateEditeur,
deleteEditeur,
createLocalUser, createLocalUser,
createSolution, createSolution,
updateSolution, updateSolution,
deleteSolution, deleteSolution,
getStatistiques,
deleteLogicielEtablissement, deleteLogicielEtablissement,
deleteUser, deleteUser,
getAllDemandes, getAllDemandes,
@@ -141,6 +146,19 @@ export const appRouter = router({
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin"; const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
return createBlocFonctionnel(input.nom, isGestionnaire); return createBlocFonctionnel(input.nom, isGestionnaire);
}), }),
updateBlocFonctionnel: gestionnaireProcedure
.input(z.object({ id: z.number().int(), nom: z.string().min(1) }))
.mutation(({ input }) => updateBlocFonctionnel(input.id, input.nom)),
deleteBlocFonctionnel: gestionnaireProcedure
.input(z.object({ id: z.number().int() }))
.mutation(({ input }) => deleteBlocFonctionnel(input.id)),
updateEditeur: gestionnaireProcedure
.input(z.object({ id: z.number().int(), nom: z.string().min(1) }))
.mutation(({ input }) => updateEditeur(input.id, input.nom)),
deleteEditeur: gestionnaireProcedure
.input(z.object({ id: z.number().int() }))
.mutation(({ input }) => deleteEditeur(input.id)),
statistiques: gestionnaireProcedure.query(() => getStatistiques()),
createSolution: protectedProcedure createSolution: protectedProcedure
.input(z.object({ .input(z.object({

10
todo.md
View File

@@ -74,3 +74,13 @@
## Évolution v5 ## Évolution v5
- [x] Solutions Logicielles : toggle vue accordéon / vue liste à plat (solutions + établissements visibles sans clic) - [x] Solutions Logicielles : toggle vue accordéon / vue liste à plat (solutions + établissements visibles sans clic)
## Évolution v6
- [x] Admin : onglet "Éditeurs" — CRUD éditeurs (ajouter, modifier, supprimer)
- [x] Admin : onglet "Blocs fonctionnels" — CRUD blocs fonctionnels (ajouter, modifier, supprimer)
- [x] Backend : procédures tRPC CRUD éditeurs (createEditeur, updateEditeur, deleteEditeur)
- [x] Backend : procédures tRPC CRUD blocs fonctionnels (createBlocFonctionnel, updateBlocFonctionnel, deleteBlocFonctionnel)
- [x] Backend : procédure statistiques (stats par bloc fonctionnel, par région, taux de remplissage)
- [x] Page "Tableau de bord" : graphiques recharts (barres, camembert, indicateurs clés)
- [x] Lien "Tableau de bord" dans la sidebar (visible uniquement gestionnaires)