Files
workshop-manager/src/app/sessions/WorkshopTabs.tsx
Julien Froidefond cc7e73ce7b
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m0s
feat: refactor workshop management by centralizing workshop data and improving session navigation across components
2026-02-17 09:43:08 +01:00

815 lines
27 KiB
TypeScript

'use client';
import { useState, useTransition } from 'react';
import Link from 'next/link';
import { useSearchParams, useRouter } from 'next/navigation';
import {
Card,
Badge,
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 {
type WorkshopTabType,
type WorkshopTypeId,
WORKSHOPS,
VALID_TAB_PARAMS,
getWorkshop,
getSessionPath,
} from '@/lib/workshops';
const TYPE_TABS = [
{ value: 'all' as const, icon: '📋', label: 'Tous' },
...WORKSHOPS.map((w) => ({ value: w.id, icon: w.icon, label: w.labelShort })),
];
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: 'VIEWER' | 'EDITOR';
user: ShareUser;
}
interface ResolvedCollaborator {
raw: string;
matchedUser: {
id: string;
email: string;
name: string | null;
} | null;
}
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';
}
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';
}
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';
}
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';
}
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';
}
type AnySession = SwotSession | MotivatorSession | YearReviewSession | WeeklyCheckInSession | WeatherSession;
interface WorkshopTabsProps {
swotSessions: SwotSession[];
motivatorSessions: MotivatorSession[];
yearReviewSessions: YearReviewSession[];
weeklyCheckInSessions: WeeklyCheckInSession[];
weatherSessions: WeatherSession[];
}
// Helper to get resolved collaborator from any session
function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
if (session.workshopType === 'swot') {
return (session as SwotSession).resolvedCollaborator;
} else if (session.workshopType === 'year-review') {
return (session as YearReviewSession).resolvedParticipant;
} else if (session.workshopType === 'weekly-checkin') {
return (session as WeeklyCheckInSession).resolvedParticipant;
} else if (session.workshopType === 'weather') {
// For weather sessions, use the owner as the "participant" since it's a personal weather
const weatherSession = session as WeatherSession;
return {
raw: weatherSession.user.name || weatherSession.user.email,
matchedUser: {
id: weatherSession.user.id,
email: weatherSession.user.email,
name: weatherSession.user.name,
},
};
} else {
return (session as MotivatorSession).resolvedParticipant;
}
}
// Get grouping key - use matched user ID if available, otherwise normalized raw string
function getGroupKey(session: AnySession): string {
const resolved = getResolvedCollaborator(session);
// If we have a matched user, use their ID as key (ensures same person = same group)
if (resolved.matchedUser) {
return `user:${resolved.matchedUser.id}`;
}
// Otherwise, normalize the raw string
return `raw:${resolved.raw.trim().toLowerCase()}`;
}
// Group sessions by participant (using matched user ID when available)
function groupByPerson(sessions: AnySession[]): Map<string, AnySession[]> {
const grouped = new Map<string, AnySession[]>();
sessions.forEach((session) => {
const key = getGroupKey(session);
const existing = grouped.get(key);
if (existing) {
existing.push(session);
} else {
grouped.set(key, [session]);
}
});
// Sort sessions within each group by date
grouped.forEach((sessions) => {
sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
});
return grouped;
}
export function WorkshopTabs({
swotSessions,
motivatorSessions,
yearReviewSessions,
weeklyCheckInSessions,
weatherSessions,
}: WorkshopTabsProps) {
const searchParams = useSearchParams();
const router = useRouter();
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false);
// Get tab from URL or default to 'all'
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()}` : ''}`);
};
// Combine and sort all sessions
const allSessions: AnySession[] = [
...swotSessions,
...motivatorSessions,
...yearReviewSessions,
...weeklyCheckInSessions,
...weatherSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
// Filter based on active tab (for non-byPerson tabs)
const filteredSessions =
activeTab === 'all' || activeTab === 'byPerson'
? allSessions
: activeTab === 'swot'
? swotSessions
: activeTab === 'motivators'
? motivatorSessions
: activeTab === 'year-review'
? yearReviewSessions
: activeTab === 'weekly-checkin'
? weeklyCheckInSessions
: weatherSessions;
// Separate by ownership
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
const sharedSessions = filteredSessions.filter((s) => !s.isOwner);
// Group by person (all sessions - owned and shared)
const sessionsByPerson = groupByPerson(allSessions);
const sortedPersons = Array.from(sessionsByPerson.entries()).sort((a, b) =>
a[0].localeCompare(b[0], 'fr')
);
return (
<div className="space-y-6">
{/* Tabs */}
<div className="flex gap-2 border-b border-border pb-4 flex-wrap items-center">
<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}
/>
<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,
}}
/>
</div>
{/* Sessions */}
{activeTab === 'byPerson' ? (
// By Person View
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}>
<h2 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-3">
<CollaboratorDisplay collaborator={resolved} size="md" />
<Badge variant="primary">
{sessions.length} atelier{sessions.length > 1 ? 's' : ''}
</Badge>
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
);
})}
</div>
)
) : filteredSessions.length === 0 ? (
<div className="text-center py-12 text-muted">Aucun atelier de ce type pour le moment</div>
) : (
<div className="space-y-8">
{/* My Sessions */}
{ownedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
📁 Mes ateliers ({ownedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{ownedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
)}
{/* Shared Sessions */}
{sharedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
🤝 Partagés avec moi ({sharedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sharedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
)}
</div>
)}
</div>
);
}
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');
const current = TYPE_TABS.find((t) => t.value === activeTab) ?? TYPE_TABS[0];
const isTypeSelected = activeTab !== 'all' && activeTab !== 'byPerson';
const totalCount = typeTabs.reduce((s, t) => s + (counts[t.value] ?? 0), 0);
return (
<div className="relative">
<button
type="button"
onClick={() => onOpenChange(!open)}
onBlur={() => setTimeout(() => onOpenChange(false), 150)}
className={`
flex items-center gap-2 px-3 py-2 rounded-lg font-medium text-sm transition-colors
${isTypeSelected ? 'bg-primary text-primary-foreground' : 'text-muted hover:bg-card-hover hover:text-foreground'}
`}
>
<span>{current.icon}</span>
<span>{current.label}</span>
<Badge variant={isTypeSelected ? 'default' : 'primary'} className="ml-1 text-xs">
{isTypeSelected ? counts[activeTab] ?? 0 : totalCount}
</Badge>
<svg
className={`h-4 w-4 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-44 rounded-lg border border-border bg-card py-1 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"
>
<span className="flex items-center gap-2">
<span>📋</span>
<span>Tous</span>
</span>
<Badge variant="primary" className="text-xs">
{totalCount}
</Badge>
</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"
>
<span className="flex items-center gap-2">
<span>{t.icon}</span>
<span>{t.label}</span>
</span>
<Badge variant="primary" className="text-xs">
{counts[t.value] ?? 0}
</Badge>
</button>
))}
</div>
)}
</div>
);
}
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-2 px-3 py-2 rounded-lg font-medium text-sm transition-colors
${
active
? 'bg-primary text-primary-foreground'
: 'text-muted hover:bg-card-hover hover:text-foreground'
}
`}
>
<span>{icon}</span>
<span>{label}</span>
<Badge variant={active ? 'default' : 'primary'} className="ml-1 text-xs">
{count}
</Badge>
</button>
);
}
function SessionCard({ session }: { session: AnySession }) {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [isPending, startTransition] = useTransition();
// Edit form state
const [editTitle, setEditTitle] = useState(session.title);
const [editParticipant, setEditParticipant] = useState(
session.workshopType === 'swot'
? (session as SwotSession).collaborator
: session.workshopType === 'year-review'
? (session as YearReviewSession).participant
: session.workshopType === 'weather'
? ''
: (session as MotivatorSession).participant
);
const workshop = getWorkshop(session.workshopType as WorkshopTypeId);
const isSwot = session.workshopType === 'swot';
const isYearReview = session.workshopType === 'year-review';
const isWeeklyCheckIn = session.workshopType === 'weekly-checkin';
const isWeather = session.workshopType === 'weather';
const href = getSessionPath(session.workshopType as WorkshopTypeId, session.id);
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
: (session as MotivatorSession).participant;
const accentColor = workshop.accentColor;
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)
: await deleteMotivatorSession(session.id);
if (result.success) {
setShowDeleteModal(false);
} else {
console.error('Error deleting session:', 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 })
: await updateMotivatorSession(session.id, {
title: editTitle,
participant: editParticipant,
});
if (result.success) {
setShowEditModal(false);
} else {
console.error('Error updating session:', result.error);
}
});
};
const openEditModal = () => {
// Reset form values when opening
setEditTitle(session.title);
setEditParticipant(participant);
setShowEditModal(true);
};
const editParticipantLabel = workshop.participantLabel;
return (
<>
<div className="relative group">
<Link href={href}>
<Card hover className="h-full p-4 relative overflow-hidden">
{/* Accent bar */}
<div
className="absolute top-0 left-0 right-0 h-1"
style={{ backgroundColor: accentColor }}
/>
{/* Header: Icon + Title + Role badge */}
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">{workshop.icon}</span>
<h3 className="font-semibold text-foreground line-clamp-1 flex-1">{session.title}</h3>
{!session.isOwner && (
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor:
session.role === 'EDITOR' ? 'rgba(6,182,212,0.1)' : 'rgba(234,179,8,0.1)',
color: session.role === 'EDITOR' ? '#06b6d4' : '#eab308',
}}
>
{session.role === 'EDITOR' ? '✏️' : '👁️'}
</span>
)}
</div>
{/* Participant + Owner info */}
<div className="mb-3 flex items-center gap-2">
<CollaboratorDisplay collaborator={getResolvedCollaborator(session)} size="sm" />
{!session.isOwner && (
<span className="text-xs text-muted">
· par {session.user.name || session.user.email}
</span>
)}
</div>
{/* Footer: Stats + Avatars + Date */}
<div className="flex items-center justify-between text-xs">
{/* Stats */}
<div className="flex items-center gap-2 text-muted">
{isSwot ? (
<>
<span>{(session as SwotSession)._count.items} items</span>
<span>·</span>
<span>{(session as SwotSession)._count.actions} actions</span>
</>
) : isYearReview ? (
<>
<span>{(session as YearReviewSession)._count.items} items</span>
<span>·</span>
<span>Année {(session as YearReviewSession).year}</span>
</>
) : isWeeklyCheckIn ? (
<>
<span>{(session as WeeklyCheckInSession)._count.items} items</span>
<span>·</span>
<span>
{new Date((session as WeeklyCheckInSession).date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</>
) : isWeather ? (
<>
<span>{(session as WeatherSession)._count.entries} membres</span>
<span>·</span>
<span>
{new Date((session as WeatherSession).date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</>
) : (
<span>{(session as MotivatorSession)._count.cards}/10</span>
)}
</div>
{/* Date */}
<span className="text-muted">
{new Date(session.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</div>
{/* Shared with */}
{session.isOwner && session.shares.length > 0 && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
<span className="text-[10px] text-muted uppercase tracking-wide">Partagé</span>
<div className="flex flex-wrap gap-1.5">
{session.shares.slice(0, 3).map((share) => (
<div
key={share.id}
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-primary/10 text-[10px] text-primary"
title={share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
>
<span className="font-medium">
{share.user.name?.split(' ')[0] || share.user.email.split('@')[0]}
</span>
<span>{share.role === 'EDITOR' ? '✏️' : '👁️'}</span>
</div>
))}
{session.shares.length > 3 && (
<span className="text-[10px] text-muted">+{session.shares.length - 3}</span>
)}
</div>
</div>
)}
</Card>
</Link>
{/* Action buttons - only for owner */}
{session.isOwner && (
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openEditModal();
}}
className="p-1.5 rounded-lg bg-primary/10 text-primary hover:bg-primary/20"
title="Modifier"
>
<svg className="w-4 h-4" 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>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowDeleteModal(true);
}}
className="p-1.5 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive/20"
title="Supprimer"
>
<svg className="w-4 h-4" 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>
)}
</div>
{/* Edit modal */}
<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"
>
{editParticipantLabel}
</label>
{!isWeather && (
<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 && !editParticipant.trim())}
>
{isPending ? 'Enregistrement...' : 'Enregistrer'}
</Button>
</ModalFooter>
</form>
</Modal>
{/* Delete confirmation 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 l&apos;atelier{' '}
<strong className="text-foreground">&quot;{session.title}&quot;</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>
</>
);
}