feat: implement Year Review feature with session management, item categorization, and real-time collaboration
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m7s

This commit is contained in:
Julien Froidefond
2025-12-16 08:55:13 +01:00
parent 48ff86fb5f
commit 56a9c2c3be
21 changed files with 2480 additions and 50 deletions

View File

@@ -0,0 +1,123 @@
import { auth } from '@/lib/auth';
import {
canAccessYearReviewSession,
getYearReviewSessionEvents,
} from '@/services/year-review';
export const dynamic = 'force-dynamic';
// Store active connections per session
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id: sessionId } = await params;
const session = await auth();
if (!session?.user?.id) {
return new Response('Unauthorized', { status: 401 });
}
// Check access
const hasAccess = await canAccessYearReviewSession(sessionId, session.user.id);
if (!hasAccess) {
return new Response('Forbidden', { status: 403 });
}
const userId = session.user.id;
let lastEventTime = new Date();
let controller: ReadableStreamDefaultController;
const stream = new ReadableStream({
start(ctrl) {
controller = ctrl;
// Register connection
if (!connections.has(sessionId)) {
connections.set(sessionId, new Set());
}
connections.get(sessionId)!.add(controller);
// Send initial ping
const encoder = new TextEncoder();
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`)
);
},
cancel() {
// Remove connection on close
connections.get(sessionId)?.delete(controller);
if (connections.get(sessionId)?.size === 0) {
connections.delete(sessionId);
}
},
});
// Poll for new events (simple approach, works with any DB)
const pollInterval = setInterval(async () => {
try {
const events = await getYearReviewSessionEvents(sessionId, lastEventTime);
if (events.length > 0) {
const encoder = new TextEncoder();
for (const event of events) {
// Don't send events to the user who created them
if (event.userId !== userId) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: event.type,
payload: JSON.parse(event.payload),
userId: event.userId,
user: event.user,
timestamp: event.createdAt,
})}\n\n`
)
);
}
lastEventTime = event.createdAt;
}
}
} catch {
// Connection might be closed
clearInterval(pollInterval);
}
}, 1000); // Poll every second
// Cleanup on abort
request.signal.addEventListener('abort', () => {
clearInterval(pollInterval);
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
// Helper to broadcast to all connections (called from actions)
export function broadcastToYearReviewSession(sessionId: string, event: object) {
const sessionConnections = connections.get(sessionId);
if (!sessionConnections || sessionConnections.size === 0) {
return;
}
const encoder = new TextEncoder();
const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
for (const controller of sessionConnections) {
try {
controller.enqueue(message);
} catch {
// Connection might be closed, remove it
sessionConnections.delete(controller);
}
}
// Clean up empty sets
if (sessionConnections.size === 0) {
connections.delete(sessionId);
}
}

View File

@@ -18,6 +18,7 @@ export function EditableMotivatorTitle({
const [title, setTitle] = useState(initialTitle);
const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
const prevInitialTitleRef = useRef(initialTitle);
useEffect(() => {
if (isEditing && inputRef.current) {
@@ -26,9 +27,14 @@ export function EditableMotivatorTitle({
}
}, [isEditing]);
// Update local state when prop changes (e.g., from SSE)
// Update local state when prop changes (e.g., from SSE) - only when not editing
// This is a valid pattern for syncing external state (SSE updates) with local state
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (!isEditing) {
if (!isEditing && prevInitialTitleRef.current !== initialTitle) {
prevInitialTitleRef.current = initialTitle;
// Synchronizing with external prop updates (e.g., from SSE)
// eslint-disable-next-line react-hooks/exhaustive-deps
setTitle(initialTitle);
}
}, [initialTitle, isEditing]);

View File

@@ -20,7 +20,7 @@ export default function Home() {
<h2 className="mb-8 text-center text-2xl font-bold text-foreground">
Choisissez votre atelier
</h2>
<div className="grid gap-8 md:grid-cols-2 max-w-4xl mx-auto">
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3 max-w-6xl mx-auto">
{/* SWOT Workshop Card */}
<WorkshopCard
href="/sessions?tab=swot"
@@ -52,6 +52,22 @@ export default function Home() {
accentColor="#8b5cf6"
newHref="/motivators/new"
/>
{/* Year Review Workshop Card */}
<WorkshopCard
href="/sessions?tab=year-review"
icon="📅"
title="Year Review"
tagline="Faites le bilan de l'année"
description="Réalisez un bilan complet de l'année écoulée. Identifiez réalisations, défis, apprentissages et définissez vos objectifs pour l'année à venir."
features={[
'5 catégories : Réalisations, Défis, Apprentissages, Objectifs, Moments',
'Organisation par drag & drop',
'Vue d\'ensemble de l\'année',
]}
accentColor="#f59e0b"
newHref="/year-review/new"
/>
</div>
</section>
@@ -250,6 +266,95 @@ export default function Home() {
</div>
</section>
{/* Year Review Deep Dive Section */}
<section className="mb-16">
<div className="flex items-center gap-3 mb-8">
<span className="text-4xl">📅</span>
<div>
<h2 className="text-3xl font-bold text-foreground">Year Review</h2>
<p className="text-amber-500 font-medium">Faites le bilan de l&apos;année écoulée</p>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-2">
{/* Why */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">💭</span>
Pourquoi faire un bilan annuel ?
</h3>
<p className="text-muted mb-4">
Le Year Review est un exercice de réflexion structuré qui permet de prendre du recul
sur l&apos;année écoulée. Il aide à identifier les patterns, célébrer les réussites,
apprendre des défis et préparer l&apos;avenir avec clarté.
</p>
<ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2">
<span className="text-amber-500"></span>
Prendre conscience de ses accomplissements et les célébrer
</li>
<li className="flex items-start gap-2">
<span className="text-amber-500"></span>
Identifier les apprentissages et compétences développées
</li>
<li className="flex items-start gap-2">
<span className="text-amber-500"></span>
Comprendre les défis rencontrés pour mieux les anticiper
</li>
<li className="flex items-start gap-2">
<span className="text-amber-500"></span>
Définir des objectifs clairs et motivants pour l&apos;année à venir
</li>
</ul>
</div>
{/* The 5 categories */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">📋</span>
Les 5 catégories du bilan
</h3>
<div className="space-y-3">
<CategoryPill icon="🏆" name="Réalisations" color="#22c55e" description="Ce que vous avez accompli" />
<CategoryPill icon="⚔️" name="Défis" color="#ef4444" description="Les difficultés rencontrées" />
<CategoryPill icon="📚" name="Apprentissages" color="#3b82f6" description="Ce que vous avez appris" />
<CategoryPill icon="🎯" name="Objectifs" color="#8b5cf6" description="Vos ambitions pour l'année prochaine" />
<CategoryPill icon="⭐" name="Moments" color="#f59e0b" description="Les moments forts et marquants" />
</div>
</div>
{/* How it works */}
<div className="rounded-xl border border-border bg-card p-6 lg:col-span-2">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl"></span>
Comment ça marche ?
</h3>
<div className="grid md:grid-cols-4 gap-4">
<StepCard
number={1}
title="Réfléchir"
description="Prenez le temps de revenir sur l'année écoulée, consultez votre agenda, vos notes, vos projets"
/>
<StepCard
number={2}
title="Catégoriser"
description="Organisez vos réflexions dans les 5 catégories : réalisations, défis, apprentissages, objectifs et moments"
/>
<StepCard
number={3}
title="Prioriser"
description="Classez les éléments par importance et impact pour identifier ce qui compte vraiment"
/>
<StepCard
number={4}
title="Planifier"
description="Utilisez ce bilan pour définir vos objectifs et actions pour l'année à venir"
/>
</div>
</div>
</div>
</section>
{/* Benefits Section */}
<section className="rounded-2xl border border-border bg-card p-8">
<h2 className="mb-8 text-center text-2xl font-bold text-foreground">
@@ -420,3 +525,30 @@ function MotivatorPill({ icon, name, color }: { icon: string; name: string; colo
</div>
);
}
function CategoryPill({
icon,
name,
color,
description,
}: {
icon: string;
name: string;
color: string;
description: string;
}) {
return (
<div
className="flex items-start gap-3 px-4 py-3 rounded-lg"
style={{ backgroundColor: `${color}10`, border: `1px solid ${color}30` }}
>
<span className="text-xl">{icon}</span>
<div className="flex-1">
<p className="font-semibold text-sm mb-0.5" style={{ color }}>
{name}
</p>
<p className="text-xs text-muted">{description}</p>
</div>
</div>
);
}

View File

@@ -14,10 +14,11 @@ import {
} from '@/components/ui';
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review';
type WorkshopType = 'all' | 'swot' | 'motivators' | 'byPerson';
type WorkshopType = 'all' | 'swot' | 'motivators' | 'year-review' | 'byPerson';
const VALID_TABS: WorkshopType[] = ['all', 'swot', 'motivators', 'byPerson'];
const VALID_TABS: WorkshopType[] = ['all', 'swot', 'motivators', 'year-review', 'byPerson'];
interface ShareUser {
id: string;
@@ -68,34 +69,38 @@ interface MotivatorSession {
workshopType: 'motivators';
}
type AnySession = SwotSession | MotivatorSession;
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';
}
type AnySession = SwotSession | MotivatorSession | YearReviewSession;
interface WorkshopTabsProps {
swotSessions: SwotSession[];
motivatorSessions: MotivatorSession[];
}
// Helper to get participant name from any session
function getParticipant(session: AnySession): string {
return session.workshopType === 'swot'
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant;
yearReviewSessions: YearReviewSession[];
}
// Helper to get resolved collaborator from any session
function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
return session.workshopType === 'swot'
? (session as SwotSession).resolvedCollaborator
: (session as MotivatorSession).resolvedParticipant;
}
// Get display name for grouping - prefer matched user name
function getDisplayName(session: AnySession): string {
const resolved = getResolvedCollaborator(session);
if (resolved.matchedUser?.name) {
return resolved.matchedUser.name;
if (session.workshopType === 'swot') {
return (session as SwotSession).resolvedCollaborator;
} else if (session.workshopType === 'year-review') {
return (session as YearReviewSession).resolvedParticipant;
} else {
return (session as MotivatorSession).resolvedParticipant;
}
return resolved.raw;
}
// Get grouping key - use matched user ID if available, otherwise normalized raw string
@@ -132,7 +137,11 @@ function groupByPerson(sessions: AnySession[]): Map<string, AnySession[]> {
return grouped;
}
export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsProps) {
export function WorkshopTabs({
swotSessions,
motivatorSessions,
yearReviewSessions,
}: WorkshopTabsProps) {
const searchParams = useSearchParams();
const router = useRouter();
@@ -152,9 +161,11 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
};
// Combine and sort all sessions
const allSessions: AnySession[] = [...swotSessions, ...motivatorSessions].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
const allSessions: AnySession[] = [
...swotSessions,
...motivatorSessions,
...yearReviewSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
// Filter based on active tab (for non-byPerson tabs)
const filteredSessions =
@@ -162,7 +173,9 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
? allSessions
: activeTab === 'swot'
? swotSessions
: motivatorSessions;
: activeTab === 'motivators'
? motivatorSessions
: yearReviewSessions;
// Separate by ownership
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
@@ -206,6 +219,13 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
label="Moving Motivators"
count={motivatorSessions.length}
/>
<TabButton
active={activeTab === 'year-review'}
onClick={() => setActiveTab('year-review')}
icon="📅"
label="Year Review"
count={yearReviewSessions.length}
/>
</div>
{/* Sessions */}
@@ -316,22 +336,33 @@ function SessionCard({ session }: { session: AnySession }) {
const [editParticipant, setEditParticipant] = useState(
session.workshopType === 'swot'
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant
: session.workshopType === 'year-review'
? (session as YearReviewSession).participant
: (session as MotivatorSession).participant
);
const isSwot = session.workshopType === 'swot';
const href = isSwot ? `/sessions/${session.id}` : `/motivators/${session.id}`;
const icon = isSwot ? '📊' : '🎯';
const isYearReview = session.workshopType === 'year-review';
const href = isSwot
? `/sessions/${session.id}`
: isYearReview
? `/year-review/${session.id}`
: `/motivators/${session.id}`;
const icon = isSwot ? '📊' : isYearReview ? '📅' : '🎯';
const participant = isSwot
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant;
const accentColor = isSwot ? '#06b6d4' : '#8b5cf6';
: isYearReview
? (session as YearReviewSession).participant
: (session as MotivatorSession).participant;
const accentColor = isSwot ? '#06b6d4' : isYearReview ? '#f59e0b' : '#8b5cf6';
const handleDelete = () => {
startTransition(async () => {
const result = isSwot
? await deleteSwotSession(session.id)
: await deleteMotivatorSession(session.id);
: isYearReview
? await deleteYearReviewSession(session.id)
: await deleteMotivatorSession(session.id);
if (result.success) {
setShowDeleteModal(false);
@@ -345,10 +376,15 @@ function SessionCard({ session }: { session: AnySession }) {
startTransition(async () => {
const result = isSwot
? await updateSwotSession(session.id, { title: editTitle, collaborator: editParticipant })
: await updateMotivatorSession(session.id, {
title: editTitle,
participant: editParticipant,
});
: isYearReview
? await updateYearReviewSession(session.id, {
title: editTitle,
participant: editParticipant,
})
: await updateMotivatorSession(session.id, {
title: editTitle,
participant: editParticipant,
});
if (result.success) {
setShowEditModal(false);
@@ -414,6 +450,12 @@ function SessionCard({ session }: { session: AnySession }) {
<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>
</>
) : (
<span>{(session as MotivatorSession)._count.cards}/10</span>
)}

View File

@@ -3,6 +3,7 @@ import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getSessionsByUserId } from '@/services/sessions';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
import { getYearReviewSessionsByUserId } from '@/services/year-review';
import { Card, Button } from '@/components/ui';
import { WorkshopTabs } from './WorkshopTabs';
@@ -32,10 +33,11 @@ export default async function SessionsPage() {
return null;
}
// Fetch both SWOT and Moving Motivators sessions
const [swotSessions, motivatorSessions] = await Promise.all([
// Fetch SWOT, Moving Motivators, and Year Review sessions
const [swotSessions, motivatorSessions, yearReviewSessions] = await Promise.all([
getSessionsByUserId(session.user.id),
getMotivatorSessionsByUserId(session.user.id),
getYearReviewSessionsByUserId(session.user.id),
]);
// Add type to each session for unified display
@@ -49,10 +51,17 @@ export default async function SessionsPage() {
workshopType: 'motivators' as const,
}));
const allYearReviewSessions = yearReviewSessions.map((s) => ({
...s,
workshopType: 'year-review' as const,
}));
// Combine and sort by updatedAt
const allSessions = [...allSwotSessions, ...allMotivatorSessions].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
const allSessions = [
...allSwotSessions,
...allMotivatorSessions,
...allYearReviewSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const hasNoSessions = allSessions.length === 0;
@@ -72,11 +81,17 @@ export default async function SessionsPage() {
</Button>
</Link>
<Link href="/motivators/new">
<Button>
<Button variant="outline">
<span>🎯</span>
Nouveau Motivators
</Button>
</Link>
<Link href="/year-review/new">
<Button>
<span>📅</span>
Nouveau Year Review
</Button>
</Link>
</div>
</div>
@@ -88,8 +103,8 @@ export default async function SessionsPage() {
Commencez votre premier atelier
</h2>
<p className="text-muted mb-6 max-w-md mx-auto">
Créez un atelier SWOT pour analyser les forces et faiblesses, ou un Moving Motivators
pour découvrir les motivations de vos collaborateurs.
Créez un atelier SWOT pour analyser les forces et faiblesses, un Moving Motivators pour
découvrir les motivations, ou un Year Review pour faire le bilan de l&apos;année.
</p>
<div className="flex gap-3 justify-center">
<Link href="/sessions/new">
@@ -99,16 +114,26 @@ export default async function SessionsPage() {
</Button>
</Link>
<Link href="/motivators/new">
<Button>
<Button variant="outline">
<span>🎯</span>
Créer un Moving Motivators
</Button>
</Link>
<Link href="/year-review/new">
<Button>
<span>📅</span>
Créer un Year Review
</Button>
</Link>
</div>
</Card>
) : (
<Suspense fallback={<WorkshopTabsSkeleton />}>
<WorkshopTabs swotSessions={allSwotSessions} motivatorSessions={allMotivatorSessions} />
<WorkshopTabs
swotSessions={allSwotSessions}
motivatorSessions={allMotivatorSessions}
yearReviewSessions={allYearReviewSessions}
/>
</Suspense>
)}
</main>

View File

@@ -0,0 +1,114 @@
'use client';
import { useState, useTransition, useRef, useEffect } from 'react';
import { updateYearReviewSession } from '@/actions/year-review';
interface EditableYearReviewTitleProps {
sessionId: string;
initialTitle: string;
isOwner: boolean;
}
export function EditableYearReviewTitle({
sessionId,
initialTitle,
isOwner,
}: EditableYearReviewTitleProps) {
const [isEditing, setIsEditing] = useState(false);
const [title, setTitle] = useState(initialTitle);
const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
const prevInitialTitleRef = useRef(initialTitle);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
// Update local state when prop changes (e.g., from SSE) - only when not editing
// This is a valid pattern for syncing external state (SSE updates) with local state
useEffect(() => {
if (!isEditing && prevInitialTitleRef.current !== initialTitle) {
prevInitialTitleRef.current = initialTitle;
// Synchronizing with external prop updates (e.g., from SSE)
// eslint-disable-next-line react-hooks/exhaustive-deps
setTitle(initialTitle);
}
}, [initialTitle, isEditing]);
const handleSave = () => {
if (!title.trim()) {
setTitle(initialTitle);
setIsEditing(false);
return;
}
if (title.trim() === initialTitle) {
setIsEditing(false);
return;
}
startTransition(async () => {
const result = await updateYearReviewSession(sessionId, { title: title.trim() });
if (!result.success) {
setTitle(initialTitle);
console.error(result.error);
}
setIsEditing(false);
});
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
setTitle(initialTitle);
setIsEditing(false);
}
};
if (!isOwner) {
return <h1 className="text-3xl font-bold text-foreground">{title}</h1>;
}
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
disabled={isPending}
className="w-full max-w-md rounded-lg border border-border bg-input px-3 py-1.5 text-3xl font-bold text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 disabled:opacity-50"
/>
);
}
return (
<button
onClick={() => setIsEditing(true)}
className="group flex items-center gap-2 text-left"
title="Cliquez pour modifier"
>
<h1 className="text-3xl font-bold text-foreground">{title}</h1>
<svg
className="h-5 w-5 text-muted opacity-0 transition-opacity group-hover:opacity-100"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
);
}

View File

@@ -0,0 +1,82 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getYearReviewSessionById } from '@/services/year-review';
import { YearReviewBoard, YearReviewLiveWrapper } from '@/components/year-review';
import { Badge, CollaboratorDisplay } from '@/components/ui';
import { EditableYearReviewTitle } from './EditableTitle';
interface YearReviewSessionPageProps {
params: Promise<{ id: string }>;
}
export default async function YearReviewSessionPage({ params }: YearReviewSessionPageProps) {
const { id } = await params;
const authSession = await auth();
if (!authSession?.user?.id) {
return null;
}
const session = await getYearReviewSessionById(id, authSession.user.id);
if (!session) {
notFound();
}
return (
<main className="mx-auto max-w-7xl px-4 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-muted mb-2">
<Link href="/sessions?tab=year-review" className="hover:text-foreground">
Year Review
</Link>
<span>/</span>
<span className="text-foreground">{session.title}</span>
{!session.isOwner && (
<Badge variant="accent" className="ml-2">
Partagé par {session.user.name || session.user.email}
</Badge>
)}
</div>
<div className="flex items-start justify-between">
<div>
<EditableYearReviewTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
/>
<div className="mt-2">
<CollaboratorDisplay collaborator={session.resolvedParticipant} size="lg" showEmail />
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary">{session.items.length} items</Badge>
<Badge variant="default">Année {session.year}</Badge>
<span className="text-sm text-muted">
{new Date(session.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
{/* Live Wrapper + Board */}
<YearReviewLiveWrapper
sessionId={session.id}
sessionTitle={session.title}
currentUserId={authSession.user.id}
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
>
<YearReviewBoard sessionId={session.id} items={session.items} />
</YearReviewLiveWrapper>
</main>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
Input,
} from '@/components/ui';
import { createYearReviewSession } from '@/actions/year-review';
export default function NewYearReviewPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const currentYear = new Date().getFullYear();
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const formData = new FormData(e.currentTarget);
const title = formData.get('title') as string;
const participant = formData.get('participant') as string;
const year = parseInt(formData.get('year') as string, 10);
if (!title || !participant || !year) {
setError('Veuillez remplir tous les champs');
setLoading(false);
return;
}
const result = await createYearReviewSession({ title, participant, year });
if (!result.success) {
setError(result.error || 'Une erreur est survenue');
setLoading(false);
return;
}
router.push(`/year-review/${result.data?.id}`);
}
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>📅</span>
Nouveau Bilan Annuel
</CardTitle>
<CardDescription>
Créez un bilan de l&apos;année pour faire le point sur les réalisations, défis,
apprentissages et objectifs
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<Input
label="Titre du bilan"
name="title"
placeholder={`Ex: Bilan annuel ${currentYear}`}
required
/>
<Input
label="Nom du participant"
name="participant"
placeholder="Ex: Jean Dupont"
required
/>
<div>
<label htmlFor="year" className="block text-sm font-medium text-foreground mb-1">
Année du bilan
</label>
<input
id="year"
name="year"
type="number"
min="2000"
max="2100"
defaultValue={currentYear}
required
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
/>
</div>
<div className="rounded-lg border border-border bg-card-hover p-4">
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
<ol className="text-sm text-muted space-y-1 list-decimal list-inside">
<li>
<strong>Réalisations</strong> : Notez ce que vous avez accompli cette année
</li>
<li>
<strong>Défis</strong> : Identifiez les difficultés rencontrées
</li>
<li>
<strong>Apprentissages</strong> : Listez ce que vous avez appris et développé
</li>
<li>
<strong>Objectifs</strong> : Définissez vos objectifs pour l&apos;année prochaine
</li>
<li>
<strong>Moments</strong> : Partagez les moments forts et marquants
</li>
</ol>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={loading}
>
Annuler
</Button>
<Button type="submit" loading={loading} className="flex-1">
Créer le bilan
</Button>
</div>
</form>
</CardContent>
</Card>
</main>
);
}