feat: implement Moving Motivators feature with session management, real-time event handling, and UI components for enhanced user experience

This commit is contained in:
Julien Froidefond
2025-11-28 08:40:39 +01:00
parent a5c17e23f6
commit 448cf61e66
26 changed files with 3191 additions and 183 deletions

BIN
dev.db

Binary file not shown.

View File

@@ -16,6 +16,9 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "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", "@hello-pangea/dnd": "^18.0.1",
"@prisma/adapter-better-sqlite3": "^7.0.1", "@prisma/adapter-better-sqlite3": "^7.0.1",
"@prisma/client": "^7.0.1", "@prisma/client": "^7.0.1",

56
pnpm-lock.yaml generated
View File

@@ -8,6 +8,15 @@ importers:
.: .:
dependencies: 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': '@hello-pangea/dnd':
specifier: ^18.0.1 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) 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': '@chevrotain/utils@10.5.0':
resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==} 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': '@electric-sql/pglite-socket@0.0.6':
resolution: {integrity: sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw==} resolution: {integrity: sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw==}
hasBin: true hasBin: true
@@ -2612,6 +2643,31 @@ snapshots:
'@chevrotain/utils@10.5.0': {} '@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)': '@electric-sql/pglite-socket@0.0.6(@electric-sql/pglite@0.3.2)':
dependencies: dependencies:
'@electric-sql/pglite': 0.3.2 '@electric-sql/pglite': 0.3.2

View File

@@ -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");

View File

@@ -17,6 +17,10 @@ model User {
sessions Session[] sessions Session[]
sharedSessions SessionShare[] sharedSessions SessionShare[]
sessionEvents SessionEvent[] sessionEvents SessionEvent[]
// Moving Motivators relations
motivatorSessions MovingMotivatorsSession[]
sharedMotivatorSessions MMSessionShare[]
motivatorSessionEvents MMSessionEvent[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -122,3 +126,77 @@ model SessionEvent {
@@index([sessionId, createdAt]) @@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])
}

View File

@@ -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' };
}
}

View File

@@ -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<string, Set<ReadableStreamDefaultController>>();
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id: sessionId } = await params;
const session = await auth();
if (!session?.user?.id) {
return new Response('Unauthorized', { status: 401 });
}
// Check access
const hasAccess = await 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
}
}
}

View File

@@ -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<HTMLInputElement>(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 <h1 className="text-3xl font-bold text-foreground">{title}</h1>;
}
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
disabled={isPending}
className="w-full max-w-md rounded-lg border border-border bg-input px-3 py-1.5 text-3xl font-bold text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 disabled:opacity-50"
/>
);
}
return (
<button
onClick={() => setIsEditing(true)}
className="group flex items-center gap-2 text-left"
title="Cliquez pour modifier"
>
<h1 className="text-3xl font-bold text-foreground">{title}</h1>
<svg
className="h-5 w-5 text-muted opacity-0 transition-opacity group-hover:opacity-100"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
);
}

View File

@@ -0,0 +1,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 (
<main className="mx-auto max-w-7xl px-4 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-muted mb-2">
<Link href="/motivators" className="hover:text-foreground">
Moving Motivators
</Link>
<span>/</span>
<span className="text-foreground">{session.title}</span>
{!session.isOwner && (
<Badge variant="accent" className="ml-2">
Partagé par {session.user.name || session.user.email}
</Badge>
)}
</div>
<div className="flex items-start justify-between">
<div>
<EditableMotivatorTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
/>
<p className="mt-1 text-lg text-muted">
👤 {session.participant}
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary">
{session.cards.filter((c) => c.influence !== 0).length} / 10 évalués
</Badge>
<span className="text-sm text-muted">
{new Date(session.date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
{/* Live Wrapper + Board */}
<MotivatorLiveWrapper
sessionId={session.id}
sessionTitle={session.title}
currentUserId={authSession.user.id}
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
>
<MotivatorBoard
sessionId={session.id}
cards={session.cards}
canEdit={session.canEdit}
/>
</MotivatorLiveWrapper>
</main>
);
}

View File

@@ -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<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const formData = new FormData(e.currentTarget);
const title = formData.get('title') as string;
const participant = formData.get('participant') as string;
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 (
<main className="mx-auto max-w-2xl px-4 py-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>🎯</span>
Nouvelle Session Moving Motivators
</CardTitle>
<CardDescription>
Créez une session pour explorer les motivations intrinsèques d&apos;un collaborateur
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<Input
label="Titre de la session"
name="title"
placeholder="Ex: Entretien motivation Q1 2025"
required
/>
<Input
label="Nom du participant"
name="participant"
placeholder="Ex: Jean Dupont"
required
/>
<div className="rounded-lg border border-border bg-card-hover p-4">
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
<ol className="text-sm text-muted space-y-1 list-decimal list-inside">
<li>Classez les 10 cartes de motivation par ordre d&apos;importance</li>
<li>Évaluez l&apos;influence positive ou négative de chaque motivation</li>
<li>Découvrez le récapitulatif des motivations clés</li>
</ol>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={loading}
>
Annuler
</Button>
<Button type="submit" loading={loading} className="flex-1">
Créer la session
</Button>
</div>
</form>
</CardContent>
</Card>
</main>
);
}

135
src/app/motivators/page.tsx Normal file
View File

@@ -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 (
<main className="mx-auto max-w-7xl px-4 py-8">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Moving Motivators</h1>
<p className="mt-1 text-muted">
Découvrez ce qui motive vraiment vos collaborateurs
</p>
</div>
<Link href="/motivators/new">
<Button>
<span>🎯</span>
Nouvelle Session
</Button>
</Link>
</div>
{/* Sessions Grid */}
{sessions.length === 0 ? (
<Card className="p-12 text-center">
<div className="text-5xl mb-4">🎯</div>
<h2 className="text-xl font-semibold text-foreground mb-2">
Aucune session pour le moment
</h2>
<p className="text-muted mb-6">
Créez votre première session Moving Motivators pour explorer les motivations
intrinsèques de vos collaborateurs.
</p>
<Link href="/motivators/new">
<Button>Créer ma première session</Button>
</Link>
</Card>
) : (
<div className="space-y-8">
{/* My Sessions */}
{ownedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
📁 Mes sessions ({ownedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{ownedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
)}
{/* Shared Sessions */}
{sharedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
🤝 Sessions partagées avec moi ({sharedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sharedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
)}
</div>
)}
</main>
);
}
type SessionWithMeta = Awaited<ReturnType<typeof getMotivatorSessionsByUserId>>[number];
function SessionCard({ session: s }: { session: SessionWithMeta }) {
return (
<Link href={`/motivators/${s.id}`}>
<Card hover className="h-full p-6">
<div className="mb-4 flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground line-clamp-1">
{s.title}
</h3>
<p className="text-sm text-muted">{s.participant}</p>
{!s.isOwner && (
<p className="text-xs text-muted mt-1">
Par {s.user.name || s.user.email}
</p>
)}
</div>
<div className="flex items-center gap-2">
{!s.isOwner && (
<Badge variant={s.role === 'EDITOR' ? 'primary' : 'warning'}>
{s.role === 'EDITOR' ? '✏️' : '👁️'}
</Badge>
)}
<span className="text-2xl">🎯</span>
</div>
</div>
<CardContent className="p-0">
<div className="flex flex-wrap gap-2 mb-4">
<Badge variant="primary">
{s._count.cards} motivations
</Badge>
</div>
<p className="text-xs text-muted">
Mis à jour le{' '}
{new Date(s.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</p>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -7,95 +7,182 @@ export default function Home() {
{/* Hero Section */} {/* Hero Section */}
<section className="mb-16 text-center"> <section className="mb-16 text-center">
<h1 className="mb-4 text-5xl font-bold text-foreground"> <h1 className="mb-4 text-5xl font-bold text-foreground">
Analysez. Planifiez. <span className="text-primary">Progressez.</span> Vos ateliers, <span className="text-primary">réinventés</span>
</h1> </h1>
<p className="mx-auto mb-8 max-w-2xl text-lg text-muted"> <p className="mx-auto mb-8 max-w-2xl text-lg text-muted">
Créez des ateliers SWOT interactifs avec vos collaborateurs. Identifiez les forces, Des outils interactifs et collaboratifs pour accompagner vos équipes.
faiblesses, opportunités et menaces, puis définissez ensemble une roadmap Analysez, comprenez et faites progresser vos collaborateurs avec des ateliers modernes.
d&apos;actions concrètes.
</p> </p>
<Link
href="/sessions/new"
className="inline-flex items-center gap-2 rounded-lg bg-primary px-6 py-3 text-lg font-semibold text-primary-foreground transition-colors hover:bg-primary-hover"
>
<span></span>
Nouvelle Session SWOT
</Link>
</section> </section>
{/* Features Grid */} {/* Workshops Grid */}
<section className="mb-16"> <section className="mb-16">
<h2 className="mb-8 text-center text-2xl font-bold text-foreground"> <h2 className="mb-8 text-center text-2xl font-bold text-foreground">
Comment ça marche ? Choisissez votre atelier
</h2> </h2>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-8 md:grid-cols-2 max-w-4xl mx-auto">
{/* Strength */} {/* SWOT Workshop Card */}
<div className="rounded-xl border border-strength-border bg-strength-bg p-6"> <WorkshopCard
<div className="mb-3 text-3xl">💪</div> href="/sessions"
<h3 className="mb-2 text-lg font-semibold text-strength">Forces</h3> icon="📊"
<p className="text-sm text-muted"> title="Analyse SWOT"
Les atouts et compétences sur lesquels s&apos;appuyer pour progresser. tagline="Analysez. Planifiez. Progressez."
</p> description="Cartographiez les forces et faiblesses de vos collaborateurs. Identifiez opportunités et menaces pour définir des actions concrètes."
</div> features={[
'Matrice interactive Forces/Faiblesses/Opportunités/Menaces',
'Actions croisées et plan de développement',
'Collaboration en temps réel',
]}
accentColor="#06b6d4"
newHref="/sessions/new"
/>
{/* Weakness */} {/* Moving Motivators Workshop Card */}
<div className="rounded-xl border border-weakness-border bg-weakness-bg p-6"> <WorkshopCard
<div className="mb-3 text-3xl"></div> href="/motivators"
<h3 className="mb-2 text-lg font-semibold text-weakness">Faiblesses</h3> icon="🎯"
<p className="text-sm text-muted"> title="Moving Motivators"
Les axes d&apos;amélioration et points de vigilance à travailler. tagline="Révélez ce qui motive vraiment"
</p> description="Explorez les 10 motivations intrinsèques de vos collaborateurs. Comprenez leur impact et alignez aspirations et missions."
</div> features={[
'10 cartes de motivation à classer',
{/* Opportunity */} 'Évaluation de l\'influence positive/négative',
<div className="rounded-xl border border-opportunity-border bg-opportunity-bg p-6"> 'Récapitulatif personnalisé des motivations',
<div className="mb-3 text-3xl">🚀</div> ]}
<h3 className="mb-2 text-lg font-semibold text-opportunity">Opportunités</h3> accentColor="#8b5cf6"
<p className="text-sm text-muted"> newHref="/motivators/new"
Les occasions de développement et de croissance à saisir. />
</p>
</div>
{/* Threat */}
<div className="rounded-xl border border-threat-border bg-threat-bg p-6">
<div className="mb-3 text-3xl">🛡</div>
<h3 className="mb-2 text-lg font-semibold text-threat">Menaces</h3>
<p className="text-sm text-muted">
Les risques et obstacles potentiels à anticiper.
</p>
</div>
</div> </div>
</section> </section>
{/* Cross Actions Section */} {/* Benefits Section */}
<section className="rounded-2xl border border-border bg-card p-8 text-center"> <section className="rounded-2xl border border-border bg-card p-8">
<h2 className="mb-4 text-2xl font-bold text-foreground">🔗 Actions Croisées</h2> <h2 className="mb-8 text-center text-2xl font-bold text-foreground">
<p className="mx-auto mb-6 max-w-2xl text-muted"> Pourquoi nos ateliers ?
La puissance du SWOT réside dans le croisement des catégories. Liez vos forces à vos </h2>
opportunités, anticipez les menaces avec vos atouts, et transformez vos faiblesses en <div className="grid gap-6 md:grid-cols-3">
axes de progression. <BenefitCard
</p> icon="🤝"
<div className="flex flex-wrap justify-center gap-3"> title="Collaboratif"
<span className="rounded-full border border-strength-border bg-strength-bg px-4 py-2 text-sm font-medium text-strength"> description="Travaillez ensemble en temps réel avec vos collaborateurs et partagez facilement vos sessions."
S + O Maximiser />
</span> <BenefitCard
<span className="rounded-full border border-threat-border bg-threat-bg px-4 py-2 text-sm font-medium text-threat"> icon="💾"
S + T Protéger title="Historique sauvegardé"
</span> description="Retrouvez vos ateliers passés, suivez l'évolution et mesurez les progrès dans le temps."
<span className="rounded-full border border-opportunity-border bg-opportunity-bg px-4 py-2 text-sm font-medium text-opportunity"> />
W + O Améliorer <BenefitCard
</span> icon="✨"
<span className="rounded-full border border-weakness-border bg-weakness-bg px-4 py-2 text-sm font-medium text-weakness"> title="Interface intuitive"
W + T Surveiller description="Des outils modernes avec drag & drop, pensés pour une utilisation simple et agréable."
</span> />
</div> </div>
</section> </section>
</main> </main>
{/* Footer */} {/* Footer */}
<footer className="border-t border-border py-6 text-center text-sm text-muted"> <footer className="border-t border-border py-6 text-center text-sm text-muted">
SWOT Manager Outil d&apos;entretiens managériaux Workshop Manager Vos ateliers managériaux en ligne
</footer> </footer>
</> </>
); );
} }
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 (
<div
className="group relative overflow-hidden rounded-2xl border-2 border-border bg-card p-8 transition-all hover:border-primary/50 hover:shadow-xl"
>
{/* Accent gradient */}
<div
className="absolute inset-x-0 top-0 h-1 opacity-80"
style={{ background: `linear-gradient(to right, ${accentColor}, ${accentColor}80)` }}
/>
{/* Icon & Title */}
<div className="mb-4 flex items-center gap-3">
<span className="text-4xl">{icon}</span>
<div>
<h3 className="text-xl font-bold text-foreground">{title}</h3>
<p className="text-sm font-medium" style={{ color: accentColor }}>
{tagline}
</p>
</div>
</div>
{/* Description */}
<p className="mb-6 text-muted">{description}</p>
{/* Features */}
<ul className="mb-6 space-y-2">
{features.map((feature, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-muted">
<svg
className="mt-0.5 h-4 w-4 shrink-0"
style={{ color: accentColor }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{feature}
</li>
))}
</ul>
{/* Actions */}
<div className="flex gap-3">
<Link
href={newHref}
className="flex-1 rounded-lg px-4 py-2.5 text-center font-medium text-white transition-colors"
style={{ backgroundColor: accentColor }}
>
Démarrer
</Link>
<Link
href={href}
className="rounded-lg border border-border px-4 py-2.5 font-medium text-foreground transition-colors hover:bg-card-hover"
>
Mes sessions
</Link>
</div>
</div>
);
}
function BenefitCard({
icon,
title,
description,
}: {
icon: string;
title: string;
description: string;
}) {
return (
<div className="text-center">
<div className="mb-3 text-3xl">{icon}</div>
<h3 className="mb-2 font-semibold text-foreground">{title}</h3>
<p className="text-sm text-muted">{description}</p>
</div>
);
}

View File

@@ -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<WorkshopType>('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 (
<div className="space-y-6">
{/* Tabs */}
<div className="flex gap-2 border-b border-border pb-4">
<TabButton
active={activeTab === 'all'}
onClick={() => setActiveTab('all')}
icon="📋"
label="Tous"
count={allSessions.length}
/>
<TabButton
active={activeTab === 'swot'}
onClick={() => setActiveTab('swot')}
icon="📊"
label="SWOT"
count={swotSessions.length}
/>
<TabButton
active={activeTab === 'motivators'}
onClick={() => setActiveTab('motivators')}
icon="🎯"
label="Moving Motivators"
count={motivatorSessions.length}
/>
</div>
{/* Sessions */}
{filteredSessions.length === 0 ? (
<div className="text-center py-12 text-muted">
Aucun atelier de ce type pour le moment
</div>
) : (
<div className="space-y-8">
{/* My Sessions */}
{ownedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
📁 Mes ateliers ({ownedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{ownedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
)}
{/* Shared Sessions */}
{sharedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
🤝 Partagés avec moi ({sharedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sharedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
)}
</div>
)}
</div>
);
}
function TabButton({
active,
onClick,
icon,
label,
count,
}: {
active: boolean;
onClick: () => void;
icon: string;
label: string;
count: number;
}) {
return (
<button
onClick={onClick}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors
${active
? 'bg-primary text-primary-foreground'
: 'text-muted hover:bg-card-hover hover:text-foreground'
}
`}
>
<span>{icon}</span>
<span>{label}</span>
<Badge variant={active ? 'default' : 'primary'} className="ml-1">
{count}
</Badge>
</button>
);
}
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 (
<Link href={href}>
<Card hover className="h-full p-4 relative overflow-hidden">
{/* Accent bar */}
<div
className="absolute top-0 left-0 right-0 h-1"
style={{ backgroundColor: accentColor }}
/>
{/* Header: Icon + Title + Role badge */}
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">{icon}</span>
<h3 className="font-semibold text-foreground line-clamp-1 flex-1">
{session.title}
</h3>
{!session.isOwner && (
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor: session.role === 'EDITOR' ? 'rgba(6,182,212,0.1)' : 'rgba(234,179,8,0.1)',
color: session.role === 'EDITOR' ? '#06b6d4' : '#eab308',
}}
>
{session.role === 'EDITOR' ? '✏️' : '👁️'}
</span>
)}
</div>
{/* Participant + Owner info */}
<p className="text-sm text-muted mb-3 line-clamp-1">
👤 {participant}
{!session.isOwner && (
<span className="text-xs"> · par {session.user.name || session.user.email}</span>
)}
</p>
{/* Footer: Stats + Avatars + Date */}
<div className="flex items-center justify-between text-xs">
{/* Stats */}
<div className="flex items-center gap-2 text-muted">
{isSwot ? (
<>
<span>{(session as SwotSession)._count.items} items</span>
<span>·</span>
<span>{(session as SwotSession)._count.actions} actions</span>
</>
) : (
<span>{(session as MotivatorSession)._count.cards}/10</span>
)}
</div>
{/* Date */}
<span className="text-muted">
{new Date(session.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</div>
{/* Shared with */}
{session.isOwner && session.shares.length > 0 && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
<span className="text-[10px] text-muted uppercase tracking-wide">Partagé</span>
<div className="flex flex-wrap gap-1.5">
{session.shares.slice(0, 3).map((share) => (
<div
key={share.id}
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-primary/10 text-[10px] text-primary"
title={share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
>
<span className="font-medium">
{share.user.name?.split(' ')[0] || share.user.email.split('@')[0]}
</span>
<span>{share.role === 'EDITOR' ? '✏️' : '👁️'}</span>
</div>
))}
{session.shares.length > 3 && (
<span className="text-[10px] text-muted">
+{session.shares.length - 3}
</span>
)}
</div>
</div>
)}
</Card>
</Link>
);
}

View File

@@ -1,7 +1,9 @@
import Link from 'next/link'; import Link from 'next/link';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { getSessionsByUserId } from '@/services/sessions'; import { getSessionsByUserId } from '@/services/sessions';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
import { Card, CardContent, Badge, Button } from '@/components/ui'; import { Card, CardContent, Badge, Button } from '@/components/ui';
import { WorkshopTabs } from './WorkshopTabs';
export default async function SessionsPage() { export default async function SessionsPage() {
const session = await auth(); const session = await auth();
@@ -10,129 +12,87 @@ export default async function SessionsPage() {
return null; 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 // Add type to each session for unified display
const ownedSessions = sessions.filter((s) => s.isOwner); const allSwotSessions = swotSessions.map((s) => ({
const sharedSessions = sessions.filter((s) => !s.isOwner); ...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 ( return (
<main className="mx-auto max-w-7xl px-4 py-8"> <main className="mx-auto max-w-7xl px-4 py-8">
{/* Header */} {/* Header */}
<div className="mb-8 flex items-center justify-between"> <div className="mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <div>
<h1 className="text-3xl font-bold text-foreground">Mes Sessions SWOT</h1> <h1 className="text-3xl font-bold text-foreground">Mes Ateliers</h1>
<p className="mt-1 text-muted"> <p className="mt-1 text-muted">
Gérez vos ateliers SWOT avec vos collaborateurs Tous vos ateliers en un seul endroit
</p> </p>
</div> </div>
<Link href="/sessions/new"> <div className="flex gap-2">
<Button> <Link href="/sessions/new">
<span></span> <Button variant="outline">
Nouvelle Session <span>📊</span>
</Button> Nouveau SWOT
</Link> </Button>
</Link>
<Link href="/motivators/new">
<Button>
<span>🎯</span>
Nouveau Motivators
</Button>
</Link>
</div>
</div> </div>
{/* Sessions Grid */} {/* Content */}
{sessions.length === 0 ? ( {hasNoSessions ? (
<Card className="p-12 text-center"> <Card className="p-12 text-center">
<div className="text-5xl mb-4">📋</div> <div className="text-5xl mb-4">🚀</div>
<h2 className="text-xl font-semibold text-foreground mb-2"> <h2 className="text-xl font-semibold text-foreground mb-2">
Aucune session pour le moment Commencez votre premier atelier
</h2> </h2>
<p className="text-muted mb-6"> <p className="text-muted mb-6 max-w-md mx-auto">
Créez votre première session SWOT pour commencer à analyser les forces, Créez un atelier SWOT pour analyser les forces et faiblesses, ou un Moving Motivators pour découvrir les motivations de vos collaborateurs.
faiblesses, opportunités et menaces de vos collaborateurs.
</p> </p>
<Link href="/sessions/new"> <div className="flex gap-3 justify-center">
<Button>Créer ma première session</Button> <Link href="/sessions/new">
</Link> <Button variant="outline">
<span>📊</span>
Créer un SWOT
</Button>
</Link>
<Link href="/motivators/new">
<Button>
<span>🎯</span>
Créer un Moving Motivators
</Button>
</Link>
</div>
</Card> </Card>
) : ( ) : (
<div className="space-y-8"> <WorkshopTabs
{/* My Sessions */} swotSessions={allSwotSessions}
{ownedSessions.length > 0 && ( motivatorSessions={allMotivatorSessions}
<section> />
<h2 className="text-lg font-semibold text-foreground mb-4">
📁 Mes sessions ({ownedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{ownedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
)}
{/* Shared Sessions */}
{sharedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
🤝 Sessions partagées avec moi ({sharedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sharedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
)}
</div>
)} )}
</main> </main>
); );
} }
type SessionWithMeta = Awaited<ReturnType<typeof getSessionsByUserId>>[number];
function SessionCard({ session: s }: { session: SessionWithMeta }) {
return (
<Link href={`/sessions/${s.id}`}>
<Card hover className="h-full p-6">
<div className="mb-4 flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground line-clamp-1">
{s.title}
</h3>
<p className="text-sm text-muted">{s.collaborator}</p>
{!s.isOwner && (
<p className="text-xs text-muted mt-1">
Par {s.user.name || s.user.email}
</p>
)}
</div>
<div className="flex items-center gap-2">
{!s.isOwner && (
<Badge variant={s.role === 'EDITOR' ? 'primary' : 'warning'}>
{s.role === 'EDITOR' ? '✏️' : '👁️'}
</Badge>
)}
<span className="text-2xl">📊</span>
</div>
</div>
<CardContent className="p-0">
<div className="flex flex-wrap gap-2 mb-4">
<Badge variant="primary">
{s._count.items} items
</Badge>
<Badge variant="success">
{s._count.actions} actions
</Badge>
</div>
<p className="text-xs text-muted">
Mis à jour le{' '}
{new Date(s.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</p>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useSession, signOut } from 'next-auth/react'; import { useSession, signOut } from 'next-auth/react';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
import { useState } from 'react'; import { useState } from 'react';
@@ -9,23 +10,89 @@ export function Header() {
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [workshopsOpen, setWorkshopsOpen] = useState(false);
const pathname = usePathname();
const isActiveLink = (path: string) => pathname.startsWith(path);
return ( return (
<header className="sticky top-0 z-50 border-b border-border bg-card/80 backdrop-blur-sm"> <header className="sticky top-0 z-50 border-b border-border bg-card/80 backdrop-blur-sm">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4"> <div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
<Link href="/" className="flex items-center gap-2"> <Link href="/" className="flex items-center gap-2">
<span className="text-2xl">📊</span> <span className="text-2xl">🚀</span>
<span className="text-xl font-bold text-foreground">SWOT Manager</span> <span className="text-xl font-bold text-foreground">Workshop Manager</span>
</Link> </Link>
<nav className="flex items-center gap-4"> <nav className="flex items-center gap-4">
{status === 'authenticated' && session?.user && ( {status === 'authenticated' && session?.user && (
<Link <>
href="/sessions" {/* All Workshops Link */}
className="text-muted transition-colors hover:text-foreground" <Link
> href="/sessions"
Mes Sessions className={`text-sm font-medium transition-colors ${
</Link> isActiveLink('/sessions') && !isActiveLink('/sessions/')
? 'text-primary'
: 'text-muted hover:text-foreground'
}`}
>
Mes Ateliers
</Link>
{/* Workshops Dropdown */}
<div className="relative">
<button
onClick={() => setWorkshopsOpen(!workshopsOpen)}
onBlur={() => setTimeout(() => setWorkshopsOpen(false), 150)}
className={`flex items-center gap-1 text-sm font-medium transition-colors ${
isActiveLink('/sessions/') || isActiveLink('/motivators')
? 'text-primary'
: 'text-muted hover:text-foreground'
}`}
>
Ateliers
<svg
className={`h-4 w-4 transition-transform ${workshopsOpen ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{workshopsOpen && (
<div className="absolute left-0 z-20 mt-2 w-56 rounded-lg border border-border bg-card py-1 shadow-lg">
<Link
href="/sessions/new"
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover"
onClick={() => setWorkshopsOpen(false)}
>
<span className="text-lg">📊</span>
<div>
<div className="font-medium">Analyse SWOT</div>
<div className="text-xs text-muted">Forces, faiblesses, opportunités</div>
</div>
</Link>
<Link
href="/motivators/new"
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover"
onClick={() => setWorkshopsOpen(false)}
>
<span className="text-lg">🎯</span>
<div>
<div className="font-medium">Moving Motivators</div>
<div className="text-xs text-muted">Motivations intrinsèques</div>
</div>
</Link>
</div>
)}
</div>
</>
)} )}
<button <button

View File

@@ -0,0 +1,143 @@
'use client';
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
import { MOTIVATOR_BY_TYPE } from '@/lib/types';
interface InfluenceZoneProps {
cards: MotivatorCardType[];
onInfluenceChange: (cardId: string, influence: number) => void;
canEdit: boolean;
}
export function InfluenceZone({ cards, onInfluenceChange, canEdit }: InfluenceZoneProps) {
// Sort by importance (orderIndex)
const sortedCards = [...cards].sort((a, b) => b.orderIndex - a.orderIndex);
return (
<div className="space-y-4">
{/* Legend */}
<div className="flex justify-center gap-8 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-muted">Influence négative</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-gray-400" />
<span className="text-muted">Neutre</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-muted">Influence positive</span>
</div>
</div>
{/* Cards with sliders */}
<div className="space-y-3">
{sortedCards.map((card) => (
<InfluenceSlider
key={card.id}
card={card}
onInfluenceChange={onInfluenceChange}
disabled={!canEdit}
/>
))}
</div>
</div>
);
}
function InfluenceSlider({
card,
onInfluenceChange,
disabled,
}: {
card: MotivatorCardType;
onInfluenceChange: (cardId: string, influence: number) => void;
disabled: boolean;
}) {
const config = MOTIVATOR_BY_TYPE[card.type];
return (
<div
className={`
flex items-center gap-4 p-4 rounded-xl border border-border bg-card
transition-all hover:shadow-md
`}
>
{/* Card info */}
<div className="flex items-center gap-3 w-40 shrink-0">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center text-xl"
style={{ backgroundColor: `${config.color}20` }}
>
{config.icon}
</div>
<div>
<div className="font-medium text-foreground text-sm">{config.name}</div>
<div className="text-xs text-muted">#{card.orderIndex}</div>
</div>
</div>
{/* Influence slider */}
<div className="flex-1 flex items-center gap-4">
<span className="text-xs text-red-500 font-medium w-6 text-right">-3</span>
<div className="flex-1 relative">
{/* Track background */}
<div className="absolute inset-0 h-2 top-1/2 -translate-y-1/2 rounded-full bg-gradient-to-r from-red-500 via-gray-300 to-green-500" />
{/* Slider */}
<input
type="range"
min={-3}
max={3}
value={card.influence}
onChange={(e) => onInfluenceChange(card.id, parseInt(e.target.value))}
disabled={disabled}
className={`
relative w-full h-8 appearance-none bg-transparent cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-6
[&::-webkit-slider-thumb]:h-6
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:border-2
[&::-webkit-slider-thumb]:border-foreground
[&::-webkit-slider-thumb]:shadow-md
[&::-webkit-slider-thumb]:cursor-grab
[&::-webkit-slider-thumb]:active:cursor-grabbing
[&::-moz-range-thumb]:w-6
[&::-moz-range-thumb]:h-6
[&::-moz-range-thumb]:rounded-full
[&::-moz-range-thumb]:bg-white
[&::-moz-range-thumb]:border-2
[&::-moz-range-thumb]:border-foreground
[&::-moz-range-thumb]:shadow-md
[&::-moz-range-thumb]:cursor-grab
disabled:cursor-not-allowed
disabled:[&::-webkit-slider-thumb]:cursor-not-allowed
`}
/>
{/* Zero marker */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-4 bg-foreground/30 rounded-full pointer-events-none" />
</div>
<span className="text-xs text-green-500 font-medium w-6">+3</span>
</div>
{/* Current value */}
<div
className={`
w-12 h-8 rounded-lg flex items-center justify-center font-bold text-sm
${card.influence > 0 ? 'bg-green-500/20 text-green-600' : ''}
${card.influence < 0 ? 'bg-red-500/20 text-red-600' : ''}
${card.influence === 0 ? 'bg-muted/20 text-muted' : ''}
`}
>
{card.influence > 0 ? `+${card.influence}` : card.influence}
</div>
</div>
);
}

View File

@@ -0,0 +1,276 @@
'use client';
import { useState, useTransition } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable';
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
import { MotivatorCard } from './MotivatorCard';
import { MotivatorSummary } from './MotivatorSummary';
import { InfluenceZone } from './InfluenceZone';
import { reorderMotivatorCards, updateCardInfluence } from '@/actions/moving-motivators';
interface MotivatorBoardProps {
sessionId: string;
cards: MotivatorCardType[];
canEdit: boolean;
}
type Step = 'ranking' | 'influence' | 'summary';
export function MotivatorBoard({ sessionId, cards: initialCards, canEdit }: MotivatorBoardProps) {
const [cards, setCards] = useState(initialCards);
const [step, setStep] = useState<Step>('ranking');
const [isPending, startTransition] = useTransition();
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// Sort cards by orderIndex
const sortedCards = [...cards].sort((a, b) => a.orderIndex - b.orderIndex);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = sortedCards.findIndex((c) => c.id === active.id);
const newIndex = sortedCards.findIndex((c) => c.id === over.id);
const newCards = arrayMove(sortedCards, oldIndex, newIndex).map((card, index) => ({
...card,
orderIndex: index + 1,
}));
setCards(newCards);
// Persist to server
startTransition(async () => {
await reorderMotivatorCards(sessionId, newCards.map((c) => c.id));
});
}
function handleInfluenceChange(cardId: string, influence: number) {
setCards((prev) =>
prev.map((c) => (c.id === cardId ? { ...c, influence } : c))
);
startTransition(async () => {
await updateCardInfluence(cardId, sessionId, influence);
});
}
function nextStep() {
if (step === 'ranking') setStep('influence');
else if (step === 'influence') setStep('summary');
}
function prevStep() {
if (step === 'influence') setStep('ranking');
else if (step === 'summary') setStep('influence');
}
return (
<div className={`space-y-6 ${isPending ? 'opacity-70' : ''}`}>
{/* Progress Steps */}
<div className="flex items-center justify-center gap-4">
<StepIndicator
number={1}
label="Classement"
active={step === 'ranking'}
completed={step !== 'ranking'}
onClick={() => setStep('ranking')}
/>
<div className="h-px w-12 bg-border" />
<StepIndicator
number={2}
label="Influence"
active={step === 'influence'}
completed={step === 'summary'}
onClick={() => setStep('influence')}
/>
<div className="h-px w-12 bg-border" />
<StepIndicator
number={3}
label="Récapitulatif"
active={step === 'summary'}
completed={false}
onClick={() => setStep('summary')}
/>
</div>
{/* Step Content */}
{step === 'ranking' && (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-xl font-semibold text-foreground mb-2">
Classez vos motivations par importance
</h2>
<p className="text-muted">
Glissez les cartes de gauche (moins important) à droite (plus important)
</p>
</div>
{/* Importance axis */}
<div className="relative">
<div className="flex justify-between text-sm text-muted mb-4 px-4">
<span> Moins important</span>
<span>Plus important </span>
</div>
{/* Cards container */}
<div className="bg-gradient-to-r from-red-500/10 via-yellow-500/10 to-green-500/10 rounded-2xl p-4 border border-border overflow-x-auto">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedCards.map((c) => c.id)}
strategy={horizontalListSortingStrategy}
>
<div className="flex gap-2 min-w-max px-2">
{sortedCards.map((card) => (
<MotivatorCard
key={card.id}
card={card}
disabled={!canEdit}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
</div>
{/* Next button */}
<div className="flex justify-end">
<button
onClick={nextStep}
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
>
Suivant
</button>
</div>
</div>
)}
{step === 'influence' && (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-xl font-semibold text-foreground mb-2">
Évaluez l&apos;influence de chaque motivation
</h2>
<p className="text-muted">
Pour chaque carte, indiquez si cette motivation a une influence positive ou négative sur votre situation actuelle
</p>
</div>
<InfluenceZone
cards={sortedCards}
onInfluenceChange={handleInfluenceChange}
canEdit={canEdit}
/>
{/* Navigation buttons */}
<div className="flex justify-between">
<button
onClick={prevStep}
className="px-6 py-2 border border-border rounded-lg font-medium hover:bg-card transition-colors"
>
Retour
</button>
<button
onClick={nextStep}
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
>
Voir le récapitulatif
</button>
</div>
</div>
)}
{step === 'summary' && (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-xl font-semibold text-foreground mb-2">
Récapitulatif de vos Moving Motivators
</h2>
<p className="text-muted">
Voici l&apos;analyse de vos motivations et leur impact
</p>
</div>
<MotivatorSummary cards={sortedCards} />
{/* Navigation buttons */}
<div className="flex justify-start">
<button
onClick={prevStep}
className="px-6 py-2 border border-border rounded-lg font-medium hover:bg-card transition-colors"
>
Modifier
</button>
</div>
</div>
)}
</div>
);
}
function StepIndicator({
number,
label,
active,
completed,
onClick,
}: {
number: number;
label: string;
active: boolean;
completed: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`
flex flex-col items-center gap-1 transition-colors
${active ? 'text-primary' : completed ? 'text-green-500' : 'text-muted'}
`}
>
<div
className={`
w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm
${active ? 'bg-primary text-primary-foreground' : ''}
${completed ? 'bg-green-500 text-white' : ''}
${!active && !completed ? 'bg-muted/20 text-muted' : ''}
`}
>
{completed ? '✓' : number}
</div>
<span className="text-xs font-medium">{label}</span>
</button>
);
}

View File

@@ -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 (
<div
ref={setNodeRef}
style={style}
className={`
relative flex flex-col items-center justify-center
w-28 h-36 rounded-xl border-2 shrink-0
bg-card shadow-md
cursor-grab active:cursor-grabbing
transition-all duration-200
${isDragging ? 'opacity-50 scale-105 shadow-xl z-50' : 'hover:shadow-lg hover:-translate-y-1'}
${disabled ? 'cursor-default opacity-60' : ''}
`}
{...attributes}
{...listeners}
>
{/* Color accent bar */}
<div
className="absolute top-0 left-0 right-0 h-2 rounded-t-lg"
style={{ backgroundColor: config.color }}
/>
{/* Icon */}
<div className="text-3xl mb-1 mt-2">{config.icon}</div>
{/* Name */}
<div
className="font-semibold text-sm text-center px-2"
style={{ color: config.color }}
>
{config.name}
</div>
{/* Description */}
<p className="text-[10px] text-muted text-center px-2 mt-1 line-clamp-2">
{config.description}
</p>
{/* Influence indicator */}
{showInfluence && card.influence !== 0 && (
<div
className={`
absolute -top-2 -right-2 w-6 h-6 rounded-full
flex items-center justify-center text-xs font-bold text-white
${card.influence > 0 ? 'bg-green-500' : 'bg-red-500'}
`}
>
{card.influence > 0 ? `+${card.influence}` : card.influence}
</div>
)}
{/* Rank badge */}
<div
className="absolute -bottom-2 left-1/2 -translate-x-1/2
bg-foreground text-background text-xs font-bold
w-5 h-5 rounded-full flex items-center justify-center"
>
{card.orderIndex}
</div>
</div>
);
}
// 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 (
<div
className={`
relative flex flex-col items-center justify-center
rounded-xl border-2 bg-card shadow-md
${sizeClasses[size]}
`}
>
{/* Color accent bar */}
<div
className="absolute top-0 left-0 right-0 h-2 rounded-t-lg"
style={{ backgroundColor: config.color }}
/>
{/* Icon */}
<div className={`mb-1 mt-2 ${size === 'small' ? 'text-xl' : 'text-3xl'}`}>
{config.icon}
</div>
{/* Name */}
<div
className={`font-semibold text-center px-2 ${size === 'small' ? 'text-xs' : 'text-sm'}`}
style={{ color: config.color }}
>
{config.name}
</div>
{/* Influence indicator */}
{card.influence !== 0 && (
<div
className={`
absolute -top-2 -right-2 rounded-full
flex items-center justify-center font-bold text-white
${card.influence > 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}
</div>
)}
{/* Rank badge */}
<div
className={`
absolute -bottom-2 left-1/2 -translate-x-1/2
bg-foreground text-background font-bold
rounded-full flex items-center justify-center
${size === 'small' ? 'w-4 h-4 text-[10px]' : 'w-5 h-5 text-xs'}
`}
>
{card.orderIndex}
</div>
</div>
);
}

View File

@@ -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<string | null>(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 */}
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-4">
<LiveIndicator isConnected={isConnected} error={error} />
{lastEventUser && (
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
<span></span>
<span>{lastEventUser} édite...</span>
</div>
)}
{!canEdit && (
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
<span>👁</span>
<span>Mode lecture</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Collaborators avatars */}
{shares.length > 0 && (
<div className="flex -space-x-2">
{shares.slice(0, 3).map((share) => (
<div
key={share.id}
className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-primary/10 text-xs font-medium text-primary"
title={share.user.name || share.user.email}
>
{share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()}
</div>
))}
{shares.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
+{shares.length - 3}
</div>
)}
</div>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShareModalOpen(true)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="mr-2 h-4 w-4"
>
<path d="M13 4.5a2.5 2.5 0 11.702 1.737L6.97 9.604a2.518 2.518 0 010 .792l6.733 3.367a2.5 2.5 0 11-.671 1.341l-6.733-3.367a2.5 2.5 0 110-3.475l6.733-3.366A2.52 2.52 0 0113 4.5z" />
</svg>
Partager
</Button>
</div>
</div>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>
{children}
</div>
{/* Share Modal */}
<MotivatorShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
/>
</>
);
}

View File

@@ -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<ShareRole>('EDITOR');
const [error, setError] = useState<string | null>(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 (
<Modal isOpen={isOpen} onClose={onClose} title="Partager la session">
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Session Moving Motivators</p>
<p className="font-medium text-foreground">{sessionTitle}</p>
</div>
{/* Share form (only for owner) */}
{isOwner && (
<form onSubmit={handleShare} className="space-y-4">
<div className="flex gap-2">
<Input
type="email"
placeholder="Email de l'utilisateur"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1"
required
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground"
>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={isPending || !email} className="w-full">
{isPending ? 'Partage...' : 'Partager'}
</Button>
</form>
)}
{/* Current shares */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">
Collaborateurs ({shares.length})
</p>
{shares.length === 0 ? (
<p className="text-sm text-muted">
Aucun collaborateur pour le moment
</p>
) : (
<ul className="space-y-2">
{shares.map((share) => (
<li
key={share.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-medium text-primary">
{share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()}
</div>
<div>
<p className="text-sm font-medium text-foreground">
{share.user.name || share.user.email}
</p>
{share.user.name && (
<p className="text-xs text-muted">{share.user.email}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
</Badge>
{isOwner && (
<button
onClick={() => handleRemove(share.user.id)}
disabled={isPending}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
title="Retirer l'accès"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
</li>
))}
</ul>
)}
</div>
{/* Help text */}
<div className="rounded-lg bg-primary/5 p-3">
<p className="text-xs text-muted">
<strong>Éditeur</strong> : peut modifier les cartes et leurs positions
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</p>
</div>
</div>
</Modal>
);
}

View File

@@ -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 (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Top 3 Most Important */}
<SummarySection
title="🏆 Top 3 - Plus importantes"
subtitle="Ces motivations vous animent le plus"
cards={top3}
emptyMessage="Classez vos cartes pour voir ce résultat"
variant="success"
/>
{/* Bottom 3 Least Important */}
<SummarySection
title="📉 Moins importantes"
subtitle="Ces motivations ont moins d'impact pour vous"
cards={bottom3}
emptyMessage="Classez vos cartes pour voir ce résultat"
variant="muted"
/>
{/* Positive Influence */}
<SummarySection
title="✨ Influence positive"
subtitle="Ces motivations sont satisfaites actuellement"
cards={positiveInfluence}
emptyMessage="Aucune motivation en influence positive"
variant="success"
/>
{/* Negative Influence */}
<SummarySection
title="⚠️ Influence négative"
subtitle="Ces motivations ne sont pas satisfaites"
cards={negativeInfluence}
emptyMessage="Aucune motivation en influence négative"
variant="danger"
/>
</div>
);
}
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 (
<div className={`rounded-xl border-2 p-5 ${borderColors[variant]}`}>
<h3 className="font-semibold text-foreground mb-1">{title}</h3>
<p className="text-sm text-muted mb-4">{subtitle}</p>
{cards.length > 0 ? (
<div className="flex gap-3 flex-wrap justify-center">
{cards.map((card) => (
<MotivatorCardStatic key={card.id} card={card} size="small" />
))}
</div>
) : (
<p className="text-sm text-muted text-center py-4">{emptyMessage}</p>
)}
</div>
);
}

View File

@@ -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';

View File

@@ -0,0 +1,131 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
export type MotivatorLiveEvent = {
type: string;
payload: Record<string, unknown>;
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<MotivatorLiveEvent | null>(null);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(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 };
}

View File

@@ -152,3 +152,151 @@ export const STATUS_LABELS: Record<ActionStatus, string> = {
done: 'Terminé', 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<MotivatorType, MotivatorConfig> =
MOTIVATORS_CONFIG.reduce(
(acc, config) => {
acc[config.type] = config;
return acc;
},
{} as Record<MotivatorType, MotivatorConfig>
);

View File

@@ -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<string, unknown>
) {
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;
}

View File

@@ -12,6 +12,11 @@ export async function getSessionsByUserId(userId: string) {
where: { userId }, where: { userId },
include: { include: {
user: { select: { id: true, name: true, email: true } }, user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: { _count: {
select: { select: {
items: true, items: true,
@@ -27,6 +32,11 @@ export async function getSessionsByUserId(userId: string) {
session: { session: {
include: { include: {
user: { select: { id: true, name: true, email: true } }, user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: { _count: {
select: { select: {
items: true, items: true,