feat: redesign sessions dashboard with multi-view layout and sortable table
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m17s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m17s
- Redesign session cards with colored left border (Figma-style), improved visual hierarchy, hover states, and stats in footer - Add 4 switchable view modes: grid, list, sortable table, and timeline - Table view: unified flat table with clickable column headers for sorting (Type, Titre, Créateur, Participant, Stats, Date) - Add Créateur column showing the workshop owner with Gravatar avatar - Widen Type column to 160px for better readability - Improve tabs navigation with pill-shaped active state and shadow - Fix TypeFilterDropdown to exclude 'Équipe' from type list - Make filter tabs visually distinct with bg-card + border + shadow-sm - Split WorkshopTabs.tsx into 4 focused modules: workshop-session-types.ts, workshop-session-helpers.ts, SessionCard.tsx, WorkshopTabs.tsx Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,14 +15,17 @@ export function NewWorkshopDropdown() {
|
||||
<div ref={containerRef} className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Nouvel atelier
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -31,15 +34,20 @@ export function NewWorkshopDropdown() {
|
||||
</svg>
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="absolute right-0 z-20 mt-2 w-56 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
<div className="absolute right-0 z-20 mt-2 w-60 rounded-xl border border-border bg-card py-1.5 shadow-lg">
|
||||
{WORKSHOPS.map((w) => (
|
||||
<Link
|
||||
key={w.id}
|
||||
href={w.newPath}
|
||||
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover"
|
||||
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover transition-colors"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<span className="text-lg">{w.icon}</span>
|
||||
<span
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-base flex-shrink-0"
|
||||
style={{ backgroundColor: `${w.accentColor}18` }}
|
||||
>
|
||||
{w.icon}
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-medium">{w.label}</div>
|
||||
<div className="text-xs text-muted">{w.description}</div>
|
||||
|
||||
282
src/app/sessions/SessionCard.tsx
Normal file
282
src/app/sessions/SessionCard.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button, Modal, ModalFooter, Input, CollaboratorDisplay } from '@/components/ui';
|
||||
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
|
||||
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
|
||||
import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review';
|
||||
import { deleteWeeklyCheckInSession, updateWeeklyCheckInSession } from '@/actions/weekly-checkin';
|
||||
import { deleteWeatherSession, updateWeatherSession } from '@/actions/weather';
|
||||
import { deleteGifMoodSession, updateGifMoodSession } from '@/actions/gif-mood';
|
||||
import { type WorkshopTypeId, getWorkshop, getSessionPath } from '@/lib/workshops';
|
||||
import type { Share } from '@/lib/share-utils';
|
||||
import type {
|
||||
AnySession, CardView,
|
||||
SwotSession, MotivatorSession, YearReviewSession,
|
||||
WeeklyCheckInSession, WeatherSession, GifMoodSession,
|
||||
} from './workshop-session-types';
|
||||
import { TABLE_COLS } from './workshop-session-types';
|
||||
import { getResolvedCollaborator, formatDate, getStatsText } from './workshop-session-helpers';
|
||||
|
||||
// ─── RoleBadge ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function RoleBadge({ role }: { role: 'OWNER' | 'VIEWER' | 'EDITOR' }) {
|
||||
return (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded font-medium flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: role === 'EDITOR' ? 'rgba(6,182,212,0.12)' : 'rgba(234,179,8,0.12)',
|
||||
color: role === 'EDITOR' ? '#06b6d4' : '#ca8a04',
|
||||
}}
|
||||
>
|
||||
{role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── SharesList ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function SharesList({ shares }: { shares: Share[] }) {
|
||||
if (!shares.length) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[10px] text-muted">Partagé avec</span>
|
||||
{shares.slice(0, 3).map((s) => (
|
||||
<span key={s.id} className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
|
||||
{s.user.name?.split(' ')[0] || s.user.email.split('@')[0]}
|
||||
</span>
|
||||
))}
|
||||
{shares.length > 3 && <span className="text-[10px] text-muted">+{shares.length - 3}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── SessionCard ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function SessionCard({
|
||||
session, isTeamCollab = false, view = 'grid',
|
||||
}: {
|
||||
session: AnySession; isTeamCollab?: boolean; view?: CardView;
|
||||
}) {
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const isSwot = session.workshopType === 'swot';
|
||||
const isYearReview = session.workshopType === 'year-review';
|
||||
const isWeeklyCheckIn = session.workshopType === 'weekly-checkin';
|
||||
const isWeather = session.workshopType === 'weather';
|
||||
const isGifMood = session.workshopType === 'gif-mood';
|
||||
|
||||
const participant = isSwot ? (session as SwotSession).collaborator
|
||||
: isYearReview ? (session as YearReviewSession).participant
|
||||
: isWeeklyCheckIn ? (session as WeeklyCheckInSession).participant
|
||||
: isWeather ? (session as WeatherSession).user.name || (session as WeatherSession).user.email
|
||||
: isGifMood ? (session as GifMoodSession).user.name || (session as GifMoodSession).user.email
|
||||
: (session as MotivatorSession).participant;
|
||||
|
||||
const [editTitle, setEditTitle] = useState(session.title);
|
||||
const [editParticipant, setEditParticipant] = useState(
|
||||
isSwot ? (session as SwotSession).collaborator
|
||||
: isYearReview ? (session as YearReviewSession).participant
|
||||
: isWeather || isGifMood ? ''
|
||||
: (session as MotivatorSession).participant
|
||||
);
|
||||
|
||||
const workshop = getWorkshop(session.workshopType as WorkshopTypeId);
|
||||
const href = getSessionPath(session.workshopType as WorkshopTypeId, session.id);
|
||||
const accentColor = workshop.accentColor;
|
||||
const resolved = getResolvedCollaborator(session);
|
||||
const participantName = resolved.matchedUser?.name || resolved.matchedUser?.email?.split('@')[0] || resolved.raw;
|
||||
const statsText = getStatsText(session);
|
||||
|
||||
const handleDelete = () => {
|
||||
startTransition(async () => {
|
||||
const result = isSwot ? await deleteSwotSession(session.id)
|
||||
: isYearReview ? await deleteYearReviewSession(session.id)
|
||||
: isWeeklyCheckIn ? await deleteWeeklyCheckInSession(session.id)
|
||||
: isWeather ? await deleteWeatherSession(session.id)
|
||||
: isGifMood ? await deleteGifMoodSession(session.id)
|
||||
: await deleteMotivatorSession(session.id);
|
||||
if (result.success) setShowDeleteModal(false);
|
||||
else console.error('Error deleting:', result.error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
startTransition(async () => {
|
||||
const result = isSwot ? await updateSwotSession(session.id, { title: editTitle, collaborator: editParticipant })
|
||||
: isYearReview ? await updateYearReviewSession(session.id, { title: editTitle, participant: editParticipant })
|
||||
: isWeeklyCheckIn ? await updateWeeklyCheckInSession(session.id, { title: editTitle, participant: editParticipant })
|
||||
: isWeather ? await updateWeatherSession(session.id, { title: editTitle })
|
||||
: isGifMood ? await updateGifMoodSession(session.id, { title: editTitle })
|
||||
: await updateMotivatorSession(session.id, { title: editTitle, participant: editParticipant });
|
||||
if (result.success) setShowEditModal(false);
|
||||
else console.error('Error updating:', result.error);
|
||||
});
|
||||
};
|
||||
|
||||
const openEditModal = () => { setEditTitle(session.title); setEditParticipant(participant); setShowEditModal(true); };
|
||||
const hoverCard = !isTeamCollab ? 'hover:-translate-y-0.5 hover:shadow-md' : '';
|
||||
const opacity = isTeamCollab ? 'opacity-60' : '';
|
||||
|
||||
// ── Vue Grille ───────────────────────────────────────────────────────────
|
||||
const gridCard = (
|
||||
<div className={`h-full flex rounded-xl bg-card border border-border overflow-hidden transition-all duration-200 ${hoverCard} ${opacity}`}>
|
||||
<div className="w-1 flex-shrink-0" style={{ backgroundColor: accentColor }} />
|
||||
<div className="flex flex-col flex-1 px-4 py-4 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 className="font-semibold text-foreground line-clamp-2 leading-snug text-[15px] flex-1">{session.title}</h3>
|
||||
{!session.isOwner && <RoleBadge role={session.role} />}
|
||||
</div>
|
||||
<CollaboratorDisplay collaborator={resolved} size="sm" />
|
||||
{!session.isOwner && <p className="text-xs text-muted mt-0.5 truncate">par {session.user.name || session.user.email}</p>}
|
||||
{session.isOwner && session.shares.length > 0 && <div className="mt-2"><SharesList shares={session.shares} /></div>}
|
||||
<div className="flex-1 min-h-4" />
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border text-xs text-muted">
|
||||
<div className="flex items-center gap-1.5 min-w-0 truncate">
|
||||
<span className="text-sm leading-none flex-shrink-0">{workshop.icon}</span>
|
||||
<span className="font-medium flex-shrink-0" style={{ color: accentColor }}>{workshop.labelShort}</span>
|
||||
<span className="opacity-30 flex-shrink-0">·</span>
|
||||
<span className="truncate">{statsText}</span>
|
||||
</div>
|
||||
<span className="text-[11px] whitespace-nowrap ml-3 flex-shrink-0">{formatDate(session.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Vue Liste ────────────────────────────────────────────────────────────
|
||||
const listCard = (
|
||||
<div className={`flex items-center gap-3 rounded-xl bg-card border border-border overflow-hidden transition-all duration-150 ${!isTeamCollab ? 'hover:shadow-sm' : ''} ${opacity} px-4 py-3`}>
|
||||
<div className="w-0.5 self-stretch rounded-full flex-shrink-0" style={{ backgroundColor: accentColor }} />
|
||||
<span className="text-lg leading-none flex-shrink-0">{workshop.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="font-semibold text-foreground text-sm truncate">{session.title}</span>
|
||||
{!session.isOwner && <RoleBadge role={session.role} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted flex-wrap">
|
||||
<span className="truncate max-w-[140px]">{participantName}</span>
|
||||
<span className="opacity-30">·</span>
|
||||
<span className="font-medium flex-shrink-0" style={{ color: accentColor }}>{workshop.labelShort}</span>
|
||||
<span className="opacity-30">·</span>
|
||||
<span className="whitespace-nowrap">{statsText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted whitespace-nowrap flex-shrink-0 ml-2">{formatDate(session.updatedAt)}</span>
|
||||
<svg className="w-3.5 h-3.5 text-muted/40 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Vue Tableau ──────────────────────────────────────────────────────────
|
||||
const tableRow = (
|
||||
<div
|
||||
className={`grid border-b border-border last:border-0 transition-colors ${!isTeamCollab ? 'hover:bg-card-hover/60' : ''} ${opacity}`}
|
||||
style={{ gridTemplateColumns: TABLE_COLS }}
|
||||
>
|
||||
<div className="px-4 py-3 flex items-center gap-2">
|
||||
<div className="w-0.5 h-5 rounded-full flex-shrink-0" style={{ backgroundColor: accentColor }} />
|
||||
<span className="text-base leading-none">{workshop.icon}</span>
|
||||
<span className="text-xs font-semibold truncate" style={{ color: accentColor }}>{workshop.labelShort}</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center gap-2 min-w-0">
|
||||
<span className="font-medium text-foreground text-sm truncate">{session.title}</span>
|
||||
{!session.isOwner && <RoleBadge role={session.role} />}
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center">
|
||||
<CollaboratorDisplay
|
||||
collaborator={{ raw: session.user.name || session.user.email, matchedUser: { id: session.user.id, email: session.user.email, name: session.user.name } }}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center">
|
||||
<CollaboratorDisplay collaborator={resolved} size="sm" />
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center text-xs text-muted">
|
||||
<span className="truncate">{statsText}</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center text-xs text-muted whitespace-nowrap">
|
||||
{formatDate(session.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const cardContent = view === 'list' ? listCard : view === 'table' ? tableRow : gridCard;
|
||||
|
||||
const actionButtons = (
|
||||
<>
|
||||
{(session.isOwner || session.role === 'EDITOR' || session.isTeamCollab) && (
|
||||
<div className={`absolute flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-20 ${view === 'table' ? 'top-1/2 -translate-y-1/2 right-3' : 'top-2.5 right-2.5'}`}>
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); openEditModal(); }}
|
||||
className="p-1.5 rounded-lg bg-card border border-border text-muted hover:text-primary hover:bg-primary/5 shadow-sm"
|
||||
title="Modifier"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
{(session.isOwner || session.isTeamCollab) && (
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setShowDeleteModal(true); }}
|
||||
className="p-1.5 rounded-lg bg-card border border-border text-muted hover:text-destructive hover:bg-destructive/5 shadow-sm"
|
||||
title="Supprimer"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative group">
|
||||
<Link href={href} className={view === 'table' ? 'block' : undefined} title={isTeamCollab ? "Atelier de l'équipe" : undefined}>
|
||||
{cardContent}
|
||||
</Link>
|
||||
{actionButtons}
|
||||
</div>
|
||||
|
||||
<Modal isOpen={showEditModal} onClose={() => setShowEditModal(false)} title="Modifier l'atelier" size="sm">
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleEdit(); }} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="edit-title" className="block text-sm font-medium text-foreground mb-1">Titre</label>
|
||||
<Input id="edit-title" value={editTitle} onChange={(e) => setEditTitle(e.target.value)} placeholder="Titre de l'atelier" required />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="edit-participant" className="block text-sm font-medium text-foreground mb-1">{workshop.participantLabel}</label>
|
||||
{!isWeather && !isGifMood && (
|
||||
<Input id="edit-participant" value={editParticipant} onChange={(e) => setEditParticipant(e.target.value)}
|
||||
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'} required />
|
||||
)}
|
||||
</div>
|
||||
<ModalFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setShowEditModal(false)} disabled={isPending}>Annuler</Button>
|
||||
<Button type="submit" disabled={isPending || !editTitle.trim() || (!isWeather && !isGifMood && !editParticipant.trim())}>
|
||||
{isPending ? 'Enregistrement...' : 'Enregistrer'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={showDeleteModal} onClose={() => setShowDeleteModal(false)} title="Supprimer l'atelier" size="sm">
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted">Êtes-vous sûr de vouloir supprimer <strong className="text-foreground">"{session.title}"</strong> ?</p>
|
||||
<p className="text-sm text-destructive">Cette action est irréversible. Toutes les données seront perdues.</p>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" onClick={() => setShowDeleteModal(false)} disabled={isPending}>Annuler</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={isPending}>{isPending ? 'Suppression...' : 'Supprimer'}</Button>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,15 +33,15 @@ function WorkshopTabsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Tabs skeleton */}
|
||||
<div className="flex gap-2 border-b border-border pb-4">
|
||||
<div className="flex gap-2 pb-2">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-10 w-32 bg-card animate-pulse rounded-lg" />
|
||||
<div key={i} className="h-9 w-28 bg-card animate-pulse rounded-full" />
|
||||
))}
|
||||
</div>
|
||||
{/* Cards skeleton */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="h-40 bg-card animate-pulse rounded-xl" />
|
||||
<div key={i} className="h-44 bg-card animate-pulse rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,14 +110,22 @@ export default async function SessionsPage() {
|
||||
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
|
||||
const hasNoSessions = allSessions.length === 0;
|
||||
const totalCount = allSessions.length;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="mb-10 flex flex-col sm:flex-row sm:items-end justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Mes Ateliers</h1>
|
||||
<p className="mt-1 text-muted">Tous vos ateliers en un seul endroit</p>
|
||||
<div className="flex items-center gap-3 mb-1.5">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">Mes Ateliers</h1>
|
||||
{totalCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center px-2.5 h-6 rounded-full bg-primary/10 text-primary text-sm font-semibold">
|
||||
{totalCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted">Tous vos ateliers en un seul endroit</p>
|
||||
</div>
|
||||
<NewWorkshopDropdown />
|
||||
</div>
|
||||
|
||||
93
src/app/sessions/workshop-session-helpers.ts
Normal file
93
src/app/sessions/workshop-session-helpers.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type {
|
||||
AnySession, SortCol, ResolvedCollaborator,
|
||||
SwotSession, MotivatorSession, YearReviewSession,
|
||||
WeeklyCheckInSession, WeatherSession, GifMoodSession,
|
||||
} from './workshop-session-types';
|
||||
|
||||
export function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
|
||||
if (session.workshopType === 'swot') return (session as SwotSession).resolvedCollaborator;
|
||||
if (session.workshopType === 'year-review') return (session as YearReviewSession).resolvedParticipant;
|
||||
if (session.workshopType === 'weekly-checkin') return (session as WeeklyCheckInSession).resolvedParticipant;
|
||||
if (session.workshopType === 'weather') {
|
||||
const s = session as WeatherSession;
|
||||
return { raw: s.user.name || s.user.email, matchedUser: { id: s.user.id, email: s.user.email, name: s.user.name } };
|
||||
}
|
||||
if (session.workshopType === 'gif-mood') {
|
||||
const s = session as GifMoodSession;
|
||||
return { raw: s.user.name || s.user.email, matchedUser: { id: s.user.id, email: s.user.email, name: s.user.name } };
|
||||
}
|
||||
return (session as MotivatorSession).resolvedParticipant;
|
||||
}
|
||||
|
||||
export function getGroupKey(session: AnySession): string {
|
||||
const r = getResolvedCollaborator(session);
|
||||
return r.matchedUser ? `user:${r.matchedUser.id}` : `raw:${r.raw.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function groupByPerson(sessions: AnySession[]): Map<string, AnySession[]> {
|
||||
const grouped = new Map<string, AnySession[]>();
|
||||
sessions.forEach((s) => {
|
||||
const key = getGroupKey(s);
|
||||
const existing = grouped.get(key);
|
||||
if (existing) existing.push(s);
|
||||
else grouped.set(key, [s]);
|
||||
});
|
||||
grouped.forEach((arr) => arr.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()));
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
return new Date(date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
export function getMonthGroup(date: Date | string): string {
|
||||
return new Date(date).toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function getStatsText(session: AnySession): string {
|
||||
const isSwot = session.workshopType === 'swot';
|
||||
const isYearReview = session.workshopType === 'year-review';
|
||||
const isWeeklyCheckIn = session.workshopType === 'weekly-checkin';
|
||||
const isWeather = session.workshopType === 'weather';
|
||||
const isGifMood = session.workshopType === 'gif-mood';
|
||||
|
||||
if (isSwot) return `${(session as SwotSession)._count.items} items · ${(session as SwotSession)._count.actions} actions`;
|
||||
if (isYearReview) return `${(session as YearReviewSession)._count.items} items · ${(session as YearReviewSession).year}`;
|
||||
if (isWeeklyCheckIn) return `${(session as WeeklyCheckInSession)._count.items} items · ${formatDate((session as WeeklyCheckInSession).date)}`;
|
||||
if (isWeather) return `${(session as WeatherSession)._count.entries} membres · ${formatDate((session as WeatherSession).date)}`;
|
||||
if (isGifMood) return `${(session as GifMoodSession)._count.items} GIFs · ${formatDate((session as GifMoodSession).date)}`;
|
||||
return `${(session as MotivatorSession)._count.cards}/10 motivateurs`;
|
||||
}
|
||||
|
||||
function getStatsSortValue(session: AnySession): number {
|
||||
if (session.workshopType === 'swot') return (session as SwotSession)._count.items;
|
||||
if (session.workshopType === 'year-review') return (session as YearReviewSession)._count.items;
|
||||
if (session.workshopType === 'weekly-checkin') return (session as WeeklyCheckInSession)._count.items;
|
||||
if (session.workshopType === 'weather') return (session as WeatherSession)._count.entries;
|
||||
if (session.workshopType === 'gif-mood') return (session as GifMoodSession)._count.items;
|
||||
return (session as MotivatorSession)._count.cards;
|
||||
}
|
||||
|
||||
function getParticipantSortName(session: AnySession): string {
|
||||
const r = getResolvedCollaborator(session);
|
||||
return (r.matchedUser?.name || r.matchedUser?.email?.split('@')[0] || r.raw).toLowerCase();
|
||||
}
|
||||
|
||||
function getCreatorName(session: AnySession): string {
|
||||
return (session.user.name || session.user.email).toLowerCase();
|
||||
}
|
||||
|
||||
export function sortSessions(sessions: AnySession[], col: SortCol, dir: 'asc' | 'desc'): AnySession[] {
|
||||
return [...sessions].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (col) {
|
||||
case 'type': cmp = a.workshopType.localeCompare(b.workshopType); break;
|
||||
case 'titre': cmp = a.title.localeCompare(b.title, 'fr'); break;
|
||||
case 'createur': cmp = getCreatorName(a).localeCompare(getCreatorName(b), 'fr'); break;
|
||||
case 'participant': cmp = getParticipantSortName(a).localeCompare(getParticipantSortName(b), 'fr'); break;
|
||||
case 'stats': cmp = getStatsSortValue(a) - getStatsSortValue(b); break;
|
||||
case 'date': cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(); break;
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
94
src/app/sessions/workshop-session-types.ts
Normal file
94
src/app/sessions/workshop-session-types.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { WORKSHOPS } from '@/lib/workshops';
|
||||
import type { Share } from '@/lib/share-utils';
|
||||
|
||||
export type CardView = 'grid' | 'list' | 'table' | 'timeline';
|
||||
export type SortCol = 'type' | 'titre' | 'createur' | 'participant' | 'stats' | 'date';
|
||||
|
||||
// Colonnes tableau : type | titre | créateur | participant | stats | date
|
||||
export const TABLE_COLS = '160px 1fr 160px 160px 160px 76px';
|
||||
|
||||
export const SORT_COLUMNS: { key: SortCol; label: string }[] = [
|
||||
{ key: 'type', label: 'Type' },
|
||||
{ key: 'titre', label: 'Titre' },
|
||||
{ key: 'createur', label: 'Créateur' },
|
||||
{ key: 'participant', label: 'Participant' },
|
||||
{ key: 'stats', label: 'Stats' },
|
||||
{ key: 'date', label: 'Date' },
|
||||
];
|
||||
|
||||
export const TYPE_TABS = [
|
||||
{ value: 'all' as const, icon: '📋', label: 'Tous' },
|
||||
{ value: 'team' as const, icon: '🏢', label: 'Équipe' },
|
||||
...WORKSHOPS.map((w) => ({ value: w.id, icon: w.icon, label: w.labelShort })),
|
||||
];
|
||||
|
||||
export interface ResolvedCollaborator {
|
||||
raw: string;
|
||||
matchedUser: { id: string; email: string; name: string | null } | null;
|
||||
}
|
||||
|
||||
export interface SwotSession {
|
||||
id: string; title: string; collaborator: string;
|
||||
resolvedCollaborator: ResolvedCollaborator; updatedAt: Date;
|
||||
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||
user: { id: string; name: string | null; email: string };
|
||||
shares: Share[]; _count: { items: number; actions: number };
|
||||
workshopType: 'swot'; isTeamCollab?: true; canEdit?: boolean;
|
||||
}
|
||||
|
||||
export interface MotivatorSession {
|
||||
id: string; title: string; participant: string;
|
||||
resolvedParticipant: ResolvedCollaborator; updatedAt: Date;
|
||||
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||
user: { id: string; name: string | null; email: string };
|
||||
shares: Share[]; _count: { cards: number };
|
||||
workshopType: 'motivators'; isTeamCollab?: true; canEdit?: boolean;
|
||||
}
|
||||
|
||||
export interface YearReviewSession {
|
||||
id: string; title: string; participant: string;
|
||||
resolvedParticipant: ResolvedCollaborator; year: number; updatedAt: Date;
|
||||
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||
user: { id: string; name: string | null; email: string };
|
||||
shares: Share[]; _count: { items: number };
|
||||
workshopType: 'year-review'; isTeamCollab?: true; canEdit?: boolean;
|
||||
}
|
||||
|
||||
export interface WeeklyCheckInSession {
|
||||
id: string; title: string; participant: string;
|
||||
resolvedParticipant: ResolvedCollaborator; date: Date; updatedAt: Date;
|
||||
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||
user: { id: string; name: string | null; email: string };
|
||||
shares: Share[]; _count: { items: number };
|
||||
workshopType: 'weekly-checkin'; isTeamCollab?: true; canEdit?: boolean;
|
||||
}
|
||||
|
||||
export interface WeatherSession {
|
||||
id: string; title: string; date: Date; updatedAt: Date;
|
||||
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||
user: { id: string; name: string | null; email: string };
|
||||
shares: Share[]; _count: { entries: number };
|
||||
workshopType: 'weather'; isTeamCollab?: true; canEdit?: boolean;
|
||||
}
|
||||
|
||||
export interface GifMoodSession {
|
||||
id: string; title: string; date: Date; updatedAt: Date;
|
||||
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||
user: { id: string; name: string | null; email: string };
|
||||
shares: Share[]; _count: { items: number };
|
||||
workshopType: 'gif-mood'; isTeamCollab?: true; canEdit?: boolean;
|
||||
}
|
||||
|
||||
export type AnySession =
|
||||
| SwotSession | MotivatorSession | YearReviewSession
|
||||
| WeeklyCheckInSession | WeatherSession | GifMoodSession;
|
||||
|
||||
export interface WorkshopTabsProps {
|
||||
swotSessions: SwotSession[];
|
||||
motivatorSessions: MotivatorSession[];
|
||||
yearReviewSessions: YearReviewSession[];
|
||||
weeklyCheckInSessions: WeeklyCheckInSession[];
|
||||
weatherSessions: WeatherSession[];
|
||||
gifMoodSessions: GifMoodSession[];
|
||||
teamCollabSessions?: (AnySession & { isTeamCollab?: true })[];
|
||||
}
|
||||
Reference in New Issue
Block a user