From 448cf61e66fd663bbfa7f0f482fa78138f83e065 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Fri, 28 Nov 2025 08:40:39 +0100 Subject: [PATCH] feat: implement Moving Motivators feature with session management, real-time event handling, and UI components for enhanced user experience --- dev.db | Bin 147456 -> 229376 bytes package.json | 3 + pnpm-lock.yaml | 56 +++ .../migration.sql | 67 ++++ prisma/schema.prisma | 78 ++++ src/actions/moving-motivators.ts | 218 +++++++++++ .../api/motivators/[id]/subscribe/route.ts | 118 ++++++ src/app/motivators/[id]/EditableTitle.tsx | 110 ++++++ src/app/motivators/[id]/page.tsx | 88 +++++ src/app/motivators/new/page.tsx | 102 ++++++ src/app/motivators/page.tsx | 135 +++++++ src/app/page.tsx | 227 ++++++++---- src/app/sessions/WorkshopTabs.tsx | 272 ++++++++++++++ src/app/sessions/page.tsx | 170 ++++----- src/components/layout/Header.tsx | 83 ++++- .../moving-motivators/InfluenceZone.tsx | 143 ++++++++ .../moving-motivators/MotivatorBoard.tsx | 276 ++++++++++++++ .../moving-motivators/MotivatorCard.tsx | 172 +++++++++ .../MotivatorLiveWrapper.tsx | 138 +++++++ .../moving-motivators/MotivatorShareModal.tsx | 180 ++++++++++ .../moving-motivators/MotivatorSummary.tsx | 103 ++++++ src/components/moving-motivators/index.ts | 7 + src/hooks/useMotivatorLive.ts | 131 +++++++ src/lib/types.ts | 148 ++++++++ src/services/moving-motivators.ts | 339 ++++++++++++++++++ src/services/sessions.ts | 10 + 26 files changed, 3191 insertions(+), 183 deletions(-) create mode 100644 prisma/migrations/20251128071728_add_moving_motivators/migration.sql create mode 100644 src/actions/moving-motivators.ts create mode 100644 src/app/api/motivators/[id]/subscribe/route.ts create mode 100644 src/app/motivators/[id]/EditableTitle.tsx create mode 100644 src/app/motivators/[id]/page.tsx create mode 100644 src/app/motivators/new/page.tsx create mode 100644 src/app/motivators/page.tsx create mode 100644 src/app/sessions/WorkshopTabs.tsx create mode 100644 src/components/moving-motivators/InfluenceZone.tsx create mode 100644 src/components/moving-motivators/MotivatorBoard.tsx create mode 100644 src/components/moving-motivators/MotivatorCard.tsx create mode 100644 src/components/moving-motivators/MotivatorLiveWrapper.tsx create mode 100644 src/components/moving-motivators/MotivatorShareModal.tsx create mode 100644 src/components/moving-motivators/MotivatorSummary.tsx create mode 100644 src/components/moving-motivators/index.ts create mode 100644 src/hooks/useMotivatorLive.ts create mode 100644 src/services/moving-motivators.ts diff --git a/dev.db b/dev.db index 6ee2b29e6a8c085f1144a3342f2f494456df1b4f..7c5eb707a590a9c471eb8968a6ce2b9984e064bc 100644 GIT binary patch literal 229376 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 JA3N^P{}1Xd9zp;B delta 1206 zcmaKrU1%It6vywq^Re@>bMI`^?q+KCqtc4lcC*>0o26)`B2r(9CP*+%5#6lm#-N$l zgtFVRS^7{YwItz!V=MSUi?K-Aw1XWHjRk3-P^ltT`(RP~ph6!CN(n?<@10FjThxVt z%em*A`#-<`*;)g~Nd9|->nbaerd1Nct3px@A+ z)4tRC@P6YrW7unRc4*?HuGn->rK)O*9kWz>*DgBunI#p5iiIKCs60$Fhg2z_?#gFr z_o0}a&8D;4GhXk(^J1%xeL!xKOQfAd@bCCr?2}n?l#F<7wTswoZHG}wiwicSaNeTk z)E^oolYmU(2JT~`pTe~7?KjB$s;;8#m4yDod zF8WM$t(#nrLi}8Q=NRjDv-$iioA(5v2mey(T)oXb979nFwF$QBIx^#8JDr8X%P$53+`0{AIx*Ekjn8|M40zAI z6@$EDqFfp5=wENwe0+ZUuNuyOC8qwzz1!C?)M%s;r)Gnj zc0cAg6zJ|o-2J%@ljOc3eA9%>Q@A}zXi32s0iNX}_eA2GoM=V^sR5O{L}nQiM#MK$ ztIdutLJLZCWGde5!6`YeN;VPN|K=_i-T2C~67Gw?d*e<=qBqTH+9 z%-S>aEwa?b%z~@#1%9stxls3j4dW<(H^Y*}94l$A7Ju!+P6$&(ISEwxU0_@zUih4G z_Pud9GgtJ+;X@&rEP}1JMaSfXKVO1Rgf=V3z#35kM+E=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@electric-sql/pglite-socket@0.0.6': resolution: {integrity: sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw==} hasBin: true @@ -2612,6 +2643,31 @@ snapshots: '@chevrotain/utils@10.5.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.0)': + dependencies: + react: 19.2.0 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.0) + '@dnd-kit/utilities': 3.2.2(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@dnd-kit/utilities': 3.2.2(react@19.2.0) + react: 19.2.0 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.0)': + dependencies: + react: 19.2.0 + tslib: 2.8.1 + '@electric-sql/pglite-socket@0.0.6(@electric-sql/pglite@0.3.2)': dependencies: '@electric-sql/pglite': 0.3.2 diff --git a/prisma/migrations/20251128071728_add_moving_motivators/migration.sql b/prisma/migrations/20251128071728_add_moving_motivators/migration.sql new file mode 100644 index 0000000..8b4a6fa --- /dev/null +++ b/prisma/migrations/20251128071728_add_moving_motivators/migration.sql @@ -0,0 +1,67 @@ +-- CreateTable +CREATE TABLE "MovingMotivatorsSession" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "participant" TEXT NOT NULL, + "date" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "MovingMotivatorsSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "MotivatorCard" ( + "id" TEXT NOT NULL PRIMARY KEY, + "type" TEXT NOT NULL, + "orderIndex" INTEGER NOT NULL, + "influence" INTEGER NOT NULL DEFAULT 0, + "sessionId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "MotivatorCard_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "MovingMotivatorsSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "MMSessionShare" ( + "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 "MMSessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "MovingMotivatorsSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "MMSessionShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "MMSessionEvent" ( + "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 "MMSessionEvent_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "MovingMotivatorsSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "MMSessionEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "MovingMotivatorsSession_userId_idx" ON "MovingMotivatorsSession"("userId"); + +-- CreateIndex +CREATE INDEX "MotivatorCard_sessionId_idx" ON "MotivatorCard"("sessionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "MotivatorCard_sessionId_type_key" ON "MotivatorCard"("sessionId", "type"); + +-- CreateIndex +CREATE INDEX "MMSessionShare_sessionId_idx" ON "MMSessionShare"("sessionId"); + +-- CreateIndex +CREATE INDEX "MMSessionShare_userId_idx" ON "MMSessionShare"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "MMSessionShare_sessionId_userId_key" ON "MMSessionShare"("sessionId", "userId"); + +-- CreateIndex +CREATE INDEX "MMSessionEvent_sessionId_createdAt_idx" ON "MMSessionEvent"("sessionId", "createdAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 227f608..fb7b864 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,6 +17,10 @@ model User { sessions Session[] sharedSessions SessionShare[] sessionEvents SessionEvent[] + // Moving Motivators relations + motivatorSessions MovingMotivatorsSession[] + sharedMotivatorSessions MMSessionShare[] + motivatorSessionEvents MMSessionEvent[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -122,3 +126,77 @@ model SessionEvent { @@index([sessionId, createdAt]) } + +// ============================================ +// Moving Motivators Workshop +// ============================================ + +enum MotivatorType { + STATUS // Statut + POWER // Pouvoir + ORDER // Ordre + ACCEPTANCE // Acceptation + HONOR // Honneur + MASTERY // Maîtrise + SOCIAL // Relations sociales + FREEDOM // Liberté + CURIOSITY // Curiosité + PURPOSE // But +} + +model MovingMotivatorsSession { + id String @id @default(cuid()) + title String + participant String // Nom du participant + date DateTime @default(now()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + cards MotivatorCard[] + shares MMSessionShare[] + events MMSessionEvent[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) +} + +model MotivatorCard { + id String @id @default(cuid()) + type MotivatorType + orderIndex Int // Position horizontale (1-10, importance) + influence Int @default(0) // Position verticale (-3 à +3) + sessionId String + session MovingMotivatorsSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([sessionId, type]) // Une seule carte par type par session + @@index([sessionId]) +} + +model MMSessionShare { + id String @id @default(cuid()) + sessionId String + session MovingMotivatorsSession @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 MMSessionEvent { + id String @id @default(cuid()) + sessionId String + session MovingMotivatorsSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + type String // CARD_MOVED, CARD_INFLUENCE_CHANGED, etc. + payload String // JSON payload + createdAt DateTime @default(now()) + + @@index([sessionId, createdAt]) +} diff --git a/src/actions/moving-motivators.ts b/src/actions/moving-motivators.ts new file mode 100644 index 0000000..3ebff87 --- /dev/null +++ b/src/actions/moving-motivators.ts @@ -0,0 +1,218 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { auth } from '@/lib/auth'; +import * as motivatorsService from '@/services/moving-motivators'; + +// ============================================ +// Session Actions +// ============================================ + +export async function createMotivatorSession(data: { title: string; participant: string }) { + const session = await auth(); + if (!session?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + const motivatorSession = await motivatorsService.createMotivatorSession( + session.user.id, + data + ); + revalidatePath('/motivators'); + return { success: true, data: motivatorSession }; + } catch (error) { + console.error('Error creating motivator session:', error); + return { success: false, error: 'Erreur lors de la création' }; + } +} + +export async function updateMotivatorSession( + sessionId: string, + data: { title?: string; participant?: string } +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + await motivatorsService.updateMotivatorSession(sessionId, authSession.user.id, data); + + // Emit event for real-time sync + await motivatorsService.createMotivatorSessionEvent( + sessionId, + authSession.user.id, + 'SESSION_UPDATED', + data + ); + + revalidatePath(`/motivators/${sessionId}`); + revalidatePath('/motivators'); + return { success: true }; + } catch (error) { + console.error('Error updating motivator session:', error); + return { success: false, error: 'Erreur lors de la mise à jour' }; + } +} + +export async function deleteMotivatorSession(sessionId: string) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + await motivatorsService.deleteMotivatorSession(sessionId, authSession.user.id); + revalidatePath('/motivators'); + return { success: true }; + } catch (error) { + console.error('Error deleting motivator session:', error); + return { success: false, error: 'Erreur lors de la suppression' }; + } +} + +// ============================================ +// Card Actions +// ============================================ + +export async function updateMotivatorCard( + cardId: string, + sessionId: string, + data: { orderIndex?: number; influence?: number } +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + // Check edit permission + const canEdit = await motivatorsService.canEditMotivatorSession( + sessionId, + authSession.user.id + ); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + const card = await motivatorsService.updateMotivatorCard(cardId, data); + + // Emit event for real-time sync + if (data.influence !== undefined) { + await motivatorsService.createMotivatorSessionEvent( + sessionId, + authSession.user.id, + 'CARD_INFLUENCE_CHANGED', + { cardId, influence: data.influence, type: card.type } + ); + } else if (data.orderIndex !== undefined) { + await motivatorsService.createMotivatorSessionEvent( + sessionId, + authSession.user.id, + 'CARD_MOVED', + { cardId, orderIndex: data.orderIndex, type: card.type } + ); + } + + revalidatePath(`/motivators/${sessionId}`); + return { success: true, data: card }; + } catch (error) { + console.error('Error updating motivator card:', error); + return { success: false, error: 'Erreur lors de la mise à jour' }; + } +} + +export async function reorderMotivatorCards(sessionId: string, cardIds: string[]) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + // Check edit permission + const canEdit = await motivatorsService.canEditMotivatorSession( + sessionId, + authSession.user.id + ); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + await motivatorsService.reorderMotivatorCards(sessionId, cardIds); + + // Emit event for real-time sync + await motivatorsService.createMotivatorSessionEvent( + sessionId, + authSession.user.id, + 'CARDS_REORDERED', + { cardIds } + ); + + revalidatePath(`/motivators/${sessionId}`); + return { success: true }; + } catch (error) { + console.error('Error reordering motivator cards:', error); + return { success: false, error: 'Erreur lors du réordonnancement' }; + } +} + +export async function updateCardInfluence( + cardId: string, + sessionId: string, + influence: number +) { + return updateMotivatorCard(cardId, sessionId, { influence }); +} + +// ============================================ +// Sharing Actions +// ============================================ + +export async function shareMotivatorSession( + 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 motivatorsService.shareMotivatorSession( + sessionId, + authSession.user.id, + targetEmail, + role + ); + revalidatePath(`/motivators/${sessionId}`); + return { success: true, data: share }; + } catch (error) { + console.error('Error sharing motivator session:', error); + const message = + error instanceof Error ? error.message : 'Erreur lors du partage'; + return { success: false, error: message }; + } +} + +export async function removeMotivatorShare(sessionId: string, shareUserId: string) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + await motivatorsService.removeMotivatorShare( + sessionId, + authSession.user.id, + shareUserId + ); + revalidatePath(`/motivators/${sessionId}`); + return { success: true }; + } catch (error) { + console.error('Error removing motivator share:', error); + return { success: false, error: 'Erreur lors de la suppression du partage' }; + } +} + diff --git a/src/app/api/motivators/[id]/subscribe/route.ts b/src/app/api/motivators/[id]/subscribe/route.ts new file mode 100644 index 0000000..6facd00 --- /dev/null +++ b/src/app/api/motivators/[id]/subscribe/route.ts @@ -0,0 +1,118 @@ +import { auth } from '@/lib/auth'; +import { + canAccessMotivatorSession, + getMotivatorSessionEvents, +} from '@/services/moving-motivators'; + +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 canAccessMotivatorSession(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 getMotivatorSessionEvents(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 broadcastToMotivatorSession(sessionId: string, event: object) { + const sessionConnections = connections.get(sessionId); + if (!sessionConnections) 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 closed, will be cleaned up + } + } +} + diff --git a/src/app/motivators/[id]/EditableTitle.tsx b/src/app/motivators/[id]/EditableTitle.tsx new file mode 100644 index 0000000..4ff8d83 --- /dev/null +++ b/src/app/motivators/[id]/EditableTitle.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useState, useTransition, useRef, useEffect } from 'react'; +import { updateMotivatorSession } from '@/actions/moving-motivators'; + +interface EditableMotivatorTitleProps { + sessionId: string; + initialTitle: string; + isOwner: boolean; +} + +export function EditableMotivatorTitle({ + sessionId, + initialTitle, + isOwner, +}: EditableMotivatorTitleProps) { + const [isEditing, setIsEditing] = useState(false); + const [title, setTitle] = useState(initialTitle); + const [isPending, startTransition] = useTransition(); + const inputRef = useRef(null); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + // Update local state when prop changes (e.g., from SSE) + useEffect(() => { + if (!isEditing) { + 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 updateMotivatorSession(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/motivators/[id]/page.tsx b/src/app/motivators/[id]/page.tsx new file mode 100644 index 0000000..550a350 --- /dev/null +++ b/src/app/motivators/[id]/page.tsx @@ -0,0 +1,88 @@ +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { auth } from '@/lib/auth'; +import { getMotivatorSessionById } from '@/services/moving-motivators'; +import { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators'; +import { Badge } from '@/components/ui'; +import { EditableMotivatorTitle } from './EditableTitle'; + +interface MotivatorSessionPageProps { + params: Promise<{ id: string }>; +} + +export default async function MotivatorSessionPage({ params }: MotivatorSessionPageProps) { + const { id } = await params; + const authSession = await auth(); + + if (!authSession?.user?.id) { + return null; + } + + const session = await getMotivatorSessionById(id, authSession.user.id); + + if (!session) { + notFound(); + } + + return ( +
+ {/* Header */} +
+
+ + Moving Motivators + + / + {session.title} + {!session.isOwner && ( + + Partagé par {session.user.name || session.user.email} + + )} +
+ +
+
+ +

+ 👤 {session.participant} +

+
+
+ + {session.cards.filter((c) => c.influence !== 0).length} / 10 évalués + + + {new Date(session.date).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} + +
+
+
+ + {/* Live Wrapper + Board */} + + + +
+ ); +} + diff --git a/src/app/motivators/new/page.tsx b/src/app/motivators/new/page.tsx new file mode 100644 index 0000000..b2ffee7 --- /dev/null +++ b/src/app/motivators/new/page.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Input } from '@/components/ui'; +import { createMotivatorSession } from '@/actions/moving-motivators'; + +export default function NewMotivatorSessionPage() { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + 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; + + if (!title || !participant) { + setError('Veuillez remplir tous les champs'); + setLoading(false); + return; + } + + const result = await createMotivatorSession({ title, participant }); + + if (!result.success) { + setError(result.error || 'Une erreur est survenue'); + setLoading(false); + return; + } + + router.push(`/motivators/${result.data?.id}`); + } + + return ( +
+ + + + 🎯 + Nouvelle Session Moving Motivators + + + Créez une session pour explorer les motivations intrinsèques d'un collaborateur + + + + +
+ {error && ( +
+ {error} +
+ )} + + + + + +
+

Comment ça marche ?

+
    +
  1. Classez les 10 cartes de motivation par ordre d'importance
  2. +
  3. Évaluez l'influence positive ou négative de chaque motivation
  4. +
  5. Découvrez le récapitulatif des motivations clés
  6. +
+
+ +
+ + +
+
+
+
+
+ ); +} + diff --git a/src/app/motivators/page.tsx b/src/app/motivators/page.tsx new file mode 100644 index 0000000..db474fc --- /dev/null +++ b/src/app/motivators/page.tsx @@ -0,0 +1,135 @@ +import Link from 'next/link'; +import { auth } from '@/lib/auth'; +import { getMotivatorSessionsByUserId } from '@/services/moving-motivators'; +import { Card, CardContent, Badge, Button } from '@/components/ui'; + +export default async function MotivatorsPage() { + const session = await auth(); + + if (!session?.user?.id) { + return null; + } + + const sessions = await getMotivatorSessionsByUserId(session.user.id); + + // Separate owned vs shared sessions + const ownedSessions = sessions.filter((s) => s.isOwner); + const sharedSessions = sessions.filter((s) => !s.isOwner); + + return ( +
+ {/* Header */} +
+
+

Moving Motivators

+

+ Découvrez ce qui motive vraiment vos collaborateurs +

+
+ + + +
+ + {/* Sessions Grid */} + {sessions.length === 0 ? ( + +
🎯
+

+ Aucune session pour le moment +

+

+ Créez votre première session Moving Motivators pour explorer les motivations + intrinsèques de vos collaborateurs. +

+ + + +
+ ) : ( +
+ {/* My Sessions */} + {ownedSessions.length > 0 && ( +
+

+ 📁 Mes sessions ({ownedSessions.length}) +

+
+ {ownedSessions.map((s) => ( + + ))} +
+
+ )} + + {/* Shared Sessions */} + {sharedSessions.length > 0 && ( +
+

+ 🤝 Sessions partagées avec moi ({sharedSessions.length}) +

+
+ {sharedSessions.map((s) => ( + + ))} +
+
+ )} +
+ )} +
+ ); +} + +type SessionWithMeta = Awaited>[number]; + +function SessionCard({ session: s }: { session: SessionWithMeta }) { + return ( + + +
+
+

+ {s.title} +

+

{s.participant}

+ {!s.isOwner && ( +

+ Par {s.user.name || s.user.email} +

+ )} +
+
+ {!s.isOwner && ( + + {s.role === 'EDITOR' ? '✏️' : '👁️'} + + )} + 🎯 +
+
+ + +
+ + {s._count.cards} motivations + +
+ +

+ Mis à jour le{' '} + {new Date(s.updatedAt).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} +

+
+
+ + ); +} + diff --git a/src/app/page.tsx b/src/app/page.tsx index 6ae3649..b61cfdb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,95 +7,182 @@ export default function Home() { {/* Hero Section */}

- Analysez. Planifiez. Progressez. + Vos ateliers, réinventés

- Créez des ateliers SWOT interactifs avec vos collaborateurs. Identifiez les forces, - faiblesses, opportunités et menaces, puis définissez ensemble une roadmap - d'actions concrètes. + Des outils interactifs et collaboratifs pour accompagner vos équipes. + Analysez, comprenez et faites progresser vos collaborateurs avec des ateliers modernes.

- - - Nouvelle Session SWOT -
- {/* Features Grid */} + {/* Workshops Grid */}

- Comment ça marche ? + Choisissez votre atelier

-
- {/* Strength */} -
-
💪
-

Forces

-

- Les atouts et compétences sur lesquels s'appuyer pour progresser. -

-
+
+ {/* SWOT Workshop Card */} + - {/* Weakness */} -
-
⚠️
-

Faiblesses

-

- Les axes d'amélioration et points de vigilance à travailler. -

-
- - {/* Opportunity */} -
-
🚀
-

Opportunités

-

- Les occasions de développement et de croissance à saisir. -

-
- - {/* Threat */} -
-
🛡️
-

Menaces

-

- Les risques et obstacles potentiels à anticiper. -

-
+ {/* Moving Motivators Workshop Card */} +
- {/* Cross Actions Section */} -
-

🔗 Actions Croisées

-

- La puissance du SWOT réside dans le croisement des catégories. Liez vos forces à vos - opportunités, anticipez les menaces avec vos atouts, et transformez vos faiblesses en - axes de progression. -

-
- - S + O → Maximiser - - - S + T → Protéger - - - W + O → Améliorer - - - W + T → Surveiller - + {/* Benefits Section */} +
+

+ Pourquoi nos ateliers ? +

+
+ + +
{/* Footer */}
- SWOT Manager — Outil d'entretiens managériaux + Workshop Manager — Vos ateliers managériaux en ligne
); } + +function WorkshopCard({ + href, + icon, + title, + tagline, + description, + features, + accentColor, + newHref, +}: { + href: string; + icon: string; + title: string; + tagline: string; + description: string; + features: string[]; + accentColor: string; + newHref: string; +}) { + return ( +
+ {/* Accent gradient */} +
+ + {/* Icon & Title */} +
+ {icon} +
+

{title}

+

+ {tagline} +

+
+
+ + {/* Description */} +

{description}

+ + {/* Features */} +
    + {features.map((feature, i) => ( +
  • + + + + {feature} +
  • + ))} +
+ + {/* Actions */} +
+ + Démarrer + + + Mes sessions + +
+
+ ); +} + +function BenefitCard({ + icon, + title, + description, +}: { + icon: string; + title: string; + description: string; +}) { + return ( +
+
{icon}
+

{title}

+

{description}

+
+ ); +} diff --git a/src/app/sessions/WorkshopTabs.tsx b/src/app/sessions/WorkshopTabs.tsx new file mode 100644 index 0000000..b4655d2 --- /dev/null +++ b/src/app/sessions/WorkshopTabs.tsx @@ -0,0 +1,272 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { Card, Badge } from '@/components/ui'; + +type WorkshopType = 'all' | 'swot' | 'motivators'; + +interface ShareUser { + id: string; + name: string | null; + email: string; +} + +interface Share { + id: string; + role: 'VIEWER' | 'EDITOR'; + user: ShareUser; +} + +interface SwotSession { + id: string; + title: string; + collaborator: string; + 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; + updatedAt: Date; + isOwner: boolean; + role: 'OWNER' | 'VIEWER' | 'EDITOR'; + user: { id: string; name: string | null; email: string }; + shares: Share[]; + _count: { cards: number }; + workshopType: 'motivators'; +} + +type AnySession = SwotSession | MotivatorSession; + +interface WorkshopTabsProps { + swotSessions: SwotSession[]; + motivatorSessions: MotivatorSession[]; +} + +export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsProps) { + const [activeTab, setActiveTab] = useState('all'); + + // Combine and sort all sessions + const allSessions: AnySession[] = [...swotSessions, ...motivatorSessions].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + + // Filter based on active tab + const filteredSessions = + activeTab === 'all' + ? allSessions + : activeTab === 'swot' + ? swotSessions + : motivatorSessions; + + // Separate by ownership + const ownedSessions = filteredSessions.filter((s) => s.isOwner); + const sharedSessions = filteredSessions.filter((s) => !s.isOwner); + + return ( +
+ {/* Tabs */} +
+ setActiveTab('all')} + icon="📋" + label="Tous" + count={allSessions.length} + /> + setActiveTab('swot')} + icon="📊" + label="SWOT" + count={swotSessions.length} + /> + setActiveTab('motivators')} + icon="🎯" + label="Moving Motivators" + count={motivatorSessions.length} + /> +
+ + {/* Sessions */} + {filteredSessions.length === 0 ? ( +
+ Aucun atelier de ce type pour le moment +
+ ) : ( +
+ {/* My Sessions */} + {ownedSessions.length > 0 && ( +
+

+ 📁 Mes ateliers ({ownedSessions.length}) +

+
+ {ownedSessions.map((s) => ( + + ))} +
+
+ )} + + {/* Shared Sessions */} + {sharedSessions.length > 0 && ( +
+

+ 🤝 Partagés avec moi ({sharedSessions.length}) +

+
+ {sharedSessions.map((s) => ( + + ))} +
+
+ )} +
+ )} +
+ ); +} + +function TabButton({ + active, + onClick, + icon, + label, + count, +}: { + active: boolean; + onClick: () => void; + icon: string; + label: string; + count: number; +}) { + return ( + + ); +} + +function SessionCard({ session }: { session: AnySession }) { + const isSwot = session.workshopType === 'swot'; + const href = isSwot ? `/sessions/${session.id}` : `/motivators/${session.id}`; + const icon = isSwot ? '📊' : '🎯'; + const participant = isSwot + ? (session as SwotSession).collaborator + : (session as MotivatorSession).participant; + const accentColor = isSwot ? '#06b6d4' : '#8b5cf6'; + + return ( + + + {/* Accent bar */} +
+ + {/* Header: Icon + Title + Role badge */} +
+ {icon} +

+ {session.title} +

+ {!session.isOwner && ( + + {session.role === 'EDITOR' ? '✏️' : '👁️'} + + )} +
+ + {/* Participant + Owner info */} +

+ 👤 {participant} + {!session.isOwner && ( + · par {session.user.name || session.user.email} + )} +

+ + {/* Footer: Stats + Avatars + Date */} +
+ {/* Stats */} +
+ {isSwot ? ( + <> + {(session as SwotSession)._count.items} items + · + {(session as SwotSession)._count.actions} actions + + ) : ( + {(session as MotivatorSession)._count.cards}/10 + )} +
+ + {/* Date */} + + {new Date(session.updatedAt).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + })} + +
+ + {/* Shared with */} + {session.isOwner && session.shares.length > 0 && ( +
+ Partagé +
+ {session.shares.slice(0, 3).map((share) => ( +
+ + {share.user.name?.split(' ')[0] || share.user.email.split('@')[0]} + + {share.role === 'EDITOR' ? '✏️' : '👁️'} +
+ ))} + {session.shares.length > 3 && ( + + +{session.shares.length - 3} + + )} +
+
+ )} + + + ); +} + diff --git a/src/app/sessions/page.tsx b/src/app/sessions/page.tsx index 1663d17..2369f7a 100644 --- a/src/app/sessions/page.tsx +++ b/src/app/sessions/page.tsx @@ -1,7 +1,9 @@ import Link from 'next/link'; import { auth } from '@/lib/auth'; import { getSessionsByUserId } from '@/services/sessions'; +import { getMotivatorSessionsByUserId } from '@/services/moving-motivators'; import { Card, CardContent, Badge, Button } from '@/components/ui'; +import { WorkshopTabs } from './WorkshopTabs'; export default async function SessionsPage() { const session = await auth(); @@ -10,129 +12,87 @@ export default async function SessionsPage() { return null; } - const sessions = await getSessionsByUserId(session.user.id); + // Fetch both SWOT and Moving Motivators sessions + const [swotSessions, motivatorSessions] = await Promise.all([ + getSessionsByUserId(session.user.id), + getMotivatorSessionsByUserId(session.user.id), + ]); - // Separate owned vs shared sessions - const ownedSessions = sessions.filter((s) => s.isOwner); - const sharedSessions = sessions.filter((s) => !s.isOwner); + // Add type to each session for unified display + const allSwotSessions = swotSessions.map((s) => ({ + ...s, + workshopType: 'swot' as const, + })); + + const allMotivatorSessions = motivatorSessions.map((s) => ({ + ...s, + workshopType: 'motivators' 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 hasNoSessions = allSessions.length === 0; return (
{/* Header */} -
+
-

Mes Sessions SWOT

+

Mes Ateliers

- Gérez vos ateliers SWOT avec vos collaborateurs + Tous vos ateliers en un seul endroit

- - - +
+ + + + + + +
- {/* Sessions Grid */} - {sessions.length === 0 ? ( + {/* Content */} + {hasNoSessions ? ( -
📋
+
🚀

- Aucune session pour le moment + Commencez votre premier atelier

-

- Créez votre première session SWOT pour commencer à analyser les forces, - faiblesses, opportunités et menaces de vos collaborateurs. +

+ Créez un atelier SWOT pour analyser les forces et faiblesses, ou un Moving Motivators pour découvrir les motivations de vos collaborateurs.

- - - +
+ + + + + + +
) : ( -
- {/* My Sessions */} - {ownedSessions.length > 0 && ( -
-

- 📁 Mes sessions ({ownedSessions.length}) -

-
- {ownedSessions.map((s) => ( - - ))} -
-
- )} - - {/* Shared Sessions */} - {sharedSessions.length > 0 && ( -
-

- 🤝 Sessions partagées avec moi ({sharedSessions.length}) -

-
- {sharedSessions.map((s) => ( - - ))} -
-
- )} -
+ )}
); } - -type SessionWithMeta = Awaited>[number]; - -function SessionCard({ session: s }: { session: SessionWithMeta }) { - return ( - - -
-
-

- {s.title} -

-

{s.collaborator}

- {!s.isOwner && ( -

- Par {s.user.name || s.user.email} -

- )} -
-
- {!s.isOwner && ( - - {s.role === 'EDITOR' ? '✏️' : '👁️'} - - )} - 📊 -
-
- - -
- - {s._count.items} items - - - {s._count.actions} actions - -
- -

- Mis à jour le{' '} - {new Date(s.updatedAt).toLocaleDateString('fr-FR', { - day: 'numeric', - month: 'long', - year: 'numeric', - })} -

-
-
- - ); -} - diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 5c0021c..e7b6e2d 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,6 +1,7 @@ 'use client'; import Link from 'next/link'; +import { usePathname } from 'next/navigation'; import { useSession, signOut } from 'next-auth/react'; import { useTheme } from '@/contexts/ThemeContext'; import { useState } from 'react'; @@ -9,23 +10,89 @@ export function Header() { const { theme, toggleTheme } = useTheme(); const { data: session, status } = useSession(); const [menuOpen, setMenuOpen] = useState(false); + const [workshopsOpen, setWorkshopsOpen] = useState(false); + const pathname = usePathname(); + + const isActiveLink = (path: string) => pathname.startsWith(path); return (
- 📊 - SWOT Manager + 🚀 + Workshop Manager
+
+ )} + + {step === 'influence' && ( +
+
+

+ Évaluez l'influence de chaque motivation +

+

+ Pour chaque carte, indiquez si cette motivation a une influence positive ou négative sur votre situation actuelle +

+
+ + + + {/* Navigation buttons */} +
+ + +
+
+ )} + + {step === 'summary' && ( +
+
+

+ Récapitulatif de vos Moving Motivators +

+

+ Voici l'analyse de vos motivations et leur impact +

+
+ + + + {/* Navigation buttons */} +
+ +
+
+ )} +
+ ); +} + +function StepIndicator({ + number, + label, + active, + completed, + onClick, +}: { + number: number; + label: string; + active: boolean; + completed: boolean; + onClick: () => void; +}) { + return ( + + ); +} + diff --git a/src/components/moving-motivators/MotivatorCard.tsx b/src/components/moving-motivators/MotivatorCard.tsx new file mode 100644 index 0000000..ee30dfe --- /dev/null +++ b/src/components/moving-motivators/MotivatorCard.tsx @@ -0,0 +1,172 @@ +'use client'; + +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import type { MotivatorCard as MotivatorCardType } from '@/lib/types'; +import { MOTIVATOR_BY_TYPE } from '@/lib/types'; + +interface MotivatorCardProps { + card: MotivatorCardType; + onInfluenceChange?: (influence: number) => void; + disabled?: boolean; + showInfluence?: boolean; +} + +export function MotivatorCard({ + card, + disabled = false, + showInfluence = false, +}: MotivatorCardProps) { + const config = MOTIVATOR_BY_TYPE[card.type]; + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: card.id, + disabled, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ {/* Color accent bar */} +
+ + {/* Icon */} +
{config.icon}
+ + {/* Name */} +
+ {config.name} +
+ + {/* Description */} +

+ {config.description} +

+ + {/* Influence indicator */} + {showInfluence && card.influence !== 0 && ( +
0 ? 'bg-green-500' : 'bg-red-500'} + `} + > + {card.influence > 0 ? `+${card.influence}` : card.influence} +
+ )} + + {/* Rank badge */} +
+ {card.orderIndex} +
+
+ ); +} + +// Non-draggable version for summary +export function MotivatorCardStatic({ + card, + size = 'normal', +}: { + card: MotivatorCardType; + size?: 'small' | 'normal'; +}) { + const config = MOTIVATOR_BY_TYPE[card.type]; + + const sizeClasses = { + small: 'w-20 h-24 text-2xl', + normal: 'w-28 h-36 text-3xl', + }; + + return ( +
+ {/* Color accent bar */} +
+ + {/* Icon */} +
+ {config.icon} +
+ + {/* Name */} +
+ {config.name} +
+ + {/* Influence indicator */} + {card.influence !== 0 && ( +
0 ? 'bg-green-500' : 'bg-red-500'} + ${size === 'small' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs'} + `} + > + {card.influence > 0 ? `+${card.influence}` : card.influence} +
+ )} + + {/* Rank badge */} +
+ {card.orderIndex} +
+
+ ); +} + diff --git a/src/components/moving-motivators/MotivatorLiveWrapper.tsx b/src/components/moving-motivators/MotivatorLiveWrapper.tsx new file mode 100644 index 0000000..269df27 --- /dev/null +++ b/src/components/moving-motivators/MotivatorLiveWrapper.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useMotivatorLive, type MotivatorLiveEvent } from '@/hooks/useMotivatorLive'; +import { LiveIndicator } from '@/components/collaboration/LiveIndicator'; +import { MotivatorShareModal } from './MotivatorShareModal'; +import { Button } from '@/components/ui/Button'; +import type { ShareRole } from '@prisma/client'; + +interface ShareUser { + id: string; + name: string | null; + email: string; +} + +interface Share { + id: string; + role: ShareRole; + user: ShareUser; + createdAt: Date; +} + +interface MotivatorLiveWrapperProps { + sessionId: string; + sessionTitle: string; + currentUserId: string; + shares: Share[]; + isOwner: boolean; + canEdit: boolean; + children: React.ReactNode; +} + +export function MotivatorLiveWrapper({ + sessionId, + sessionTitle, + currentUserId, + shares, + isOwner, + canEdit, + children, +}: MotivatorLiveWrapperProps) { + const [shareModalOpen, setShareModalOpen] = useState(false); + const [lastEventUser, setLastEventUser] = useState(null); + + const handleEvent = useCallback((event: MotivatorLiveEvent) => { + // Show who made the last change + if (event.user?.name || event.user?.email) { + setLastEventUser(event.user.name || event.user.email); + // Clear after 3 seconds + setTimeout(() => setLastEventUser(null), 3000); + } + }, []); + + const { isConnected, error } = useMotivatorLive({ + sessionId, + currentUserId, + onEvent: handleEvent, + }); + + return ( + <> + {/* Header toolbar */} +
+
+ + + {lastEventUser && ( +
+ ✏️ + {lastEventUser} édite... +
+ )} + + {!canEdit && ( +
+ 👁️ + Mode lecture +
+ )} +
+ +
+ {/* Collaborators avatars */} + {shares.length > 0 && ( +
+ {shares.slice(0, 3).map((share) => ( +
+ {share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()} +
+ ))} + {shares.length > 3 && ( +
+ +{shares.length - 3} +
+ )} +
+ )} + + +
+
+ + {/* Content */} +
+ {children} +
+ + {/* Share Modal */} + setShareModalOpen(false)} + sessionId={sessionId} + sessionTitle={sessionTitle} + shares={shares} + isOwner={isOwner} + /> + + ); +} + diff --git a/src/components/moving-motivators/MotivatorShareModal.tsx b/src/components/moving-motivators/MotivatorShareModal.tsx new file mode 100644 index 0000000..f0422a0 --- /dev/null +++ b/src/components/moving-motivators/MotivatorShareModal.tsx @@ -0,0 +1,180 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { Modal } from '@/components/ui/Modal'; +import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/Button'; +import { Badge } from '@/components/ui/Badge'; +import { shareMotivatorSession, removeMotivatorShare } from '@/actions/moving-motivators'; +import type { ShareRole } from '@prisma/client'; + +interface ShareUser { + id: string; + name: string | null; + email: string; +} + +interface Share { + id: string; + role: ShareRole; + user: ShareUser; + createdAt: Date; +} + +interface MotivatorShareModalProps { + isOpen: boolean; + onClose: () => void; + sessionId: string; + sessionTitle: string; + shares: Share[]; + isOwner: boolean; +} + +export function MotivatorShareModal({ + isOpen, + onClose, + sessionId, + sessionTitle, + shares, + isOwner, +}: MotivatorShareModalProps) { + const [email, setEmail] = useState(''); + const [role, setRole] = useState('EDITOR'); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + async function handleShare(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + startTransition(async () => { + const result = await shareMotivatorSession(sessionId, email, role); + if (result.success) { + setEmail(''); + } else { + setError(result.error || 'Erreur lors du partage'); + } + }); + } + + async function handleRemove(userId: string) { + startTransition(async () => { + await removeMotivatorShare(sessionId, userId); + }); + } + + return ( + +
+ {/* Session info */} +
+

Session Moving Motivators

+

{sessionTitle}

+
+ + {/* Share form (only for owner) */} + {isOwner && ( +
+
+ setEmail(e.target.value)} + className="flex-1" + required + /> + +
+ + {error &&

{error}

} + + +
+ )} + + {/* Current shares */} +
+

+ Collaborateurs ({shares.length}) +

+ + {shares.length === 0 ? ( +

+ Aucun collaborateur pour le moment +

+ ) : ( +
    + {shares.map((share) => ( +
  • +
    +
    + {share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()} +
    +
    +

    + {share.user.name || share.user.email} +

    + {share.user.name && ( +

    {share.user.email}

    + )} +
    +
    + +
    + + {share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'} + + {isOwner && ( + + )} +
    +
  • + ))} +
+ )} +
+ + {/* Help text */} +
+

+ Éditeur : peut modifier les cartes et leurs positions +
+ Lecteur : peut uniquement consulter +

+
+
+
+ ); +} + diff --git a/src/components/moving-motivators/MotivatorSummary.tsx b/src/components/moving-motivators/MotivatorSummary.tsx new file mode 100644 index 0000000..2db8648 --- /dev/null +++ b/src/components/moving-motivators/MotivatorSummary.tsx @@ -0,0 +1,103 @@ +'use client'; + +import type { MotivatorCard as MotivatorCardType } from '@/lib/types'; +import { MotivatorCardStatic } from './MotivatorCard'; + +interface MotivatorSummaryProps { + cards: MotivatorCardType[]; +} + +export function MotivatorSummary({ cards }: MotivatorSummaryProps) { + // Sort by orderIndex (importance) + const sortedByImportance = [...cards].sort((a, b) => a.orderIndex - b.orderIndex); + + // Top 3 most important (highest orderIndex) + const top3 = sortedByImportance.slice(-3).reverse(); + + // Bottom 3 least important (lowest orderIndex) + const bottom3 = sortedByImportance.slice(0, 3); + + // Cards with positive influence + const positiveInfluence = cards.filter((c) => c.influence > 0).sort((a, b) => b.influence - a.influence); + + // Cards with negative influence + const negativeInfluence = cards.filter((c) => c.influence < 0).sort((a, b) => a.influence - b.influence); + + return ( +
+ {/* Top 3 Most Important */} + + + {/* Bottom 3 Least Important */} + + + {/* Positive Influence */} + + + {/* Negative Influence */} + +
+ ); +} + +function SummarySection({ + title, + subtitle, + cards, + emptyMessage, + variant, +}: { + title: string; + subtitle: string; + cards: MotivatorCardType[]; + emptyMessage: string; + variant: 'success' | 'danger' | 'muted'; +}) { + const borderColors = { + success: 'border-green-500/30 bg-green-500/5', + danger: 'border-red-500/30 bg-red-500/5', + muted: 'border-border bg-muted/5', + }; + + return ( +
+

{title}

+

{subtitle}

+ + {cards.length > 0 ? ( +
+ {cards.map((card) => ( + + ))} +
+ ) : ( +

{emptyMessage}

+ )} +
+ ); +} + diff --git a/src/components/moving-motivators/index.ts b/src/components/moving-motivators/index.ts new file mode 100644 index 0000000..59609e1 --- /dev/null +++ b/src/components/moving-motivators/index.ts @@ -0,0 +1,7 @@ +export { MotivatorBoard } from './MotivatorBoard'; +export { MotivatorCard, MotivatorCardStatic } from './MotivatorCard'; +export { MotivatorSummary } from './MotivatorSummary'; +export { InfluenceZone } from './InfluenceZone'; +export { MotivatorLiveWrapper } from './MotivatorLiveWrapper'; +export { MotivatorShareModal } from './MotivatorShareModal'; + diff --git a/src/hooks/useMotivatorLive.ts b/src/hooks/useMotivatorLive.ts new file mode 100644 index 0000000..927ffd0 --- /dev/null +++ b/src/hooks/useMotivatorLive.ts @@ -0,0 +1,131 @@ +'use client'; + +import { useEffect, useState, useRef } from 'react'; +import { useRouter } from 'next/navigation'; + +export type MotivatorLiveEvent = { + type: string; + payload: Record; + userId?: string; + user?: { id: string; name: string | null; email: string }; + timestamp: string; +}; + +interface UseMotivatorLiveOptions { + sessionId: string; + currentUserId?: string; + enabled?: boolean; + onEvent?: (event: MotivatorLiveEvent) => void; +} + +interface UseMotivatorLiveReturn { + isConnected: boolean; + lastEvent: MotivatorLiveEvent | null; + error: string | null; +} + +export function useMotivatorLive({ + sessionId, + currentUserId, + enabled = true, + onEvent, +}: UseMotivatorLiveOptions): UseMotivatorLiveReturn { + const [isConnected, setIsConnected] = useState(false); + const [lastEvent, setLastEvent] = useState(null); + const [error, setError] = useState(null); + const router = useRouter(); + const eventSourceRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const reconnectAttemptsRef = useRef(0); + const onEventRef = useRef(onEvent); + const currentUserIdRef = useRef(currentUserId); + + // Keep refs updated + useEffect(() => { + onEventRef.current = onEvent; + }, [onEvent]); + + useEffect(() => { + currentUserIdRef.current = currentUserId; + }, [currentUserId]); + + useEffect(() => { + if (!enabled || typeof window === 'undefined') return; + + function connect() { + // Close existing connection + if (eventSourceRef.current) { + eventSourceRef.current.close(); + } + + try { + const eventSource = new EventSource(`/api/motivators/${sessionId}/subscribe`); + eventSourceRef.current = eventSource; + + eventSource.onopen = () => { + setIsConnected(true); + setError(null); + reconnectAttemptsRef.current = 0; + }; + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as MotivatorLiveEvent; + + // Handle connection event + if (data.type === 'connected') { + return; + } + + // Client-side filter: ignore events created by current user + if (currentUserIdRef.current && data.userId === currentUserIdRef.current) { + return; + } + + setLastEvent(data); + onEventRef.current?.(data); + + // Refresh the page data when we receive an event from another user + router.refresh(); + } catch (e) { + console.error('Failed to parse SSE event:', e); + } + }; + + eventSource.onerror = () => { + setIsConnected(false); + eventSource.close(); + + // Exponential backoff reconnect + const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000); + reconnectAttemptsRef.current++; + + if (reconnectAttemptsRef.current <= 5) { + reconnectTimeoutRef.current = setTimeout(connect, delay); + } else { + setError('Connexion perdue. Rechargez la page.'); + } + }; + } catch (e) { + setError('Impossible de se connecter au mode live'); + console.error('Failed to create EventSource:', e); + } + } + + connect(); + + return () => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + }; + }, [sessionId, enabled, router]); + + return { isConnected, lastEvent, error }; +} + diff --git a/src/lib/types.ts b/src/lib/types.ts index 51ce245..90f2d24 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -152,3 +152,151 @@ export const STATUS_LABELS: Record = { done: 'Terminé', }; +// ============================================ +// Moving Motivators - Type Definitions +// ============================================ + +export type MotivatorType = + | 'STATUS' + | 'POWER' + | 'ORDER' + | 'ACCEPTANCE' + | 'HONOR' + | 'MASTERY' + | 'SOCIAL' + | 'FREEDOM' + | 'CURIOSITY' + | 'PURPOSE'; + +export interface MotivatorCard { + id: string; + type: MotivatorType; + orderIndex: number; // 1-10, position horizontale (importance) + influence: number; // -3 à +3, position verticale + sessionId: string; + createdAt: Date; + updatedAt: Date; +} + +export interface MovingMotivatorsSession { + id: string; + title: string; + participant: string; + date: Date; + userId: string; + cards: MotivatorCard[]; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateMotivatorSessionInput { + title: string; + participant: string; + date?: Date; +} + +export interface UpdateMotivatorSessionInput { + title?: string; + participant?: string; + date?: Date; +} + +export interface UpdateMotivatorCardInput { + orderIndex?: number; + influence?: number; +} + +// ============================================ +// Moving Motivators - UI Config +// ============================================ + +export interface MotivatorConfig { + type: MotivatorType; + name: string; + icon: string; + description: string; + color: string; +} + +export const MOTIVATORS_CONFIG: MotivatorConfig[] = [ + { + type: 'STATUS', + name: 'Statut', + icon: '👑', + description: 'Être reconnu et respecté pour sa position', + color: '#8b5cf6', // purple + }, + { + type: 'POWER', + name: 'Pouvoir', + icon: '⚡', + description: 'Avoir de l\'influence et du contrôle sur les décisions', + color: '#ef4444', // red + }, + { + type: 'ORDER', + name: 'Ordre', + icon: '📋', + description: 'Avoir un environnement stable et prévisible', + color: '#6b7280', // gray + }, + { + type: 'ACCEPTANCE', + name: 'Acceptation', + icon: '🤝', + description: 'Être accepté et approuvé par le groupe', + color: '#f59e0b', // amber + }, + { + type: 'HONOR', + name: 'Honneur', + icon: '🏅', + description: 'Agir en accord avec ses valeurs personnelles', + color: '#eab308', // yellow + }, + { + type: 'MASTERY', + name: 'Maîtrise', + icon: '🎯', + description: 'Développer ses compétences et exceller', + color: '#22c55e', // green + }, + { + type: 'SOCIAL', + name: 'Relations', + icon: '👥', + description: 'Créer des liens et appartenir à un groupe', + color: '#ec4899', // pink + }, + { + type: 'FREEDOM', + name: 'Liberté', + icon: '🦅', + description: 'Être autonome et indépendant', + color: '#06b6d4', // cyan + }, + { + type: 'CURIOSITY', + name: 'Curiosité', + icon: '🔍', + description: 'Explorer, apprendre et découvrir', + color: '#3b82f6', // blue + }, + { + type: 'PURPOSE', + name: 'But', + icon: '🧭', + description: 'Avoir un sens et contribuer à quelque chose de plus grand', + color: '#14b8a6', // teal + }, +]; + +export const MOTIVATOR_BY_TYPE: Record = + MOTIVATORS_CONFIG.reduce( + (acc, config) => { + acc[config.type] = config; + return acc; + }, + {} as Record + ); + diff --git a/src/services/moving-motivators.ts b/src/services/moving-motivators.ts new file mode 100644 index 0000000..362b979 --- /dev/null +++ b/src/services/moving-motivators.ts @@ -0,0 +1,339 @@ +import { prisma } from '@/services/database'; +import type { ShareRole, MotivatorType } from '@prisma/client'; + +// ============================================ +// Moving Motivators Session CRUD +// ============================================ + +export async function getMotivatorSessionsByUserId(userId: string) { + // Get owned sessions + shared sessions + const [owned, shared] = await Promise.all([ + prisma.movingMotivatorsSession.findMany({ + where: { userId }, + include: { + user: { select: { id: true, name: true, email: true } }, + shares: { + include: { + user: { select: { id: true, name: true, email: true } }, + }, + }, + _count: { + select: { + cards: true, + }, + }, + }, + orderBy: { updatedAt: 'desc' }, + }), + prisma.mMSessionShare.findMany({ + where: { userId }, + include: { + session: { + include: { + user: { select: { id: true, name: true, email: true } }, + shares: { + include: { + user: { select: { id: true, name: true, email: true } }, + }, + }, + _count: { + select: { + cards: true, + }, + }, + }, + }, + }, + }), + ]); + + // Mark owned sessions and merge with shared + const ownedWithRole = owned.map((s) => ({ ...s, isOwner: true as const, role: 'OWNER' as const })); + const sharedWithRole = shared.map((s) => ({ + ...s.session, + isOwner: false as const, + role: s.role, + sharedAt: s.createdAt, + })); + + return [...ownedWithRole, ...sharedWithRole].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); +} + +export async function getMotivatorSessionById(sessionId: string, userId: string) { + // Check if user owns the session OR has it shared + const session = await prisma.movingMotivatorsSession.findFirst({ + where: { + id: sessionId, + OR: [ + { userId }, // Owner + { shares: { some: { userId } } }, // Shared with user + ], + }, + include: { + user: { select: { id: true, name: true, email: true } }, + cards: { + orderBy: { orderIndex: 'asc' }, + }, + shares: { + include: { + user: { select: { id: true, name: true, email: true } }, + }, + }, + }, + }); + + if (!session) return null; + + // Determine user's role + const isOwner = session.userId === userId; + const share = session.shares.find((s) => s.userId === userId); + const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const); + const canEdit = isOwner || role === 'EDITOR'; + + return { ...session, isOwner, role, canEdit }; +} + +// Check if user can access session (owner or shared) +export async function canAccessMotivatorSession(sessionId: string, userId: string) { + const count = await prisma.movingMotivatorsSession.count({ + where: { + id: sessionId, + OR: [{ userId }, { shares: { some: { userId } } }], + }, + }); + return count > 0; +} + +// Check if user can edit session (owner or EDITOR role) +export async function canEditMotivatorSession(sessionId: string, userId: string) { + const count = await prisma.movingMotivatorsSession.count({ + where: { + id: sessionId, + OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }], + }, + }); + return count > 0; +} + +const DEFAULT_MOTIVATOR_TYPES: MotivatorType[] = [ + 'STATUS', + 'POWER', + 'ORDER', + 'ACCEPTANCE', + 'HONOR', + 'MASTERY', + 'SOCIAL', + 'FREEDOM', + 'CURIOSITY', + 'PURPOSE', +]; + +export async function createMotivatorSession( + userId: string, + data: { title: string; participant: string } +) { + // Create session with all 10 cards initialized + return prisma.movingMotivatorsSession.create({ + data: { + ...data, + userId, + cards: { + create: DEFAULT_MOTIVATOR_TYPES.map((type, index) => ({ + type, + orderIndex: index + 1, + influence: 0, + })), + }, + }, + include: { + cards: { + orderBy: { orderIndex: 'asc' }, + }, + }, + }); +} + +export async function updateMotivatorSession( + sessionId: string, + userId: string, + data: { title?: string; participant?: string } +) { + return prisma.movingMotivatorsSession.updateMany({ + where: { id: sessionId, userId }, + data, + }); +} + +export async function deleteMotivatorSession(sessionId: string, userId: string) { + return prisma.movingMotivatorsSession.deleteMany({ + where: { id: sessionId, userId }, + }); +} + +// ============================================ +// Motivator Cards CRUD +// ============================================ + +export async function updateMotivatorCard( + cardId: string, + data: { orderIndex?: number; influence?: number } +) { + return prisma.motivatorCard.update({ + where: { id: cardId }, + data, + }); +} + +export async function reorderMotivatorCards( + sessionId: string, + cardIds: string[] +) { + const updates = cardIds.map((id, index) => + prisma.motivatorCard.update({ + where: { id }, + data: { orderIndex: index + 1 }, + }) + ); + + return prisma.$transaction(updates); +} + +export async function updateCardInfluence(cardId: string, influence: number) { + // Clamp influence between -3 and +3 + const clampedInfluence = Math.max(-3, Math.min(3, influence)); + return prisma.motivatorCard.update({ + where: { id: cardId }, + data: { influence: clampedInfluence }, + }); +} + +// ============================================ +// Session Sharing +// ============================================ + +export async function shareMotivatorSession( + sessionId: string, + ownerId: string, + targetEmail: string, + role: ShareRole = 'EDITOR' +) { + // Verify owner + const session = await prisma.movingMotivatorsSession.findFirst({ + where: { id: sessionId, userId: ownerId }, + }); + if (!session) { + throw new Error('Session not found or not owned'); + } + + // Find target user + const targetUser = await prisma.user.findUnique({ + where: { email: targetEmail }, + }); + if (!targetUser) { + throw new Error('User not found'); + } + + // Can't share with yourself + if (targetUser.id === ownerId) { + throw new Error('Cannot share session with yourself'); + } + + // Create or update share + return prisma.mMSessionShare.upsert({ + where: { + sessionId_userId: { sessionId, userId: targetUser.id }, + }, + update: { role }, + create: { + sessionId, + userId: targetUser.id, + role, + }, + include: { + user: { select: { id: true, name: true, email: true } }, + }, + }); +} + +export async function removeMotivatorShare( + sessionId: string, + ownerId: string, + shareUserId: string +) { + // Verify owner + const session = await prisma.movingMotivatorsSession.findFirst({ + where: { id: sessionId, userId: ownerId }, + }); + if (!session) { + throw new Error('Session not found or not owned'); + } + + return prisma.mMSessionShare.deleteMany({ + where: { sessionId, userId: shareUserId }, + }); +} + +export async function getMotivatorSessionShares(sessionId: string, userId: string) { + // Verify access + if (!(await canAccessMotivatorSession(sessionId, userId))) { + throw new Error('Access denied'); + } + + return prisma.mMSessionShare.findMany({ + where: { sessionId }, + include: { + user: { select: { id: true, name: true, email: true } }, + }, + }); +} + +// ============================================ +// Session Events (for real-time sync) +// ============================================ + +export type MMSessionEventType = + | 'CARD_MOVED' + | 'CARD_INFLUENCE_CHANGED' + | 'CARDS_REORDERED' + | 'SESSION_UPDATED'; + +export async function createMotivatorSessionEvent( + sessionId: string, + userId: string, + type: MMSessionEventType, + payload: Record +) { + return prisma.mMSessionEvent.create({ + data: { + sessionId, + userId, + type, + payload: JSON.stringify(payload), + }, + }); +} + +export async function getMotivatorSessionEvents(sessionId: string, since?: Date) { + return prisma.mMSessionEvent.findMany({ + where: { + sessionId, + ...(since && { createdAt: { gt: since } }), + }, + include: { + user: { select: { id: true, name: true, email: true } }, + }, + orderBy: { createdAt: 'asc' }, + }); +} + +export async function getLatestMotivatorEventTimestamp(sessionId: string) { + const event = await prisma.mMSessionEvent.findFirst({ + where: { sessionId }, + orderBy: { createdAt: 'desc' }, + select: { createdAt: true }, + }); + return event?.createdAt; +} + diff --git a/src/services/sessions.ts b/src/services/sessions.ts index c2d6860..b174bc4 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -12,6 +12,11 @@ export async function getSessionsByUserId(userId: string) { where: { userId }, include: { user: { select: { id: true, name: true, email: true } }, + shares: { + include: { + user: { select: { id: true, name: true, email: true } }, + }, + }, _count: { select: { items: true, @@ -27,6 +32,11 @@ export async function getSessionsByUserId(userId: string) { session: { include: { user: { select: { id: true, name: true, email: true } }, + shares: { + include: { + user: { select: { id: true, name: true, email: true } }, + }, + }, _count: { select: { items: true,