From 56a9c2c3be60910dce749c4645a96026843ccaac Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Tue, 16 Dec 2025 08:55:13 +0100 Subject: [PATCH] feat: implement Year Review feature with session management, item categorization, and real-time collaboration --- .../migration.sql | 70 ++++ prisma/schema.prisma | 74 ++++ src/actions/year-review.ts | 329 ++++++++++++++++ .../api/year-review/[id]/subscribe/route.ts | 123 ++++++ src/app/motivators/[id]/EditableTitle.tsx | 10 +- src/app/page.tsx | 134 ++++++- src/app/sessions/WorkshopTabs.tsx | 114 ++++-- src/app/sessions/page.tsx | 45 ++- src/app/year-review/[id]/EditableTitle.tsx | 114 ++++++ src/app/year-review/[id]/page.tsx | 82 ++++ src/app/year-review/new/page.tsx | 142 +++++++ src/components/session/EditableTitle.tsx | 5 +- .../year-review/YearReviewBoard.tsx | 94 +++++ src/components/year-review/YearReviewCard.tsx | 131 +++++++ .../year-review/YearReviewLiveWrapper.tsx | 133 +++++++ .../year-review/YearReviewSection.tsx | 134 +++++++ .../year-review/YearReviewShareModal.tsx | 173 ++++++++ src/components/year-review/index.ts | 5 + src/hooks/useYearReviewLive.ts | 131 +++++++ src/lib/types.ts | 117 ++++++ src/services/year-review.ts | 370 ++++++++++++++++++ 21 files changed, 2480 insertions(+), 50 deletions(-) create mode 100644 prisma/migrations/20251216074352_add_year_review/migration.sql create mode 100644 src/actions/year-review.ts create mode 100644 src/app/api/year-review/[id]/subscribe/route.ts create mode 100644 src/app/year-review/[id]/EditableTitle.tsx create mode 100644 src/app/year-review/[id]/page.tsx create mode 100644 src/app/year-review/new/page.tsx create mode 100644 src/components/year-review/YearReviewBoard.tsx create mode 100644 src/components/year-review/YearReviewCard.tsx create mode 100644 src/components/year-review/YearReviewLiveWrapper.tsx create mode 100644 src/components/year-review/YearReviewSection.tsx create mode 100644 src/components/year-review/YearReviewShareModal.tsx create mode 100644 src/components/year-review/index.ts create mode 100644 src/hooks/useYearReviewLive.ts create mode 100644 src/services/year-review.ts diff --git a/prisma/migrations/20251216074352_add_year_review/migration.sql b/prisma/migrations/20251216074352_add_year_review/migration.sql new file mode 100644 index 0000000..cb83775 --- /dev/null +++ b/prisma/migrations/20251216074352_add_year_review/migration.sql @@ -0,0 +1,70 @@ +-- CreateTable +CREATE TABLE "YearReviewSession" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "participant" TEXT NOT NULL, + "year" INTEGER NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "YearReviewSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "YearReviewItem" ( + "id" TEXT NOT NULL PRIMARY KEY, + "content" TEXT NOT NULL, + "category" TEXT NOT NULL, + "order" INTEGER NOT NULL DEFAULT 0, + "sessionId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "YearReviewItem_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "YearReviewSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "YRSessionShare" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'EDITOR', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "YRSessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "YearReviewSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "YRSessionShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "YRSessionEvent" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "payload" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "YRSessionEvent_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "YearReviewSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "YRSessionEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "YearReviewSession_userId_idx" ON "YearReviewSession"("userId"); + +-- CreateIndex +CREATE INDEX "YearReviewSession_year_idx" ON "YearReviewSession"("year"); + +-- CreateIndex +CREATE INDEX "YearReviewItem_sessionId_idx" ON "YearReviewItem"("sessionId"); + +-- CreateIndex +CREATE INDEX "YearReviewItem_sessionId_category_idx" ON "YearReviewItem"("sessionId", "category"); + +-- CreateIndex +CREATE INDEX "YRSessionShare_sessionId_idx" ON "YRSessionShare"("sessionId"); + +-- CreateIndex +CREATE INDEX "YRSessionShare_userId_idx" ON "YRSessionShare"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "YRSessionShare_sessionId_userId_key" ON "YRSessionShare"("sessionId", "userId"); + +-- CreateIndex +CREATE INDEX "YRSessionEvent_sessionId_createdAt_idx" ON "YRSessionEvent"("sessionId", "createdAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fb7b864..e7a927d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,6 +21,10 @@ model User { motivatorSessions MovingMotivatorsSession[] sharedMotivatorSessions MMSessionShare[] motivatorSessionEvents MMSessionEvent[] + // Year Review relations + yearReviewSessions YearReviewSession[] + sharedYearReviewSessions YRSessionShare[] + yearReviewSessionEvents YRSessionEvent[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -200,3 +204,73 @@ model MMSessionEvent { @@index([sessionId, createdAt]) } + +// ============================================ +// Year Review Workshop +// ============================================ + +enum YearReviewCategory { + ACHIEVEMENTS // Réalisations / Accomplissements + CHALLENGES // Défis / Difficultés rencontrées + LEARNINGS // Apprentissages / Compétences développées + GOALS // Objectifs pour l'année suivante + MOMENTS // Moments forts / Moments difficiles +} + +model YearReviewSession { + id String @id @default(cuid()) + title String + participant String // Nom du participant + year Int // Année du bilan (ex: 2024) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + items YearReviewItem[] + shares YRSessionShare[] + events YRSessionEvent[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([year]) +} + +model YearReviewItem { + id String @id @default(cuid()) + content String + category YearReviewCategory + order Int @default(0) + sessionId String + session YearReviewSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([sessionId]) + @@index([sessionId, category]) +} + +model YRSessionShare { + id String @id @default(cuid()) + sessionId String + session YearReviewSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + role ShareRole @default(EDITOR) + createdAt DateTime @default(now()) + + @@unique([sessionId, userId]) + @@index([sessionId]) + @@index([userId]) +} + +model YRSessionEvent { + id String @id @default(cuid()) + sessionId String + session YearReviewSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + type String // ITEM_CREATED, ITEM_UPDATED, ITEM_DELETED, etc. + payload String // JSON payload + createdAt DateTime @default(now()) + + @@index([sessionId, createdAt]) +} diff --git a/src/actions/year-review.ts b/src/actions/year-review.ts new file mode 100644 index 0000000..5e01b84 --- /dev/null +++ b/src/actions/year-review.ts @@ -0,0 +1,329 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { auth } from '@/lib/auth'; +import * as yearReviewService from '@/services/year-review'; +import type { YearReviewCategory } from '@prisma/client'; + +// ============================================ +// Session Actions +// ============================================ + +export async function createYearReviewSession(data: { + title: string; + participant: string; + year: number; +}) { + const session = await auth(); + if (!session?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + const yearReviewSession = await yearReviewService.createYearReviewSession( + session.user.id, + data + ); + revalidatePath('/year-review'); + revalidatePath('/sessions'); + return { success: true, data: yearReviewSession }; + } catch (error) { + console.error('Error creating year review session:', error); + return { success: false, error: 'Erreur lors de la création' }; + } +} + +export async function updateYearReviewSession( + sessionId: string, + data: { title?: string; participant?: string; year?: number } +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + await yearReviewService.updateYearReviewSession(sessionId, authSession.user.id, data); + + // Emit event for real-time sync + await yearReviewService.createYearReviewSessionEvent( + sessionId, + authSession.user.id, + 'SESSION_UPDATED', + data + ); + + revalidatePath(`/year-review/${sessionId}`); + revalidatePath('/year-review'); + revalidatePath('/sessions'); + return { success: true }; + } catch (error) { + console.error('Error updating year review session:', error); + return { success: false, error: 'Erreur lors de la mise à jour' }; + } +} + +export async function deleteYearReviewSession(sessionId: string) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + await yearReviewService.deleteYearReviewSession(sessionId, authSession.user.id); + revalidatePath('/year-review'); + revalidatePath('/sessions'); + return { success: true }; + } catch (error) { + console.error('Error deleting year review session:', error); + return { success: false, error: 'Erreur lors de la suppression' }; + } +} + +// ============================================ +// Item Actions +// ============================================ + +export async function createYearReviewItem( + sessionId: string, + data: { content: string; category: YearReviewCategory } +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + // Check edit permission + const canEdit = await yearReviewService.canEditYearReviewSession( + sessionId, + authSession.user.id + ); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + const item = await yearReviewService.createYearReviewItem(sessionId, data); + + // Emit event for real-time sync + await yearReviewService.createYearReviewSessionEvent( + sessionId, + authSession.user.id, + 'ITEM_CREATED', + { + itemId: item.id, + content: item.content, + category: item.category, + } + ); + + revalidatePath(`/year-review/${sessionId}`); + return { success: true, data: item }; + } catch (error) { + console.error('Error creating year review item:', error); + return { success: false, error: 'Erreur lors de la création' }; + } +} + +export async function updateYearReviewItem( + itemId: string, + sessionId: string, + data: { content?: string; category?: YearReviewCategory } +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + // Check edit permission + const canEdit = await yearReviewService.canEditYearReviewSession( + sessionId, + authSession.user.id + ); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + const item = await yearReviewService.updateYearReviewItem(itemId, data); + + // Emit event for real-time sync + await yearReviewService.createYearReviewSessionEvent( + sessionId, + authSession.user.id, + 'ITEM_UPDATED', + { + itemId: item.id, + ...data, + } + ); + + revalidatePath(`/year-review/${sessionId}`); + return { success: true, data: item }; + } catch (error) { + console.error('Error updating year review item:', error); + return { success: false, error: 'Erreur lors de la mise à jour' }; + } +} + +export async function deleteYearReviewItem(itemId: string, sessionId: string) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + // Check edit permission + const canEdit = await yearReviewService.canEditYearReviewSession( + sessionId, + authSession.user.id + ); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + await yearReviewService.deleteYearReviewItem(itemId); + + // Emit event for real-time sync + await yearReviewService.createYearReviewSessionEvent( + sessionId, + authSession.user.id, + 'ITEM_DELETED', + { itemId } + ); + + revalidatePath(`/year-review/${sessionId}`); + return { success: true }; + } catch (error) { + console.error('Error deleting year review item:', error); + return { success: false, error: 'Erreur lors de la suppression' }; + } +} + +export async function moveYearReviewItem( + itemId: string, + sessionId: string, + newCategory: YearReviewCategory, + newOrder: number +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + // Check edit permission + const canEdit = await yearReviewService.canEditYearReviewSession( + sessionId, + authSession.user.id + ); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + await yearReviewService.moveYearReviewItem(itemId, newCategory, newOrder); + + // Emit event for real-time sync + await yearReviewService.createYearReviewSessionEvent( + sessionId, + authSession.user.id, + 'ITEM_MOVED', + { + itemId, + category: newCategory, + order: newOrder, + } + ); + + revalidatePath(`/year-review/${sessionId}`); + return { success: true }; + } catch (error) { + console.error('Error moving year review item:', error); + return { success: false, error: 'Erreur lors du déplacement' }; + } +} + +export async function reorderYearReviewItems( + sessionId: string, + category: YearReviewCategory, + itemIds: string[] +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + // Check edit permission + const canEdit = await yearReviewService.canEditYearReviewSession( + sessionId, + authSession.user.id + ); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + await yearReviewService.reorderYearReviewItems(sessionId, category, itemIds); + + // Emit event for real-time sync + await yearReviewService.createYearReviewSessionEvent( + sessionId, + authSession.user.id, + 'ITEMS_REORDERED', + { category, itemIds } + ); + + revalidatePath(`/year-review/${sessionId}`); + return { success: true }; + } catch (error) { + console.error('Error reordering year review items:', error); + return { success: false, error: 'Erreur lors du réordonnancement' }; + } +} + +// ============================================ +// Sharing Actions +// ============================================ + +export async function shareYearReviewSession( + sessionId: string, + targetEmail: string, + role: 'VIEWER' | 'EDITOR' = 'EDITOR' +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + const share = await yearReviewService.shareYearReviewSession( + sessionId, + authSession.user.id, + targetEmail, + role + ); + revalidatePath(`/year-review/${sessionId}`); + return { success: true, data: share }; + } catch (error) { + console.error('Error sharing year review session:', error); + const message = error instanceof Error ? error.message : 'Erreur lors du partage'; + return { success: false, error: message }; + } +} + +export async function removeYearReviewShare(sessionId: string, shareUserId: string) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + await yearReviewService.removeYearReviewShare(sessionId, authSession.user.id, shareUserId); + revalidatePath(`/year-review/${sessionId}`); + return { success: true }; + } catch (error) { + console.error('Error removing year review share:', error); + return { success: false, error: 'Erreur lors de la suppression du partage' }; + } +} + diff --git a/src/app/api/year-review/[id]/subscribe/route.ts b/src/app/api/year-review/[id]/subscribe/route.ts new file mode 100644 index 0000000..30cd4ba --- /dev/null +++ b/src/app/api/year-review/[id]/subscribe/route.ts @@ -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>(); + +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); + } +} + diff --git a/src/app/motivators/[id]/EditableTitle.tsx b/src/app/motivators/[id]/EditableTitle.tsx index 94913fe..69a43e2 100644 --- a/src/app/motivators/[id]/EditableTitle.tsx +++ b/src/app/motivators/[id]/EditableTitle.tsx @@ -18,6 +18,7 @@ export function EditableMotivatorTitle({ const [title, setTitle] = useState(initialTitle); const [isPending, startTransition] = useTransition(); const inputRef = useRef(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]); diff --git a/src/app/page.tsx b/src/app/page.tsx index 7d0fc1f..d95a762 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -20,7 +20,7 @@ export default function Home() {

Choisissez votre atelier

-
+
{/* SWOT Workshop Card */} + + {/* Year Review Workshop Card */} +
@@ -250,6 +266,95 @@ export default function Home() {
+ {/* Year Review Deep Dive Section */} +
+
+ 📅 +
+

Year Review

+

Faites le bilan de l'année écoulée

+
+
+ +
+ {/* Why */} +
+

+ 💭 + Pourquoi faire un bilan annuel ? +

+

+ Le Year Review est un exercice de réflexion structuré qui permet de prendre du recul + sur l'année écoulée. Il aide à identifier les patterns, célébrer les réussites, + apprendre des défis et préparer l'avenir avec clarté. +

+
    +
  • + + Prendre conscience de ses accomplissements et les célébrer +
  • +
  • + + Identifier les apprentissages et compétences développées +
  • +
  • + + Comprendre les défis rencontrés pour mieux les anticiper +
  • +
  • + + Définir des objectifs clairs et motivants pour l'année à venir +
  • +
+
+ + {/* The 5 categories */} +
+

+ 📋 + Les 5 catégories du bilan +

+
+ + + + + +
+
+ + {/* How it works */} +
+

+ ⚙️ + Comment ça marche ? +

+
+ + + + +
+
+
+
+ {/* Benefits Section */}

@@ -420,3 +525,30 @@ function MotivatorPill({ icon, name, color }: { icon: string; name: string; colo ); } + +function CategoryPill({ + icon, + name, + color, + description, +}: { + icon: string; + name: string; + color: string; + description: string; +}) { + return ( +
+ {icon} +
+

+ {name} +

+

{description}

+
+
+ ); +} diff --git a/src/app/sessions/WorkshopTabs.tsx b/src/app/sessions/WorkshopTabs.tsx index b189b74..fb3c80e 100644 --- a/src/app/sessions/WorkshopTabs.tsx +++ b/src/app/sessions/WorkshopTabs.tsx @@ -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 { 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} /> + setActiveTab('year-review')} + icon="📅" + label="Year Review" + count={yearReviewSessions.length} + /> {/* 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 }) { · {(session as SwotSession)._count.actions} actions + ) : isYearReview ? ( + <> + {(session as YearReviewSession)._count.items} items + · + Année {(session as YearReviewSession).year} + ) : ( {(session as MotivatorSession)._count.cards}/10 )} diff --git a/src/app/sessions/page.tsx b/src/app/sessions/page.tsx index cc6fab8..10b81d6 100644 --- a/src/app/sessions/page.tsx +++ b/src/app/sessions/page.tsx @@ -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() { - + + + @@ -88,8 +103,8 @@ export default async function SessionsPage() { Commencez votre premier atelier

- 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'année.

@@ -99,16 +114,26 @@ export default async function SessionsPage() { - + + +
) : ( }> - + )} diff --git a/src/app/year-review/[id]/EditableTitle.tsx b/src/app/year-review/[id]/EditableTitle.tsx new file mode 100644 index 0000000..7fba965 --- /dev/null +++ b/src/app/year-review/[id]/EditableTitle.tsx @@ -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(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

{title}

; + } + + if (isEditing) { + return ( + 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 ( + + ); +} diff --git a/src/app/year-review/[id]/page.tsx b/src/app/year-review/[id]/page.tsx new file mode 100644 index 0000000..eb091e3 --- /dev/null +++ b/src/app/year-review/[id]/page.tsx @@ -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 ( +
+ {/* Header */} +
+
+ + Year Review + + / + {session.title} + {!session.isOwner && ( + + Partagé par {session.user.name || session.user.email} + + )} +
+ +
+
+ +
+ +
+
+
+ {session.items.length} items + Année {session.year} + + {new Date(session.updatedAt).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} + +
+
+
+ + {/* Live Wrapper + Board */} + + + +
+ ); +} diff --git a/src/app/year-review/new/page.tsx b/src/app/year-review/new/page.tsx new file mode 100644 index 0000000..dbae987 --- /dev/null +++ b/src/app/year-review/new/page.tsx @@ -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(null); + + const currentYear = new Date().getFullYear(); + + async function handleSubmit(e: React.FormEvent) { + 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 ( +
+ + + + 📅 + Nouveau Bilan Annuel + + + Créez un bilan de l'année pour faire le point sur les réalisations, défis, + apprentissages et objectifs + + + + +
+ {error && ( +
+ {error} +
+ )} + + + + + +
+ + +
+ +
+

Comment ça marche ?

+
    +
  1. + Réalisations : Notez ce que vous avez accompli cette année +
  2. +
  3. + Défis : Identifiez les difficultés rencontrées +
  4. +
  5. + Apprentissages : Listez ce que vous avez appris et développé +
  6. +
  7. + Objectifs : Définissez vos objectifs pour l'année prochaine +
  8. +
  9. + Moments : Partagez les moments forts et marquants +
  10. +
+
+ +
+ + +
+
+
+
+
+ ); +} + diff --git a/src/components/session/EditableTitle.tsx b/src/components/session/EditableTitle.tsx index 558540f..37309b5 100644 --- a/src/components/session/EditableTitle.tsx +++ b/src/components/session/EditableTitle.tsx @@ -22,9 +22,12 @@ export function EditableTitle({ sessionId, initialTitle, isOwner }: EditableTitl } }, [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 useEffect(() => { if (!isEditing) { + // Synchronizing with external prop updates (e.g., from SSE) + // eslint-disable-next-line react-hooks/exhaustive-deps setTitle(initialTitle); } }, [initialTitle, isEditing]); diff --git a/src/components/year-review/YearReviewBoard.tsx b/src/components/year-review/YearReviewBoard.tsx new file mode 100644 index 0000000..b56a10e --- /dev/null +++ b/src/components/year-review/YearReviewBoard.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useTransition } from 'react'; +import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd'; +import type { YearReviewItem, YearReviewCategory } from '@prisma/client'; +import { YearReviewSection } from './YearReviewSection'; +import { YearReviewCard } from './YearReviewCard'; +import { moveYearReviewItem, reorderYearReviewItems } from '@/actions/year-review'; +import { YEAR_REVIEW_SECTIONS } from '@/lib/types'; + +interface YearReviewBoardProps { + sessionId: string; + items: YearReviewItem[]; +} + +export function YearReviewBoard({ sessionId, items }: YearReviewBoardProps) { + const [isPending, startTransition] = useTransition(); + + const itemsByCategory = YEAR_REVIEW_SECTIONS.reduce( + (acc, section) => { + acc[section.category] = items + .filter((item) => item.category === section.category) + .sort((a, b) => a.order - b.order); + return acc; + }, + {} as Record + ); + + function handleDragEnd(result: DropResult) { + if (!result.destination) return; + + const { source, destination, draggableId } = result; + const sourceCategory = source.droppableId as YearReviewCategory; + const destCategory = destination.droppableId as YearReviewCategory; + + // If same position, do nothing + if (sourceCategory === destCategory && source.index === destination.index) { + return; + } + + startTransition(async () => { + if (sourceCategory === destCategory) { + // Same category - just reorder + const categoryItems = itemsByCategory[sourceCategory]; + const itemIds = categoryItems.map((item) => item.id); + const [removed] = itemIds.splice(source.index, 1); + itemIds.splice(destination.index, 0, removed); + await reorderYearReviewItems(sessionId, sourceCategory, itemIds); + } else { + // Different category - move item + await moveYearReviewItem(draggableId, sessionId, destCategory, destination.index); + } + }); + } + + return ( +
+ {/* Year Review Sections */} + +
+ {YEAR_REVIEW_SECTIONS.map((section) => ( + + {(provided, snapshot) => ( + + {itemsByCategory[section.category].map((item, index) => ( + + {(dragProvided, dragSnapshot) => ( + + )} + + ))} + {provided.placeholder} + + )} + + ))} +
+
+
+ ); +} diff --git a/src/components/year-review/YearReviewCard.tsx b/src/components/year-review/YearReviewCard.tsx new file mode 100644 index 0000000..22dcd02 --- /dev/null +++ b/src/components/year-review/YearReviewCard.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { forwardRef, useState, useTransition } from 'react'; +import type { YearReviewItem } from '@prisma/client'; +import { updateYearReviewItem, deleteYearReviewItem } from '@/actions/year-review'; +import { YEAR_REVIEW_BY_CATEGORY } from '@/lib/types'; + +interface YearReviewCardProps { + item: YearReviewItem; + sessionId: string; + isDragging: boolean; +} + +export const YearReviewCard = forwardRef( + ({ item, sessionId, isDragging, ...props }, ref) => { + const [isEditing, setIsEditing] = useState(false); + const [content, setContent] = useState(item.content); + const [isPending, startTransition] = useTransition(); + + const config = YEAR_REVIEW_BY_CATEGORY[item.category]; + + async function handleSave() { + if (content.trim() === item.content) { + setIsEditing(false); + return; + } + + if (!content.trim()) { + // If empty, delete + startTransition(async () => { + await deleteYearReviewItem(item.id, sessionId); + }); + return; + } + + startTransition(async () => { + await updateYearReviewItem(item.id, sessionId, { content: content.trim() }); + setIsEditing(false); + }); + } + + async function handleDelete() { + startTransition(async () => { + await deleteYearReviewItem(item.id, sessionId); + }); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSave(); + } else if (e.key === 'Escape') { + setContent(item.content); + setIsEditing(false); + } + } + + return ( +
+ {isEditing ? ( +