diff --git a/dev.db b/dev.db index 6ee2b29..7c5eb70 100644 Binary files a/dev.db and b/dev.db differ diff --git a/package.json b/package.json index fe959c0..b7066fc 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,9 @@ "lint": "eslint" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hello-pangea/dnd": "^18.0.1", "@prisma/adapter-better-sqlite3": "^7.0.1", "@prisma/client": "^7.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 915ad7a..312d655 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 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) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.0) '@hello-pangea/dnd': specifier: ^18.0.1 version: 18.0.1(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -179,6 +188,28 @@ packages: '@chevrotain/utils@10.5.0': resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=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,