feat: add 'Utilisateurs' link to Header component and implement user statistics retrieval in auth service
This commit is contained in:
231
src/app/users/page.tsx
Normal file
231
src/app/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user