feat: add 'Utilisateurs' link to Header component and implement user statistics retrieval in auth service

This commit is contained in:
Julien Froidefond
2025-11-28 10:55:49 +01:00
parent ff7c846ed1
commit 941151553f
3 changed files with 296 additions and 0 deletions

231
src/app/users/page.tsx Normal file
View File

@@ -0,0 +1,231 @@
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { getAllUsersWithStats } from '@/services/auth';
import { getGravatarUrl } from '@/lib/gravatar';
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "Aujourd'hui";
if (diffDays === 1) return 'Hier';
if (diffDays < 7) return `Il y a ${diffDays} jours`;
if (diffDays < 30) return `Il y a ${Math.floor(diffDays / 7)} sem.`;
if (diffDays < 365) return `Il y a ${Math.floor(diffDays / 30)} mois`;
return `Il y a ${Math.floor(diffDays / 365)} an(s)`;
}
export default async function UsersPage() {
const session = await auth();
if (!session?.user?.id) {
redirect('/login');
}
const users = await getAllUsersWithStats();
// Calculate some global stats
const totalSessions = users.reduce(
(acc, u) => acc + u._count.sessions + u._count.motivatorSessions,
0
);
const avgSessionsPerUser = users.length > 0 ? totalSessions / users.length : 0;
return (
<main className="mx-auto max-w-6xl px-4 py-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Utilisateurs</h1>
<p className="mt-1 text-muted">
{users.length} utilisateur{users.length > 1 ? 's' : ''} inscrit
{users.length > 1 ? 's' : ''}
</p>
</div>
{/* Global Stats */}
<div className="mb-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="rounded-xl border border-border bg-card p-4">
<div className="text-2xl font-bold text-primary">{users.length}</div>
<div className="text-sm text-muted">Utilisateurs</div>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<div className="text-2xl font-bold text-strength">{totalSessions}</div>
<div className="text-sm text-muted">Sessions totales</div>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<div className="text-2xl font-bold text-opportunity">
{avgSessionsPerUser.toFixed(1)}
</div>
<div className="text-sm text-muted">Moy. par user</div>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<div className="text-2xl font-bold text-accent">
{users.reduce(
(acc, u) =>
acc + u._count.sharedSessions + u._count.sharedMotivatorSessions,
0
)}
</div>
<div className="text-sm text-muted">Partages actifs</div>
</div>
</div>
{/* Users List */}
<div className="space-y-3">
{users.map((user) => {
const totalUserSessions =
user._count.sessions + user._count.motivatorSessions;
const totalShares =
user._count.sharedSessions + user._count.sharedMotivatorSessions;
const isCurrentUser = user.id === session.user?.id;
return (
<div
key={user.id}
className={`flex items-center gap-4 rounded-xl border p-4 transition-colors ${
isCurrentUser
? 'border-primary/30 bg-primary/5'
: 'border-border bg-card hover:bg-card-hover'
}`}
>
{/* Avatar */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getGravatarUrl(user.email, 96)}
alt={user.name || user.email}
width={48}
height={48}
className="rounded-full border-2 border-border"
/>
{/* User Info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground truncate">
{user.name || 'Sans nom'}
</span>
{isCurrentUser && (
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs text-primary">
Vous
</span>
)}
</div>
<div className="text-sm text-muted truncate">{user.email}</div>
</div>
{/* Stats Pills */}
<div className="hidden gap-2 sm:flex">
<div
className="flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium"
style={{
backgroundColor: 'color-mix(in srgb, var(--strength) 15%, transparent)',
color: 'var(--strength)',
}}
title="Sessions SWOT"
>
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
/>
</svg>
{user._count.sessions}
</div>
<div
className="flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium"
style={{
backgroundColor: 'color-mix(in srgb, var(--opportunity) 15%, transparent)',
color: 'var(--opportunity)',
}}
title="Sessions Moving Motivators"
>
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
{user._count.motivatorSessions}
</div>
{totalShares > 0 && (
<div
className="flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium"
style={{
backgroundColor: 'color-mix(in srgb, var(--accent) 15%, transparent)',
color: 'var(--accent)',
}}
title="Sessions partagées avec cet utilisateur"
>
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{totalShares}
</div>
)}
</div>
{/* Mobile Stats */}
<div className="flex flex-col items-end gap-1 sm:hidden">
<div className="text-sm font-medium text-foreground">
{totalUserSessions} session{totalUserSessions !== 1 ? 's' : ''}
</div>
<div className="text-xs text-muted">
{formatRelativeTime(user.createdAt)}
</div>
</div>
{/* Date Info */}
<div className="hidden flex-col items-end sm:flex">
<div className="text-sm text-foreground">
{formatRelativeTime(user.createdAt)}
</div>
<div className="text-xs text-muted">
{new Date(user.createdAt).toLocaleDateString('fr-FR')}
</div>
</div>
</div>
);
})}
</div>
{/* Empty state */}
{users.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-xl border border-border bg-card py-16">
<div className="text-4xl">👥</div>
<div className="mt-4 text-lg font-medium text-foreground">
Aucun utilisateur
</div>
<div className="mt-1 text-sm text-muted">
Les utilisateurs apparaîtront ici une fois inscrits
</div>
</div>
)}
</main>
);
}

View File

@@ -155,6 +155,13 @@ export function Header() {
>
👤 Mon Profil
</Link>
<Link
href="/users"
onClick={() => setMenuOpen(false)}
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
>
👥 Utilisateurs
</Link>
<button
onClick={() => signOut({ callbackUrl: '/' })}
className="w-full px-4 py-2 text-left text-sm text-destructive hover:bg-card-hover"

View File

@@ -144,3 +144,61 @@ export async function updateUserPassword(
return { success: true };
}
export interface UserStats {
sessions: number;
motivatorSessions: number;
sharedSessions: number;
sharedMotivatorSessions: number;
}
export interface UserWithStats {
id: string;
email: string;
name: string | null;
createdAt: Date;
updatedAt: Date;
_count: UserStats;
}
export async function getAllUsersWithStats(): Promise<UserWithStats[]> {
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
sessions: true,
sharedSessions: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
// Get motivator sessions count separately (Prisma doesn't have these in User model _count directly)
const usersWithMotivators = await Promise.all(
users.map(async (user) => {
const motivatorCount = await prisma.movingMotivatorsSession.count({
where: { userId: user.id },
});
const sharedMotivatorCount = await prisma.mMSessionShare.count({
where: { userId: user.id },
});
return {
...user,
_count: {
...user._count,
motivatorSessions: motivatorCount,
sharedMotivatorSessions: sharedMotivatorCount,
},
};
})
);
return usersWithMotivators;
}