refactor(admin): replace static dashboard stats with dynamic widget registry

This commit is contained in:
2026-04-15 20:43:10 -04:00
parent 371a69c499
commit 41edccc1a3
11 changed files with 198 additions and 69 deletions
+9 -54
View File
@@ -1,30 +1,9 @@
/** /**
* Admin Stats Actions * Admin Stats Actions
* Server-side actions for core dashboard statistics
* *
* Usage in your Next.js app: * @deprecated Utiliser collectAllDashboardData() de serverRegistry à la place.
* * Ce fichier est conservé pour la rétrocompatibilité avec les projets qui
* ```javascript * appellent getDashboardStats() directement depuis leur page admin.
* // app/(admin)/admin/[...admin]/page.js
* import { protectAdmin } from '@zen/core/features/admin';
* import { getDashboardStats } from '@zen/core/features/admin/actions';
* import { AdminPagesClient } from '@zen/core/features/admin/pages';
*
* export default async function AdminPage({ params }) {
* const { user } = await protectAdmin();
*
* const statsResult = await getDashboardStats();
* const dashboardStats = statsResult.success ? statsResult.stats : null;
*
* return (
* <AdminPagesClient
* params={params}
* user={user}
* dashboardStats={dashboardStats}
* />
* );
* }
* ```
*/ */
'use server'; 'use server';
@@ -33,40 +12,16 @@ import { query } from '@zen/core/database';
import { fail } from '@zen/core/shared/logger'; import { fail } from '@zen/core/shared/logger';
/** /**
* Get total number of users * @deprecated Utiliser collectAllDashboardData() de serverRegistry à la place.
* @returns {Promise<number>} * @returns {Promise<{ success: boolean, stats?: { totalUsers: number }, error?: string }>}
*/
async function getTotalUsersCount() {
try {
const result = await query(
`SELECT COUNT(*) as count FROM zen_auth_users`
);
return parseInt(result.rows[0].count) || 0;
} catch (error) {
fail(`Error getting users count: ${error.message}`);
return 0;
}
}
/**
* Get core dashboard statistics
* @returns {Promise<Object>}
*/ */
export async function getDashboardStats() { export async function getDashboardStats() {
try { try {
const totalUsers = await getTotalUsersCount(); const result = await query(`SELECT COUNT(*) as count FROM zen_auth_users`);
const totalUsers = parseInt(result.rows[0].count) || 0;
return { return { success: true, stats: { totalUsers } };
success: true,
stats: {
totalUsers,
}
};
} catch (error) { } catch (error) {
fail(`Error getting dashboard stats: ${error.message}`); fail(`Error getting dashboard stats: ${error.message}`);
return { return { success: false, error: error.message || 'Failed to get dashboard statistics' };
success: false,
error: error.message || 'Failed to get dashboard statistics'
};
} }
} }
@@ -1,10 +1,13 @@
'use client'; 'use client';
import { StatCard } from '@zen/core/shared/components'; import { getClientWidgets } from '../../dashboard/clientRegistry.js';
import { UserMultiple02Icon } from '@zen/core/shared/icons'; import '../../../dashboard.client.js';
export default function DashboardPage({ user, stats }) { // Évalué après tous les imports : les auto-registrations sont complètes
const loading = !stats; const sortedWidgets = getClientWidgets();
export default function DashboardPage({ stats }) {
const loading = stats === null || stats === undefined;
return ( return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8"> <div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
@@ -18,14 +21,13 @@ export default function DashboardPage({ user, stats }) {
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
<StatCard {sortedWidgets.map(({ id, Component }) => (
title="Nombre d'utilisateurs" <Component
value={loading ? '-' : String(stats?.totalUsers || 0)} key={id}
icon={UserMultiple02Icon} data={loading ? null : (stats[id] ?? null)}
color="text-purple-400" loading={loading}
bgColor="bg-purple-500/10" />
loading={loading} ))}
/>
</div> </div>
</div> </div>
); );
@@ -0,0 +1,30 @@
'use client';
/**
* Dashboard Client Registry — API générique
*
* Les features s'enregistrent ici via registerClientWidget().
* Ce fichier ne doit jamais importer une feature directement.
* Les features dépendent de ce fichier, pas l'inverse.
*/
const widgets = [];
/**
* Enregistre un composant React pour le tableau de bord.
* Appelé en side effect par chaque feature (ex: auth/dashboard.widget.js).
* @param {string} id - Identifiant unique de la feature (doit correspondre à l'id serveur)
* @param {React.ComponentType} Component - Composant avec props { data, loading }
* @param {number} order - Ordre d'affichage (croissant). Utiliser des intervalles de 10.
*/
export function registerClientWidget(id, Component, order) {
widgets.push({ id, Component, order });
}
/**
* Retourne tous les widgets enregistrés, triés par order croissant.
* @returns {Array<{ id: string, Component: React.ComponentType, order: number }>}
*/
export function getClientWidgets() {
return [...widgets].sort((a, b) => a.order - b.order);
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Dashboard Server Registry — API générique
*
* Les features s'enregistrent ici via registerServerWidget().
* Ce fichier ne doit jamais importer une feature directement.
* Les features dépendent de ce fichier, pas l'inverse.
*/
const widgets = new Map(); // id → fetcher
/**
* Enregistre une fonction de fetch pour le tableau de bord.
* Appelé en side effect par chaque feature (ex: auth/dashboard.server.js).
* @param {string} id - Identifiant unique de la feature
* @param {() => Promise<any>} fetcher - Fonction async retournant des données sérialisables
*/
export function registerServerWidget(id, fetcher) {
widgets.set(id, fetcher);
}
/**
* Exécute tous les fetchers enregistrés en parallèle.
* Un fetcher qui échoue n'empêche pas les autres (Promise.allSettled).
* @returns {Promise<Record<string, any>>} Map featureId → données
*/
export async function collectAllDashboardData() {
const results = await Promise.allSettled(
Array.from(widgets.entries()).map(([id, fetcher]) =>
fetcher().then(data => ({ id, data }))
)
);
return results.reduce((acc, result) => {
if (result.status === 'fulfilled') {
acc[result.value.id] = result.value.data;
}
return acc;
}, {});
}
@@ -0,0 +1,15 @@
/**
* Dashboard Server Registry — Point d'entrée
*
* Ré-exporte l'API du registre pur et déclenche le câblage des features
* via un import side-effect de features/dashboard.server.js.
*
* Ne jamais importer une feature directement ici.
* Pour ajouter une feature : éditer src/features/dashboard.server.js.
*/
export { collectAllDashboardData } from './registry.js';
// Side effect : initialise le registre, puis déclenche toutes les auto-registrations
import './registry.js';
import '../../dashboard.server.js';
+2 -3
View File
@@ -8,7 +8,7 @@
import { AdminPagesLayout, AdminPagesClient } from '@zen/core/features/admin/pages'; import { AdminPagesLayout, AdminPagesClient } from '@zen/core/features/admin/pages';
import { protectAdmin } from '@zen/core/features/admin'; import { protectAdmin } from '@zen/core/features/admin';
import { buildNavigationSections } from '@zen/core/features/admin/navigation'; import { buildNavigationSections } from '@zen/core/features/admin/navigation';
import { getDashboardStats } from '@zen/core/features/admin/actions'; import { collectAllDashboardData } from './dashboard/serverRegistry.js';
import { logoutAction } from '@zen/core/features/auth/actions'; import { logoutAction } from '@zen/core/features/auth/actions';
import { getAppName } from '@zen/core'; import { getAppName } from '@zen/core';
@@ -17,8 +17,7 @@ export default async function AdminPage({ params }) {
const session = await protectAdmin(); const session = await protectAdmin();
const appName = getAppName(); const appName = getAppName();
const statsResult = await getDashboardStats(); const dashboardStats = await collectAllDashboardData();
const dashboardStats = statsResult.success ? statsResult.stats : null;
const navigationSections = buildNavigationSections('/'); const navigationSections = buildNavigationSections('/');
+22
View File
@@ -0,0 +1,22 @@
/**
* Auth Feature — Contribution serveur au tableau de bord
*
* Ce module s'auto-enregistre auprès du registre admin en side effect.
* Dépendance correcte : auth → admin (feature → core).
*/
import { registerServerWidget } from '../admin/dashboard/registry.js';
import { query } from '@zen/core/database';
import { fail } from '@zen/core/shared/logger';
async function getDashboardData() {
try {
const result = await query(`SELECT COUNT(*) as count FROM zen_auth_users`);
return { totalUsers: parseInt(result.rows[0].count) || 0 };
} catch (error) {
fail(`Auth dashboard data error: ${error.message}`);
return { totalUsers: 0 };
}
}
registerServerWidget('auth', getDashboardData);
+33
View File
@@ -0,0 +1,33 @@
'use client';
/**
* Auth Feature — Widget client pour le tableau de bord
*
* Ce module s'auto-enregistre auprès du registre admin en side effect.
* Dépendance correcte : auth → admin (feature → core).
*
* Props du composant :
* data — { totalUsers: number } retourné par getDashboardData(), ou null
* loading — true tant que les données serveur ne sont pas disponibles
*/
import { registerClientWidget } from '../admin/dashboard/clientRegistry.js';
import { StatCard } from '@zen/core/shared/components';
import { UserMultiple02Icon } from '@zen/core/shared/icons';
function AuthDashboardWidget({ data, loading }) {
return (
<StatCard
title="Nombre d'utilisateurs"
value={loading ? '-' : String(data?.totalUsers ?? 0)}
icon={UserMultiple02Icon}
color="text-purple-400"
bgColor="bg-purple-500/10"
loading={loading}
/>
);
}
registerClientWidget('auth', AuthDashboardWidget, 10);
export default AuthDashboardWidget;
+16
View File
@@ -0,0 +1,16 @@
'use client';
/**
* Câblage client des widgets du tableau de bord
*
* Ce fichier est le SEUL à modifier pour ajouter le widget client
* d'une nouvelle feature au tableau de bord.
*
* L'import suffit : chaque module s'enregistre lui-même en side effect
* via registerClientWidget() de admin/dashboard/clientRegistry.js.
*
* Exemple pour une feature 'blog' :
* import './blog/dashboard.widget.js';
*/
import './auth/dashboard.widget.js';
+14
View File
@@ -0,0 +1,14 @@
/**
* Câblage serveur des widgets du tableau de bord
*
* Ce fichier est le SEUL à modifier pour ajouter la contribution serveur
* d'une nouvelle feature au tableau de bord.
*
* L'import suffit : chaque module s'enregistre lui-même en side effect
* via registerServerWidget() de admin/dashboard/registry.js.
*
* Exemple pour une feature 'blog' :
* import './blog/dashboard.server.js';
*/
import './auth/dashboard.server.js';
+4
View File
@@ -58,6 +58,10 @@ export default defineConfig([
'src/features/auth/page.js', 'src/features/auth/page.js',
'src/features/admin/page.js', 'src/features/admin/page.js',
'src/features/admin/navigation.server.js', 'src/features/admin/navigation.server.js',
'src/features/admin/dashboard/registry.js',
'src/features/admin/dashboard/serverRegistry.js',
'src/features/auth/dashboard.server.js',
'src/features/dashboard.server.js',
], ],
format: ['esm'], format: ['esm'],
dts: false, dts: false,