feat: implement Moving Motivators feature with session management, real-time event handling, and UI components for enhanced user experience
This commit is contained in:
@@ -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
56
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
|||||||
218
src/actions/moving-motivators.ts
Normal file
218
src/actions/moving-motivators.ts
Normal 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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
118
src/app/api/motivators/[id]/subscribe/route.ts
Normal file
118
src/app/api/motivators/[id]/subscribe/route.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
110
src/app/motivators/[id]/EditableTitle.tsx
Normal file
110
src/app/motivators/[id]/EditableTitle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
88
src/app/motivators/[id]/page.tsx
Normal file
88
src/app/motivators/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
102
src/app/motivators/new/page.tsx
Normal file
102
src/app/motivators/new/page.tsx
Normal 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'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'importance</li>
|
||||||
|
<li>Évaluez l'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
135
src/app/motivators/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
227
src/app/page.tsx
227
src/app/page.tsx
@@ -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'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'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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
272
src/app/sessions/WorkshopTabs.tsx
Normal file
272
src/app/sessions/WorkshopTabs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
143
src/components/moving-motivators/InfluenceZone.tsx
Normal file
143
src/components/moving-motivators/InfluenceZone.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
276
src/components/moving-motivators/MotivatorBoard.tsx
Normal file
276
src/components/moving-motivators/MotivatorBoard.tsx
Normal 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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
172
src/components/moving-motivators/MotivatorCard.tsx
Normal file
172
src/components/moving-motivators/MotivatorCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
138
src/components/moving-motivators/MotivatorLiveWrapper.tsx
Normal file
138
src/components/moving-motivators/MotivatorLiveWrapper.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
180
src/components/moving-motivators/MotivatorShareModal.tsx
Normal file
180
src/components/moving-motivators/MotivatorShareModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
103
src/components/moving-motivators/MotivatorSummary.tsx
Normal file
103
src/components/moving-motivators/MotivatorSummary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
7
src/components/moving-motivators/index.ts
Normal file
7
src/components/moving-motivators/index.ts
Normal 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';
|
||||||
|
|
||||||
131
src/hooks/useMotivatorLive.ts
Normal file
131
src/hooks/useMotivatorLive.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
||||||
148
src/lib/types.ts
148
src/lib/types.ts
@@ -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>
|
||||||
|
);
|
||||||
|
|
||||||
|
|||||||
339
src/services/moving-motivators.ts
Normal file
339
src/services/moving-motivators.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user