Files
workshop-manager/src/app/sessions/WorkshopTabs.tsx

642 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useRef, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { CollaboratorDisplay } from '@/components/ui';
import { type WorkshopTabType, VALID_TAB_PARAMS } from '@/lib/workshops';
import { useClickOutside } from '@/hooks/useClickOutside';
import {
type CardView,
type SortCol,
type WorkshopTabsProps,
type AnySession,
TABLE_COLS,
SORT_COLUMNS,
TYPE_TABS,
} from './workshop-session-types';
import {
getResolvedCollaborator,
groupByPerson,
getMonthGroup,
sortSessions,
} from './workshop-session-helpers';
import { SessionCard } from './SessionCard';
// ─── SectionHeader ────────────────────────────────────────────────────────────
function SectionHeader({ label, count }: { label: string; count: number }) {
return (
<div className="flex items-center gap-3 mb-5">
<div className="w-1 h-5 rounded-full bg-primary flex-shrink-0" />
<h2 className="text-sm font-semibold text-foreground">{label}</h2>
<span className="inline-flex items-center justify-center h-5 min-w-[20px] px-1.5 rounded-full bg-primary/10 text-primary text-[11px] font-semibold">
{count}
</span>
</div>
);
}
// ─── SortIcon ─────────────────────────────────────────────────────────────────
function SortIcon({ active, dir }: { active: boolean; dir: 'asc' | 'desc' }) {
if (!active) {
return (
<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 8.5L2 5H8L5 8.5Z" />
</svg>
);
}
if (dir === 'asc') {
return (
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor" className="flex-shrink-0">
<path d="M5 1.5L8 6H2L5 1.5Z" />
</svg>
);
}
return (
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor" className="flex-shrink-0">
<path d="M5 8.5L2 4H8L5 8.5Z" />
</svg>
);
}
// ─── ViewToggle ───────────────────────────────────────────────────────────────
function ViewToggle({ view, setView }: { view: CardView; setView: (v: CardView) => void }) {
const btn = (v: CardView, label: string, icon: React.ReactNode) => (
<button
key={v}
type="button"
title={label}
onClick={() => setView(v)}
className={`p-1.5 rounded transition-colors ${
view === v ? 'bg-primary text-primary-foreground' : 'text-muted hover:text-foreground'
}`}
>
{icon}
</button>
);
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">
{btn(
'grid',
'Grille',
<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="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>
)}
{btn(
'list',
'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>
)}
{btn(
'table',
'Tableau',
<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="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>
)}
{btn(
'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" />
<circle cx="3" cy="2.5" r="1.5" fill="currentColor" stroke="none" />
<line x1="5" y1="2.5" x2="14" y2="2.5" />
<circle cx="3" cy="7" r="1.5" fill="currentColor" stroke="none" />
<line x1="5" y1="7" x2="14" y2="7" />
<circle cx="3" cy="11.5" r="1.5" fill="currentColor" stroke="none" />
<line x1="5" y1="11.5" x2="14" y2="11.5" />
</svg>
)}
</div>
);
}
// ─── TabButton ────────────────────────────────────────────────────────────────
function TabButton({
active,
onClick,
icon,
label,
count,
}: {
active: boolean;
onClick: () => void;
icon: string;
label: string;
count: number;
}) {
return (
<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 ${
active
? 'bg-primary text-primary-foreground shadow-md'
: 'bg-card text-foreground/70 border border-border hover:text-foreground hover:bg-card-hover'
}`}
>
<span>{icon}</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'}`}
>
{count}
</span>
</button>
);
}
// ─── TypeFilterDropdown ───────────────────────────────────────────────────────
function TypeFilterDropdown({
activeTab,
setActiveTab,
open,
onOpenChange,
counts,
}: {
activeTab: WorkshopTabType;
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 current = TYPE_TABS.find((t) => t.value === activeTab) ?? TYPE_TABS[0];
const isTypeSelected = activeTab !== 'all' && activeTab !== 'byPerson' && activeTab !== 'team';
const totalCount = typeTabs.reduce((s, t) => s + (counts[t.value] ?? 0), 0);
const containerRef = useRef<HTMLDivElement>(null);
useClickOutside(containerRef, () => onOpenChange(false), open);
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => onOpenChange(!open)}
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 ${
isTypeSelected
? 'bg-primary text-primary-foreground shadow-md'
: 'bg-card text-foreground/70 border border-border hover:text-foreground hover:bg-card-hover'
}`}
>
<span>{isTypeSelected ? current.icon : '🔖'}</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'}`}
>
{isTypeSelected ? (counts[activeTab] ?? 0) : totalCount}
</span>
<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" />
</svg>
</button>
{open && (
<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);
}}
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>
{typeTabs.map((t) => (
<button
key={t.value}
type="button"
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>
))}
</div>
)}
</div>
);
}
// ─── SessionsGrid ─────────────────────────────────────────────────────────────
function SessionsGrid({
sessions,
view,
isTeamCollab = false,
}: {
sessions: AnySession[];
view: CardView;
isTeamCollab?: boolean;
}) {
if (view === 'table') {
return (
<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 }}
>
{SORT_COLUMNS.map((col) => (
<div key={col.key} className="px-4 py-2.5">
{col.label}
</div>
))}
</div>
{sessions.map((s) => (
<SessionCard key={s.id} session={s} isTeamCollab={isTeamCollab} view="table" />
))}
</div>
);
}
return (
<div
className={
view === 'list' ? 'flex flex-col gap-2' : 'grid gap-4 md:grid-cols-2 lg:grid-cols-3'
}
>
{sessions.map((s) => (
<SessionCard key={s.id} session={s} isTeamCollab={isTeamCollab} view={view} />
))}
</div>
);
}
// ─── SortableTableView ────────────────────────────────────────────────────────
function SortableTableView({
sessions,
sortCol,
sortDir,
onSort,
}: {
sessions: AnySession[];
sortCol: SortCol;
sortDir: 'asc' | 'desc';
onSort: (col: SortCol) => void;
}) {
if (sessions.length === 0) {
return <div className="text-center py-12 text-muted">Aucun atelier pour le moment</div>;
}
return (
<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 }}
>
{SORT_COLUMNS.map((col) => (
<button
key={col.key}
type="button"
onClick={() => onSort(col.key)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-left text-[11px] font-semibold uppercase tracking-wider transition-colors hover:text-foreground ${
sortCol === col.key ? 'text-primary' : 'text-muted'
}`}
>
{col.label}
<SortIcon active={sortCol === col.key} dir={sortDir} />
</button>
))}
</div>
{sessions.map((s) => (
<SessionCard
key={s.id}
session={s}
view="table"
isTeamCollab={(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab}
/>
))}
</div>
);
}
// ─── WorkshopTabs ─────────────────────────────────────────────────────────────
export function WorkshopTabs({
swotSessions,
motivatorSessions,
yearReviewSessions,
weeklyCheckInSessions,
weatherSessions,
gifMoodSessions,
teamCollabSessions = [],
}: 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 router = useRouter();
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false);
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 [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
useEffect(() => {
localStorage.setItem(CARD_VIEW_STORAGE_KEY, cardView);
}, [cardView]);
const handleSort = (col: SortCol) => {
if (sortCol === col) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
else {
setSortCol(col);
setSortDir('asc');
}
};
const tabParam = searchParams.get('tab');
const activeTab: WorkshopTabType =
tabParam && VALID_TAB_PARAMS.includes(tabParam as WorkshopTabType)
? (tabParam as WorkshopTabType)
: 'all';
const setActiveTab = (tab: WorkshopTabType) => {
const params = new URLSearchParams(searchParams.toString());
if (tab === 'all') params.delete('tab');
else params.set('tab', tab);
router.push(`/sessions${params.toString() ? `?${params.toString()}` : ''}`);
};
const allSessions: AnySession[] = [
...swotSessions,
...motivatorSessions,
...yearReviewSessions,
...weeklyCheckInSessions,
...weatherSessions,
...gifMoodSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const filteredSessions: AnySession[] =
activeTab === 'all' || activeTab === 'byPerson'
? allSessions
: activeTab === 'team'
? teamCollabSessions
: activeTab === 'swot'
? swotSessions
: activeTab === 'motivators'
? motivatorSessions
: activeTab === 'year-review'
? yearReviewSessions
: activeTab === 'weekly-checkin'
? weeklyCheckInSessions
: activeTab === 'gif-mood'
? gifMoodSessions
: weatherSessions;
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
const sharedSessions = filteredSessions.filter(
(s) => !s.isOwner && !(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab
);
const teamCollabFiltered = activeTab === 'all' ? teamCollabSessions : [];
const sessionsByPerson = groupByPerson(allSessions);
const sortedPersons = Array.from(sessionsByPerson.entries()).sort((a, b) =>
a[0].localeCompare(b[0], 'fr')
);
// Timeline grouping
const timelineSessions = [
...(activeTab === 'all' ? [...filteredSessions, ...teamCollabSessions] : filteredSessions),
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const byMonth = new Map<string, AnySession[]>();
timelineSessions.forEach((s) => {
const key = getMonthGroup(s.updatedAt);
if (!byMonth.has(key)) byMonth.set(key, []);
byMonth.get(key)!.push(s);
});
// Flat sorted sessions for the sortable table view
const flatTableSessions =
cardView === 'table' && activeTab !== 'byPerson'
? sortSessions(
activeTab === 'all' ? [...filteredSessions, ...teamCollabSessions] : filteredSessions,
sortCol,
sortDir
)
: [];
return (
<div className="space-y-8">
{/* Tabs + vue toggle */}
<div className="flex gap-1.5 items-center flex-wrap">
<TabButton
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 && (
<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" />
<TypeFilterDropdown
activeTab={activeTab}
setActiveTab={setActiveTab}
open={typeDropdownOpen}
onOpenChange={setTypeDropdownOpen}
counts={{
swot: swotSessions.length,
motivators: motivatorSessions.length,
'year-review': yearReviewSessions.length,
'weekly-checkin': weeklyCheckInSessions.length,
weather: weatherSessions.length,
'gif-mood': gifMoodSessions.length,
team: teamCollabSessions.length,
}}
/>
<ViewToggle view={cardView} setView={setCardView} />
</div>
{/* ── Vue Tableau flat (colonnes triables) ──────────────────── */}
{cardView === 'table' && activeTab !== 'byPerson' ? (
<SortableTableView
sessions={flatTableSessions}
sortCol={sortCol}
sortDir={sortDir}
onSort={handleSort}
/>
) : cardView === 'timeline' && activeTab !== 'byPerson' ? (
/* ── Vue Timeline ────────────────────────────────────────── */
byMonth.size === 0 ? (
<div className="text-center py-12 text-muted">Aucun atelier pour le moment</div>
) : (
<div className="space-y-8">
{Array.from(byMonth.entries()).map(([period, sessions]) => (
<section key={period}>
<div className="flex items-center gap-3 mb-4">
<div className="h-px flex-1 bg-border" />
<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>
<div className="flex flex-col gap-2">
{sessions.map((s) => (
<SessionCard
key={s.id}
session={s}
isTeamCollab={(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab}
view="list"
/>
))}
</div>
</section>
))}
</div>
)
) : activeTab === 'byPerson' ? (
/* ── Vue Par personne ───────────────────────────────────── */
sortedPersons.length === 0 ? (
<div className="text-center py-12 text-muted">Aucun atelier pour le moment</div>
) : (
<div className="space-y-8">
{sortedPersons.map(([personKey, sessions]) => {
const resolved = getResolvedCollaborator(sessions[0]);
return (
<section key={personKey}>
<div className="flex items-center gap-3 mb-5">
<CollaboratorDisplay collaborator={resolved} size="md" />
<span className="inline-flex items-center justify-center h-5 min-w-[20px] px-1.5 rounded-full bg-primary/10 text-primary text-[11px] font-semibold">
{sessions.length} atelier{sessions.length > 1 ? 's' : ''}
</span>
</div>
<SessionsGrid
sessions={sessions}
view={cardView === 'timeline' ? 'list' : cardView}
/>
</section>
);
})}
</div>
)
) : activeTab === 'team' ? (
/* ── Vue Équipe ─────────────────────────────────────────── */
teamCollabSessions.length === 0 ? (
<div className="text-center py-12 text-muted">
Aucun atelier de vos collaborateurs (non partagés)
</div>
) : (
<div className="space-y-8">
<section>
<SectionHeader
label="Ateliers de l'équipe non partagés"
count={teamCollabSessions.length}
/>
<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
qui ne vous sont pas encore partagés.
</p>
<SessionsGrid sessions={teamCollabSessions} view={cardView} isTeamCollab />
</section>
</div>
)
) : filteredSessions.length === 0 ? (
<div className="text-center py-12 text-muted">Aucun atelier de ce type pour le moment</div>
) : (
/* ── Vue normale (tous / par type) ─────────────────────── */
<div className="space-y-10">
{ownedSessions.length > 0 && (
<section>
<SectionHeader label="Mes ateliers" count={ownedSessions.length} />
<SessionsGrid sessions={ownedSessions} view={cardView} />
</section>
)}
{sharedSessions.length > 0 && (
<section>
<SectionHeader label="Partagés avec moi" count={sharedSessions.length} />
<SessionsGrid sessions={sharedSessions} view={cardView} />
</section>
)}
{activeTab === 'all' && teamCollabFiltered.length > 0 && (
<section>
<SectionHeader label="Équipe non partagés" count={teamCollabFiltered.length} />
<SessionsGrid sessions={teamCollabFiltered} view={cardView} isTeamCollab />
</section>
)}
</div>
)}
</div>
);
}