Compare commits

...

3 Commits

Author SHA1 Message Date
f2c1b195b3 Fix UserStats typing in users page counters
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m58s
2026-03-04 17:04:09 +01:00
367eea6ee8 Persist sessions view mode in localStorage 2026-03-04 17:04:03 +01:00
dcc769a930 fix(users): include all workshop types in user stats 2026-03-04 08:44:07 +01:00
3 changed files with 321 additions and 137 deletions

View File

@@ -1,16 +1,24 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import { CollaboratorDisplay } from '@/components/ui'; import { CollaboratorDisplay } from '@/components/ui';
import { type WorkshopTabType, WORKSHOPS, VALID_TAB_PARAMS } from '@/lib/workshops'; import { type WorkshopTabType, VALID_TAB_PARAMS } from '@/lib/workshops';
import { useClickOutside } from '@/hooks/useClickOutside'; import { useClickOutside } from '@/hooks/useClickOutside';
import { import {
type CardView, type SortCol, type WorkshopTabsProps, type AnySession, type CardView,
TABLE_COLS, SORT_COLUMNS, TYPE_TABS, type SortCol,
type WorkshopTabsProps,
type AnySession,
TABLE_COLS,
SORT_COLUMNS,
TYPE_TABS,
} from './workshop-session-types'; } from './workshop-session-types';
import { import {
getResolvedCollaborator, groupByPerson, getMonthGroup, sortSessions, getResolvedCollaborator,
groupByPerson,
getMonthGroup,
sortSessions,
} from './workshop-session-helpers'; } from './workshop-session-helpers';
import { SessionCard } from './SessionCard'; import { SessionCard } from './SessionCard';
@@ -33,7 +41,13 @@ function SectionHeader({ label, count }: { label: string; count: number }) {
function SortIcon({ active, dir }: { active: boolean; dir: 'asc' | 'desc' }) { function SortIcon({ active, dir }: { active: boolean; dir: 'asc' | 'desc' }) {
if (!active) { if (!active) {
return ( return (
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor" className="opacity-30 flex-shrink-0"> <svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
className="opacity-30 flex-shrink-0"
>
<path d="M5 1.5L8 5H2L5 1.5Z" /> <path d="M5 1.5L8 5H2L5 1.5Z" />
<path d="M5 8.5L2 5H8L5 8.5Z" /> <path d="M5 8.5L2 5H8L5 8.5Z" />
</svg> </svg>
@@ -72,27 +86,61 @@ function ViewToggle({ view, setView }: { view: CardView; setView: (v: CardView)
return ( return (
<div className="flex items-center gap-0.5 p-0.5 bg-card border border-border rounded-lg ml-auto flex-shrink-0 shadow-sm"> <div className="flex items-center gap-0.5 p-0.5 bg-card border border-border rounded-lg ml-auto flex-shrink-0 shadow-sm">
{btn('grid', 'Grille', {btn(
'grid',
'Grille',
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"> <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<rect x="0" y="0" width="4" height="4" rx="0.5" /><rect x="5" y="0" width="4" height="4" rx="0.5" /><rect x="10" y="0" width="4" height="4" rx="0.5" /> <rect x="0" y="0" width="4" height="4" rx="0.5" />
<rect x="0" y="5" width="4" height="4" rx="0.5" /><rect x="5" y="5" width="4" height="4" rx="0.5" /><rect x="10" y="5" width="4" height="4" rx="0.5" /> <rect x="5" y="0" width="4" height="4" rx="0.5" />
<rect x="0" y="10" width="4" height="4" rx="0.5" /><rect x="5" y="10" width="4" height="4" rx="0.5" /><rect x="10" y="10" width="4" height="4" rx="0.5" /> <rect x="10" y="0" width="4" height="4" rx="0.5" />
<rect x="0" y="5" width="4" height="4" rx="0.5" />
<rect x="5" y="5" width="4" height="4" rx="0.5" />
<rect x="10" y="5" width="4" height="4" rx="0.5" />
<rect x="0" y="10" width="4" height="4" rx="0.5" />
<rect x="5" y="10" width="4" height="4" rx="0.5" />
<rect x="10" y="10" width="4" height="4" rx="0.5" />
</svg> </svg>
)} )}
{btn('list', 'Liste', {btn(
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"> 'list',
<line x1="0" y1="2.5" x2="14" y2="2.5" /><line x1="0" y1="7" x2="14" y2="7" /><line x1="0" y1="11.5" x2="14" y2="11.5" /> 'Liste',
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<line x1="0" y1="2.5" x2="14" y2="2.5" />
<line x1="0" y1="7" x2="14" y2="7" />
<line x1="0" y1="11.5" x2="14" y2="11.5" />
</svg> </svg>
)} )}
{btn('table', 'Tableau', {btn(
'table',
'Tableau',
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"> <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<rect x="0" y="0" width="14" height="3.5" rx="0.5" /> <rect x="0" y="0" width="14" height="3.5" rx="0.5" />
<rect x="0" y="5" width="6" height="2.5" rx="0.5" /><rect x="8" y="5" width="6" height="2.5" rx="0.5" /> <rect x="0" y="5" width="6" height="2.5" rx="0.5" />
<rect x="0" y="9.5" width="6" height="2.5" rx="0.5" /><rect x="8" y="9.5" width="6" height="2.5" rx="0.5" /> <rect x="8" y="5" width="6" height="2.5" rx="0.5" />
<rect x="0" y="9.5" width="6" height="2.5" rx="0.5" />
<rect x="8" y="9.5" width="6" height="2.5" rx="0.5" />
</svg> </svg>
)} )}
{btn('timeline', 'Chronologique', {btn(
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"> 'timeline',
'Chronologique',
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<line x1="3" y1="0" x2="3" y2="14" /> <line x1="3" y1="0" x2="3" y2="14" />
<circle cx="3" cy="2.5" r="1.5" fill="currentColor" stroke="none" /> <circle cx="3" cy="2.5" r="1.5" fill="currentColor" stroke="none" />
<line x1="5" y1="2.5" x2="14" y2="2.5" /> <line x1="5" y1="2.5" x2="14" y2="2.5" />
@@ -108,11 +156,23 @@ function ViewToggle({ view, setView }: { view: CardView; setView: (v: CardView)
// ─── TabButton ──────────────────────────────────────────────────────────────── // ─── TabButton ────────────────────────────────────────────────────────────────
function TabButton({ active, onClick, icon, label, count }: { function TabButton({
active: boolean; onClick: () => void; icon: string; label: string; count: number; active,
onClick,
icon,
label,
count,
}: {
active: boolean;
onClick: () => void;
icon: string;
label: string;
count: number;
}) { }) {
return ( return (
<button type="button" onClick={onClick} <button
type="button"
onClick={onClick}
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-full font-medium text-sm transition-all duration-150 shadow-sm ${ className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-full font-medium text-sm transition-all duration-150 shadow-sm ${
active active
? 'bg-primary text-primary-foreground shadow-md' ? 'bg-primary text-primary-foreground shadow-md'
@@ -121,7 +181,9 @@ function TabButton({ active, onClick, icon, label, count }: {
> >
<span>{icon}</span> <span>{icon}</span>
<span>{label}</span> <span>{label}</span>
<span className={`text-[11px] font-semibold px-1.5 py-0.5 rounded-full ${active ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}> <span
className={`text-[11px] font-semibold px-1.5 py-0.5 rounded-full ${active ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}
>
{count} {count}
</span> </span>
</button> </button>
@@ -131,10 +193,17 @@ function TabButton({ active, onClick, icon, label, count }: {
// ─── TypeFilterDropdown ─────────────────────────────────────────────────────── // ─── TypeFilterDropdown ───────────────────────────────────────────────────────
function TypeFilterDropdown({ function TypeFilterDropdown({
activeTab, setActiveTab, open, onOpenChange, counts, activeTab,
setActiveTab,
open,
onOpenChange,
counts,
}: { }: {
activeTab: WorkshopTabType; setActiveTab: (t: WorkshopTabType) => void; activeTab: WorkshopTabType;
open: boolean; onOpenChange: (v: boolean) => void; counts: Record<string, number>; setActiveTab: (t: WorkshopTabType) => void;
open: boolean;
onOpenChange: (v: boolean) => void;
counts: Record<string, number>;
}) { }) {
const typeTabs = TYPE_TABS.filter((t) => t.value !== 'all' && t.value !== 'team'); const typeTabs = TYPE_TABS.filter((t) => t.value !== 'all' && t.value !== 'team');
const current = TYPE_TABS.find((t) => t.value === activeTab) ?? TYPE_TABS[0]; const current = TYPE_TABS.find((t) => t.value === activeTab) ?? TYPE_TABS[0];
@@ -156,25 +225,55 @@ function TypeFilterDropdown({
> >
<span>{isTypeSelected ? current.icon : '🔖'}</span> <span>{isTypeSelected ? current.icon : '🔖'}</span>
<span>{isTypeSelected ? current.label : 'Type'}</span> <span>{isTypeSelected ? current.label : 'Type'}</span>
<span className={`text-[11px] font-semibold px-1.5 py-0.5 rounded-full ${isTypeSelected ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}> <span
className={`text-[11px] font-semibold px-1.5 py-0.5 rounded-full ${isTypeSelected ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}
>
{isTypeSelected ? (counts[activeTab] ?? 0) : totalCount} {isTypeSelected ? (counts[activeTab] ?? 0) : totalCount}
</span> </span>
<svg className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</button> </button>
{open && ( {open && (
<div className="absolute left-0 z-20 mt-2 w-48 rounded-xl border border-border bg-card py-1.5 shadow-lg"> <div className="absolute left-0 z-20 mt-2 w-48 rounded-xl border border-border bg-card py-1.5 shadow-lg">
<button type="button" onClick={() => { setActiveTab('all'); onOpenChange(false); }} <button
className="flex w-full items-center justify-between gap-2 px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover border-b border-border transition-colors"> type="button"
<span className="flex items-center gap-2"><span>📋</span><span>Tous les types</span></span> onClick={() => {
<span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">{totalCount}</span> setActiveTab('all');
onOpenChange(false);
}}
className="flex w-full items-center justify-between gap-2 px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover border-b border-border transition-colors"
>
<span className="flex items-center gap-2">
<span>📋</span>
<span>Tous les types</span>
</span>
<span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
{totalCount}
</span>
</button> </button>
{typeTabs.map((t) => ( {typeTabs.map((t) => (
<button key={t.value} type="button" onClick={() => { setActiveTab(t.value); onOpenChange(false); }} <button
className="flex w-full items-center justify-between gap-2 px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover transition-colors"> key={t.value}
<span className="flex items-center gap-2"><span>{t.icon}</span><span>{t.label}</span></span> type="button"
<span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">{counts[t.value] ?? 0}</span> onClick={() => {
setActiveTab(t.value);
onOpenChange(false);
}}
className="flex w-full items-center justify-between gap-2 px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover transition-colors"
>
<span className="flex items-center gap-2">
<span>{t.icon}</span>
<span>{t.label}</span>
</span>
<span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
{counts[t.value] ?? 0}
</span>
</button> </button>
))} ))}
</div> </div>
@@ -186,16 +285,25 @@ function TypeFilterDropdown({
// ─── SessionsGrid ───────────────────────────────────────────────────────────── // ─── SessionsGrid ─────────────────────────────────────────────────────────────
function SessionsGrid({ function SessionsGrid({
sessions, view, isTeamCollab = false, sessions,
view,
isTeamCollab = false,
}: { }: {
sessions: AnySession[]; view: CardView; isTeamCollab?: boolean; sessions: AnySession[];
view: CardView;
isTeamCollab?: boolean;
}) { }) {
if (view === 'table') { if (view === 'table') {
return ( return (
<div className="rounded-xl border border-border overflow-hidden overflow-x-auto bg-card"> <div className="rounded-xl border border-border overflow-hidden overflow-x-auto bg-card">
<div className="grid text-[11px] font-semibold text-muted uppercase tracking-wider bg-card-hover/60 border-b border-border" style={{ gridTemplateColumns: TABLE_COLS }}> <div
className="grid text-[11px] font-semibold text-muted uppercase tracking-wider bg-card-hover/60 border-b border-border"
style={{ gridTemplateColumns: TABLE_COLS }}
>
{SORT_COLUMNS.map((col) => ( {SORT_COLUMNS.map((col) => (
<div key={col.key} className="px-4 py-2.5">{col.label}</div> <div key={col.key} className="px-4 py-2.5">
{col.label}
</div>
))} ))}
</div> </div>
{sessions.map((s) => ( {sessions.map((s) => (
@@ -205,7 +313,11 @@ function SessionsGrid({
); );
} }
return ( return (
<div className={view === 'list' ? 'flex flex-col gap-2' : 'grid gap-4 md:grid-cols-2 lg:grid-cols-3'}> <div
className={
view === 'list' ? 'flex flex-col gap-2' : 'grid gap-4 md:grid-cols-2 lg:grid-cols-3'
}
>
{sessions.map((s) => ( {sessions.map((s) => (
<SessionCard key={s.id} session={s} isTeamCollab={isTeamCollab} view={view} /> <SessionCard key={s.id} session={s} isTeamCollab={isTeamCollab} view={view} />
))} ))}
@@ -216,7 +328,10 @@ function SessionsGrid({
// ─── SortableTableView ──────────────────────────────────────────────────────── // ─── SortableTableView ────────────────────────────────────────────────────────
function SortableTableView({ function SortableTableView({
sessions, sortCol, sortDir, onSort, sessions,
sortCol,
sortDir,
onSort,
}: { }: {
sessions: AnySession[]; sessions: AnySession[];
sortCol: SortCol; sortCol: SortCol;
@@ -228,7 +343,10 @@ function SortableTableView({
} }
return ( return (
<div className="rounded-xl border border-border overflow-hidden overflow-x-auto bg-card"> <div className="rounded-xl border border-border overflow-hidden overflow-x-auto bg-card">
<div className="grid bg-card-hover/60 border-b border-border" style={{ gridTemplateColumns: TABLE_COLS }}> <div
className="grid bg-card-hover/60 border-b border-border"
style={{ gridTemplateColumns: TABLE_COLS }}
>
{SORT_COLUMNS.map((col) => ( {SORT_COLUMNS.map((col) => (
<button <button
key={col.key} key={col.key}
@@ -258,20 +376,39 @@ function SortableTableView({
// ─── WorkshopTabs ───────────────────────────────────────────────────────────── // ─── WorkshopTabs ─────────────────────────────────────────────────────────────
export function WorkshopTabs({ export function WorkshopTabs({
swotSessions, motivatorSessions, yearReviewSessions, swotSessions,
weeklyCheckInSessions, weatherSessions, gifMoodSessions, motivatorSessions,
yearReviewSessions,
weeklyCheckInSessions,
weatherSessions,
gifMoodSessions,
teamCollabSessions = [], teamCollabSessions = [],
}: WorkshopTabsProps) { }: WorkshopTabsProps) {
const CARD_VIEW_STORAGE_KEY = 'sessions:cardView';
const isCardView = (value: string): value is CardView =>
value === 'grid' || value === 'list' || value === 'table' || value === 'timeline';
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false); const [typeDropdownOpen, setTypeDropdownOpen] = useState(false);
const [cardView, setCardView] = useState<CardView>('grid'); const [cardView, setCardView] = useState<CardView>(() => {
if (typeof window === 'undefined') return 'grid';
const storedView = localStorage.getItem(CARD_VIEW_STORAGE_KEY);
return storedView && isCardView(storedView) ? storedView : 'grid';
});
const [sortCol, setSortCol] = useState<SortCol>('date'); const [sortCol, setSortCol] = useState<SortCol>('date');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
useEffect(() => {
localStorage.setItem(CARD_VIEW_STORAGE_KEY, cardView);
}, [cardView]);
const handleSort = (col: SortCol) => { const handleSort = (col: SortCol) => {
if (sortCol === col) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); if (sortCol === col) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
else { setSortCol(col); setSortDir('asc'); } else {
setSortCol(col);
setSortDir('asc');
}
}; };
const tabParam = searchParams.get('tab'); const tabParam = searchParams.get('tab');
@@ -288,18 +425,29 @@ export function WorkshopTabs({
}; };
const allSessions: AnySession[] = [ const allSessions: AnySession[] = [
...swotSessions, ...motivatorSessions, ...yearReviewSessions, ...swotSessions,
...weeklyCheckInSessions, ...weatherSessions, ...gifMoodSessions, ...motivatorSessions,
...yearReviewSessions,
...weeklyCheckInSessions,
...weatherSessions,
...gifMoodSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); ].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const filteredSessions: AnySession[] = const filteredSessions: AnySession[] =
activeTab === 'all' || activeTab === 'byPerson' ? allSessions activeTab === 'all' || activeTab === 'byPerson'
: activeTab === 'team' ? teamCollabSessions ? allSessions
: activeTab === 'swot' ? swotSessions : activeTab === 'team'
: activeTab === 'motivators' ? motivatorSessions ? teamCollabSessions
: activeTab === 'year-review' ? yearReviewSessions : activeTab === 'swot'
: activeTab === 'weekly-checkin' ? weeklyCheckInSessions ? swotSessions
: activeTab === 'gif-mood' ? gifMoodSessions : activeTab === 'motivators'
? motivatorSessions
: activeTab === 'year-review'
? yearReviewSessions
: activeTab === 'weekly-checkin'
? weeklyCheckInSessions
: activeTab === 'gif-mood'
? gifMoodSessions
: weatherSessions; : weatherSessions;
const ownedSessions = filteredSessions.filter((s) => s.isOwner); const ownedSessions = filteredSessions.filter((s) => s.isOwner);
@@ -309,11 +457,14 @@ export function WorkshopTabs({
const teamCollabFiltered = activeTab === 'all' ? teamCollabSessions : []; const teamCollabFiltered = activeTab === 'all' ? teamCollabSessions : [];
const sessionsByPerson = groupByPerson(allSessions); const sessionsByPerson = groupByPerson(allSessions);
const sortedPersons = Array.from(sessionsByPerson.entries()).sort((a, b) => a[0].localeCompare(b[0], 'fr')); const sortedPersons = Array.from(sessionsByPerson.entries()).sort((a, b) =>
a[0].localeCompare(b[0], 'fr')
);
// Timeline grouping // Timeline grouping
const timelineSessions = [...(activeTab === 'all' ? [...filteredSessions, ...teamCollabSessions] : filteredSessions)] const timelineSessions = [
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); ...(activeTab === 'all' ? [...filteredSessions, ...teamCollabSessions] : filteredSessions),
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const byMonth = new Map<string, AnySession[]>(); const byMonth = new Map<string, AnySession[]>();
timelineSessions.forEach((s) => { timelineSessions.forEach((s) => {
const key = getMonthGroup(s.updatedAt); const key = getMonthGroup(s.updatedAt);
@@ -326,7 +477,8 @@ export function WorkshopTabs({
cardView === 'table' && activeTab !== 'byPerson' cardView === 'table' && activeTab !== 'byPerson'
? sortSessions( ? sortSessions(
activeTab === 'all' ? [...filteredSessions, ...teamCollabSessions] : filteredSessions, activeTab === 'all' ? [...filteredSessions, ...teamCollabSessions] : filteredSessions,
sortCol, sortDir, sortCol,
sortDir
) )
: []; : [];
@@ -334,19 +486,42 @@ export function WorkshopTabs({
<div className="space-y-8"> <div className="space-y-8">
{/* Tabs + vue toggle */} {/* Tabs + vue toggle */}
<div className="flex gap-1.5 items-center flex-wrap"> <div className="flex gap-1.5 items-center flex-wrap">
<TabButton active={activeTab === 'all'} onClick={() => setActiveTab('all')} icon="📋" label="Tous" count={allSessions.length} /> <TabButton
<TabButton active={activeTab === 'byPerson'} onClick={() => setActiveTab('byPerson')} icon="👥" label="Par personne" count={sessionsByPerson.size} /> active={activeTab === 'all'}
onClick={() => setActiveTab('all')}
icon="📋"
label="Tous"
count={allSessions.length}
/>
<TabButton
active={activeTab === 'byPerson'}
onClick={() => setActiveTab('byPerson')}
icon="👥"
label="Par personne"
count={sessionsByPerson.size}
/>
{teamCollabSessions.length > 0 && ( {teamCollabSessions.length > 0 && (
<TabButton active={activeTab === 'team'} onClick={() => setActiveTab('team')} icon="🏢" label="Équipe" count={teamCollabSessions.length} /> <TabButton
active={activeTab === 'team'}
onClick={() => setActiveTab('team')}
icon="🏢"
label="Équipe"
count={teamCollabSessions.length}
/>
)} )}
<div className="h-5 w-px bg-border mx-0.5 self-center" /> <div className="h-5 w-px bg-border mx-0.5 self-center" />
<TypeFilterDropdown <TypeFilterDropdown
activeTab={activeTab} setActiveTab={setActiveTab} activeTab={activeTab}
open={typeDropdownOpen} onOpenChange={setTypeDropdownOpen} setActiveTab={setActiveTab}
open={typeDropdownOpen}
onOpenChange={setTypeDropdownOpen}
counts={{ counts={{
swot: swotSessions.length, motivators: motivatorSessions.length, swot: swotSessions.length,
'year-review': yearReviewSessions.length, 'weekly-checkin': weeklyCheckInSessions.length, motivators: motivatorSessions.length,
weather: weatherSessions.length, 'gif-mood': gifMoodSessions.length, 'year-review': yearReviewSessions.length,
'weekly-checkin': weeklyCheckInSessions.length,
weather: weatherSessions.length,
'gif-mood': gifMoodSessions.length,
team: teamCollabSessions.length, team: teamCollabSessions.length,
}} }}
/> />
@@ -355,8 +530,12 @@ export function WorkshopTabs({
{/* ── Vue Tableau flat (colonnes triables) ──────────────────── */} {/* ── Vue Tableau flat (colonnes triables) ──────────────────── */}
{cardView === 'table' && activeTab !== 'byPerson' ? ( {cardView === 'table' && activeTab !== 'byPerson' ? (
<SortableTableView sessions={flatTableSessions} sortCol={sortCol} sortDir={sortDir} onSort={handleSort} /> <SortableTableView
sessions={flatTableSessions}
sortCol={sortCol}
sortDir={sortDir}
onSort={handleSort}
/>
) : cardView === 'timeline' && activeTab !== 'byPerson' ? ( ) : cardView === 'timeline' && activeTab !== 'byPerson' ? (
/* ── Vue Timeline ────────────────────────────────────────── */ /* ── Vue Timeline ────────────────────────────────────────── */
byMonth.size === 0 ? ( byMonth.size === 0 ? (
@@ -367,19 +546,25 @@ export function WorkshopTabs({
<section key={period}> <section key={period}>
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="h-px flex-1 bg-border" /> <div className="h-px flex-1 bg-border" />
<span className="text-xs font-semibold text-muted uppercase tracking-widest px-2 capitalize">{period}</span> <span className="text-xs font-semibold text-muted uppercase tracking-widest px-2 capitalize">
{period}
</span>
<div className="h-px flex-1 bg-border" /> <div className="h-px flex-1 bg-border" />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{sessions.map((s) => ( {sessions.map((s) => (
<SessionCard key={s.id} session={s} isTeamCollab={(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab} view="list" /> <SessionCard
key={s.id}
session={s}
isTeamCollab={(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab}
view="list"
/>
))} ))}
</div> </div>
</section> </section>
))} ))}
</div> </div>
) )
) : activeTab === 'byPerson' ? ( ) : activeTab === 'byPerson' ? (
/* ── Vue Par personne ───────────────────────────────────── */ /* ── Vue Par personne ───────────────────────────────────── */
sortedPersons.length === 0 ? ( sortedPersons.length === 0 ? (
@@ -396,21 +581,28 @@ export function WorkshopTabs({
{sessions.length} atelier{sessions.length > 1 ? 's' : ''} {sessions.length} atelier{sessions.length > 1 ? 's' : ''}
</span> </span>
</div> </div>
<SessionsGrid sessions={sessions} view={cardView === 'timeline' ? 'list' : cardView} /> <SessionsGrid
sessions={sessions}
view={cardView === 'timeline' ? 'list' : cardView}
/>
</section> </section>
); );
})} })}
</div> </div>
) )
) : activeTab === 'team' ? ( ) : activeTab === 'team' ? (
/* ── Vue Équipe ─────────────────────────────────────────── */ /* ── Vue Équipe ─────────────────────────────────────────── */
teamCollabSessions.length === 0 ? ( teamCollabSessions.length === 0 ? (
<div className="text-center py-12 text-muted">Aucun atelier de vos collaborateurs (non partagés)</div> <div className="text-center py-12 text-muted">
Aucun atelier de vos collaborateurs (non partagés)
</div>
) : ( ) : (
<div className="space-y-8"> <div className="space-y-8">
<section> <section>
<SectionHeader label="Ateliers de l'équipe non partagés" count={teamCollabSessions.length} /> <SectionHeader
label="Ateliers de l'équipe non partagés"
count={teamCollabSessions.length}
/>
<p className="text-sm text-muted mb-5 -mt-2"> <p className="text-sm text-muted mb-5 -mt-2">
En tant qu&apos;admin d&apos;équipe, vous voyez les ateliers de vos collaborateurs En tant qu&apos;admin d&apos;équipe, vous voyez les ateliers de vos collaborateurs
qui ne vous sont pas encore partagés. qui ne vous sont pas encore partagés.
@@ -419,10 +611,8 @@ export function WorkshopTabs({
</section> </section>
</div> </div>
) )
) : filteredSessions.length === 0 ? ( ) : filteredSessions.length === 0 ? (
<div className="text-center py-12 text-muted">Aucun atelier de ce type pour le moment</div> <div className="text-center py-12 text-muted">Aucun atelier de ce type pour le moment</div>
) : ( ) : (
/* ── Vue normale (tous / par type) ─────────────────────── */ /* ── Vue normale (tous / par type) ─────────────────────── */
<div className="space-y-10"> <div className="space-y-10">

View File

@@ -1,9 +1,31 @@
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getAllUsersWithStats } from '@/services/auth'; import { getAllUsersWithStats, type UserStats } from '@/services/auth';
import { getGravatarUrl } from '@/lib/gravatar'; import { getGravatarUrl } from '@/lib/gravatar';
import { PageHeader } from '@/components/ui'; import { PageHeader } from '@/components/ui';
const OWNED_WORKSHOP_COUNT_KEYS = [
'sessions',
'motivatorSessions',
'yearReviewSessions',
'weeklyCheckInSessions',
'weatherSessions',
'gifMoodSessions',
] as const satisfies readonly (keyof UserStats)[];
const SHARED_WORKSHOP_COUNT_KEYS = [
'sharedSessions',
'sharedMotivatorSessions',
'sharedYearReviewSessions',
'sharedWeeklyCheckInSessions',
'sharedWeatherSessions',
'sharedGifMoodSessions',
] as const satisfies readonly (keyof UserStats)[];
function sumCountKeys(counts: UserStats, keys: readonly (keyof UserStats)[]): number {
return keys.reduce((acc, key) => acc + (counts[key] ?? 0), 0);
}
function formatRelativeTime(date: Date): string { function formatRelativeTime(date: Date): string {
const now = new Date(); const now = new Date();
const diffMs = now.getTime() - date.getTime(); const diffMs = now.getTime() - date.getTime();
@@ -28,7 +50,11 @@ export default async function UsersPage() {
// Calculate some global stats // Calculate some global stats
const totalSessions = users.reduce( const totalSessions = users.reduce(
(acc, u) => acc + u._count.sessions + u._count.motivatorSessions, (acc, u) => acc + sumCountKeys(u._count, OWNED_WORKSHOP_COUNT_KEYS),
0
);
const totalSharedSessions = users.reduce(
(acc, u) => acc + sumCountKeys(u._count, SHARED_WORKSHOP_COUNT_KEYS),
0 0
); );
const avgSessionsPerUser = users.length > 0 ? totalSessions / users.length : 0; const avgSessionsPerUser = users.length > 0 ? totalSessions / users.length : 0;
@@ -56,12 +82,7 @@ export default async function UsersPage() {
<div className="text-sm text-muted">Moy. par user</div> <div className="text-sm text-muted">Moy. par user</div>
</div> </div>
<div className="rounded-xl border border-border bg-card p-4"> <div className="rounded-xl border border-border bg-card p-4">
<div className="text-2xl font-bold text-accent"> <div className="text-2xl font-bold text-accent">{totalSharedSessions}</div>
{users.reduce(
(acc, u) => acc + u._count.sharedSessions + u._count.sharedMotivatorSessions,
0
)}
</div>
<div className="text-sm text-muted">Partages actifs</div> <div className="text-sm text-muted">Partages actifs</div>
</div> </div>
</div> </div>
@@ -69,8 +90,8 @@ export default async function UsersPage() {
{/* Users List */} {/* Users List */}
<div className="space-y-3"> <div className="space-y-3">
{users.map((user) => { {users.map((user) => {
const totalUserSessions = user._count.sessions + user._count.motivatorSessions; const totalUserSessions = sumCountKeys(user._count, OWNED_WORKSHOP_COUNT_KEYS);
const totalShares = user._count.sharedSessions + user._count.sharedMotivatorSessions; const totalShares = sumCountKeys(user._count, SHARED_WORKSHOP_COUNT_KEYS);
const isCurrentUser = user.id === session.user?.id; const isCurrentUser = user.id === session.user?.id;
return ( return (
@@ -115,7 +136,7 @@ export default async function UsersPage() {
backgroundColor: 'color-mix(in srgb, var(--strength) 15%, transparent)', backgroundColor: 'color-mix(in srgb, var(--strength) 15%, transparent)',
color: 'var(--strength)', color: 'var(--strength)',
}} }}
title="Sessions SWOT" title="Ateliers créés (tous types)"
> >
<svg <svg
className="h-3.5 w-3.5" className="h-3.5 w-3.5"
@@ -130,30 +151,7 @@ export default async function UsersPage() {
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" 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> </svg>
{user._count.sessions} {totalUserSessions}
</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> </div>
{totalShares > 0 && ( {totalShares > 0 && (
<div <div

View File

@@ -194,8 +194,16 @@ export async function updateUserPassword(
export interface UserStats { export interface UserStats {
sessions: number; sessions: number;
motivatorSessions: number; motivatorSessions: number;
yearReviewSessions: number;
weeklyCheckInSessions: number;
weatherSessions: number;
gifMoodSessions: number;
sharedSessions: number; sharedSessions: number;
sharedMotivatorSessions: number; sharedMotivatorSessions: number;
sharedYearReviewSessions: number;
sharedWeeklyCheckInSessions: number;
sharedWeatherSessions: number;
sharedGifMoodSessions: number;
} }
export interface UserWithStats { export interface UserWithStats {
@@ -208,7 +216,7 @@ export interface UserWithStats {
} }
export async function getAllUsersWithStats(): Promise<UserWithStats[]> { export async function getAllUsersWithStats(): Promise<UserWithStats[]> {
const users = await prisma.user.findMany({ return prisma.user.findMany({
select: { select: {
id: true, id: true,
email: true, email: true,
@@ -218,32 +226,20 @@ export async function getAllUsersWithStats(): Promise<UserWithStats[]> {
_count: { _count: {
select: { select: {
sessions: true, sessions: true,
motivatorSessions: true,
yearReviewSessions: true,
weeklyCheckInSessions: true,
weatherSessions: true,
gifMoodSessions: true,
sharedSessions: true, sharedSessions: true,
sharedMotivatorSessions: true,
sharedYearReviewSessions: true,
sharedWeeklyCheckInSessions: true,
sharedWeatherSessions: true,
sharedGifMoodSessions: true,
}, },
}, },
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
// Get motivator counts in bulk (2 queries instead of 2*N)
const motivatorCounts = await prisma.movingMotivatorsSession.groupBy({
by: ['userId'],
_count: { id: true },
});
const sharedMotivatorCounts = await prisma.mMSessionShare.groupBy({
by: ['userId'],
_count: { id: true },
});
const motivatorMap = new Map(motivatorCounts.map((m) => [m.userId, m._count.id]));
const sharedMotivatorMap = new Map(sharedMotivatorCounts.map((m) => [m.userId, m._count.id]));
return users.map((user) => ({
...user,
_count: {
...user._count,
motivatorSessions: motivatorMap.get(user.id) ?? 0,
sharedMotivatorSessions: sharedMotivatorMap.get(user.id) ?? 0,
},
}));
} }