Compare commits

..

16 Commits

Author SHA1 Message Date
Julien Froidefond
fd65e0d5b9 feat: enhance live collaboration features by introducing useLive hook for real-time event handling across motivators, sessions, and year reviews; refactor existing hooks to utilize this new functionality
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m39s
2025-12-16 10:41:16 +01:00
Julien Froidefond
246298dd82 refactor: consolidate editable title components into a unified UI module, removing redundant files and updating imports 2025-12-16 08:58:09 +01:00
Julien Froidefond
56a9c2c3be feat: implement Year Review feature with session management, item categorization, and real-time collaboration
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m7s
2025-12-16 08:55:13 +01:00
Julien Froidefond
48ff86fb5f chore: update deploy workflow to rebuild Docker images during deployment
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m43s
2025-12-15 13:43:57 +01:00
Julien Froidefond
d735e1c4c5 feat: add linked item management to action updates in SWOT analysis
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5s
2025-12-15 13:34:09 +01:00
Julien Froidefond
0cf7437efe chore: optimize Dockerfile by adding cache mount for pnpm installation to improve build performance
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5s
2025-12-13 12:16:14 +01:00
Julien Froidefond
ccb5338aa6 chore: update Dockerfile to set PNPM_HOME environment variable and prepare directory for pnpm installation 2025-12-13 12:16:07 +01:00
Julien Froidefond
fa2879c903 fix: update next to 16.0.10
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4s
2025-12-13 07:28:00 +01:00
Julien Froidefond
0daade6533 chore: update docker-compose.yml to change data volume path to a relative directory for better portability
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4s
2025-12-11 11:24:26 +01:00
Julien Froidefond
acdcc37091 chore: update docker-compose.yml to set a specific data volume path for improved data management
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4s
2025-12-11 11:22:09 +01:00
Julien Froidefond
7a4de67b9c chore: update docker-compose.yml to set a specific data volume path and modify deploy workflow to include DATA_VOLUME_PATH variable
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6s
2025-12-11 11:21:15 +01:00
Julien Froidefond
27995e7e7f chore: update docker-compose.yml to use dynamic data volume path and modify deploy workflow to reference environment variables
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 9s
2025-12-11 11:18:15 +01:00
Julien Froidefond
8a3966e6a9 chore: update deploy workflow to enable Docker BuildKit and add environment variables for authentication and database configuration
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 13s
2025-12-11 08:56:03 +01:00
Julien Froidefond
e2232ca595 chore: update .gitignore to include data directory and modify docker-compose.yml for external volume mapping
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 20s
2025-12-11 07:58:58 +01:00
Julien Froidefond
434043041c chore: rename app service to workshop-manager-app in docker-compose.yml for clarity
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 10m44s
2025-12-10 14:29:28 +01:00
9764402ef2 feat: adapting dor server 2025-12-06 13:09:12 +01:00
42 changed files with 2737 additions and 450 deletions

View File

@@ -0,0 +1,23 @@
name: Deploy with Docker Compose
on:
push:
branches:
- main # adapte la branche que tu veux déployer
jobs:
deploy:
runs-on: mac-orbstack-runner # le nom que tu as donné au runner
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy stack
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
AUTH_URL: ${{ vars.AUTH_URL }}
DATA_VOLUME_PATH: ${{ vars.DATA_VOLUME_PATH }}
run: |
docker compose up -d --build

3
.gitignore vendored
View File

@@ -41,3 +41,6 @@ yarn-error.log*
next-env.d.ts
/src/generated/prisma
# data
data/

View File

@@ -3,12 +3,16 @@
# ---- Base ----
FROM node:22-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN mkdir -p $PNPM_HOME
WORKDIR /app
# ---- Dependencies ----
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
# ---- Build ----
FROM base AS builder
@@ -46,7 +50,8 @@ COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
# Install prisma CLI for migrations + better-sqlite3 (compile native module)
ENV DATABASE_URL="file:/app/data/prod.db"
RUN pnpm add prisma @prisma/client @prisma/adapter-better-sqlite3 better-sqlite3 dotenv && \
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm add prisma @prisma/client @prisma/adapter-better-sqlite3 better-sqlite3 dotenv && \
pnpm prisma generate
# Copy entrypoint script

Binary file not shown.

View File

@@ -1,16 +1,18 @@
services:
app:
workshop-manager-app:
build:
context: .
dockerfile: Dockerfile
ports:
- '3011:3000'
- '3009:3000'
environment:
- NODE_ENV=production
- DATABASE_URL=file:/app/data/dev.db
- AUTH_SECRET=${AUTH_SECRET:-your-secret-key-change-in-production}
- AUTH_TRUST_HOST=true
- AUTH_URL=${AUTH_URL:-http://localhost:3011}
- AUTH_URL=${AUTH_URL:-https://workshop-manager.julienfroidefond.com}
volumes:
- ./data:/app/data
- ${DATA_VOLUME_PATH:-./data}:/app/data
restart: unless-stopped
labels:
- 'com.centurylinklabs.watchtower.enable=false'

View File

@@ -24,7 +24,7 @@
"@prisma/client": "^7.1.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.4.6",
"next": "16.0.7",
"next": "16.0.10",
"next-auth": "5.0.0-beta.30",
"prisma": "^7.1.0",
"react": "19.2.0",

95
pnpm-lock.yaml generated
View File

@@ -33,11 +33,11 @@ importers:
specifier: ^12.4.6
version: 12.4.6
next:
specifier: 16.0.7
version: 16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
specifier: 16.0.10
version: 16.0.10(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next-auth:
specifier: 5.0.0-beta.30
version: 5.0.0-beta.30(next@16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
version: 5.0.0-beta.30(next@16.0.10(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
prisma:
specifier: ^7.1.0
version: 7.1.0(@types/react@19.2.7)(better-sqlite3@12.4.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
@@ -459,56 +459,56 @@ packages:
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
'@next/env@16.0.7':
resolution: {integrity: sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==}
'@next/env@16.0.10':
resolution: {integrity: sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==}
'@next/eslint-plugin-next@16.0.5':
resolution: {integrity: sha512-m1zPz6hsBvQt1CMRz7rTga8OXpRE9rVW4JHCSjW+tswTxiEU+6ev+GTlgm7ZzcCiMEVQAHTNhpEGFzDtVha9qg==}
'@next/swc-darwin-arm64@16.0.7':
resolution: {integrity: sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==}
'@next/swc-darwin-arm64@16.0.10':
resolution: {integrity: sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@16.0.7':
resolution: {integrity: sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==}
'@next/swc-darwin-x64@16.0.10':
resolution: {integrity: sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@16.0.7':
resolution: {integrity: sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==}
'@next/swc-linux-arm64-gnu@16.0.10':
resolution: {integrity: sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@16.0.7':
resolution: {integrity: sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==}
'@next/swc-linux-arm64-musl@16.0.10':
resolution: {integrity: sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@16.0.7':
resolution: {integrity: sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==}
'@next/swc-linux-x64-gnu@16.0.10':
resolution: {integrity: sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@16.0.7':
resolution: {integrity: sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==}
'@next/swc-linux-x64-musl@16.0.10':
resolution: {integrity: sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@16.0.7':
resolution: {integrity: sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==}
'@next/swc-win32-arm64-msvc@16.0.10':
resolution: {integrity: sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@16.0.7':
resolution: {integrity: sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==}
'@next/swc-win32-x64-msvc@16.0.10':
resolution: {integrity: sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -1022,6 +1022,9 @@ packages:
caniuse-lite@1.0.30001757:
resolution: {integrity: sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==}
caniuse-lite@1.0.30001760:
resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -1908,8 +1911,8 @@ packages:
nodemailer:
optional: true
next@16.0.7:
resolution: {integrity: sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==}
next@16.0.10:
resolution: {integrity: sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -2898,34 +2901,34 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@next/env@16.0.7': {}
'@next/env@16.0.10': {}
'@next/eslint-plugin-next@16.0.5':
dependencies:
fast-glob: 3.3.1
'@next/swc-darwin-arm64@16.0.7':
'@next/swc-darwin-arm64@16.0.10':
optional: true
'@next/swc-darwin-x64@16.0.7':
'@next/swc-darwin-x64@16.0.10':
optional: true
'@next/swc-linux-arm64-gnu@16.0.7':
'@next/swc-linux-arm64-gnu@16.0.10':
optional: true
'@next/swc-linux-arm64-musl@16.0.7':
'@next/swc-linux-arm64-musl@16.0.10':
optional: true
'@next/swc-linux-x64-gnu@16.0.7':
'@next/swc-linux-x64-gnu@16.0.10':
optional: true
'@next/swc-linux-x64-musl@16.0.7':
'@next/swc-linux-x64-musl@16.0.10':
optional: true
'@next/swc-win32-arm64-msvc@16.0.7':
'@next/swc-win32-arm64-msvc@16.0.10':
optional: true
'@next/swc-win32-x64-msvc@16.0.7':
'@next/swc-win32-x64-msvc@16.0.10':
optional: true
'@nodelib/fs.scandir@2.1.5':
@@ -3474,6 +3477,8 @@ snapshots:
caniuse-lite@1.0.30001757: {}
caniuse-lite@1.0.30001760: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -4438,30 +4443,30 @@ snapshots:
natural-compare@1.4.0: {}
next-auth@5.0.0-beta.30(next@16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0):
next-auth@5.0.0-beta.30(next@16.0.10(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0):
dependencies:
'@auth/core': 0.41.0
next: 16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next: 16.0.10(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
next@16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
next@16.0.10(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@next/env': 16.0.7
'@next/env': 16.0.10
'@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001757
caniuse-lite: 1.0.30001760
postcss: 8.4.31
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.0)
optionalDependencies:
'@next/swc-darwin-arm64': 16.0.7
'@next/swc-darwin-x64': 16.0.7
'@next/swc-linux-arm64-gnu': 16.0.7
'@next/swc-linux-arm64-musl': 16.0.7
'@next/swc-linux-x64-gnu': 16.0.7
'@next/swc-linux-x64-musl': 16.0.7
'@next/swc-win32-arm64-msvc': 16.0.7
'@next/swc-win32-x64-msvc': 16.0.7
'@next/swc-darwin-arm64': 16.0.10
'@next/swc-darwin-x64': 16.0.10
'@next/swc-linux-arm64-gnu': 16.0.10
'@next/swc-linux-arm64-musl': 16.0.10
'@next/swc-linux-x64-gnu': 16.0.10
'@next/swc-linux-x64-musl': 16.0.10
'@next/swc-win32-arm64-msvc': 16.0.10
'@next/swc-win32-x64-msvc': 16.0.10
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'

View File

@@ -0,0 +1,70 @@
-- CreateTable
CREATE TABLE "YearReviewSession" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"participant" TEXT NOT NULL,
"year" INTEGER NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "YearReviewSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "YearReviewItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"content" TEXT NOT NULL,
"category" TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
"sessionId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "YearReviewItem_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "YearReviewSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "YRSessionShare" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'EDITOR',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "YRSessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "YearReviewSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "YRSessionShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "YRSessionEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payload" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "YRSessionEvent_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "YearReviewSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "YRSessionEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "YearReviewSession_userId_idx" ON "YearReviewSession"("userId");
-- CreateIndex
CREATE INDEX "YearReviewSession_year_idx" ON "YearReviewSession"("year");
-- CreateIndex
CREATE INDEX "YearReviewItem_sessionId_idx" ON "YearReviewItem"("sessionId");
-- CreateIndex
CREATE INDEX "YearReviewItem_sessionId_category_idx" ON "YearReviewItem"("sessionId", "category");
-- CreateIndex
CREATE INDEX "YRSessionShare_sessionId_idx" ON "YRSessionShare"("sessionId");
-- CreateIndex
CREATE INDEX "YRSessionShare_userId_idx" ON "YRSessionShare"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "YRSessionShare_sessionId_userId_key" ON "YRSessionShare"("sessionId", "userId");
-- CreateIndex
CREATE INDEX "YRSessionEvent_sessionId_createdAt_idx" ON "YRSessionEvent"("sessionId", "createdAt");

View File

@@ -21,6 +21,10 @@ model User {
motivatorSessions MovingMotivatorsSession[]
sharedMotivatorSessions MMSessionShare[]
motivatorSessionEvents MMSessionEvent[]
// Year Review relations
yearReviewSessions YearReviewSession[]
sharedYearReviewSessions YRSessionShare[]
yearReviewSessionEvents YRSessionEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -200,3 +204,73 @@ model MMSessionEvent {
@@index([sessionId, createdAt])
}
// ============================================
// Year Review Workshop
// ============================================
enum YearReviewCategory {
ACHIEVEMENTS // Réalisations / Accomplissements
CHALLENGES // Défis / Difficultés rencontrées
LEARNINGS // Apprentissages / Compétences développées
GOALS // Objectifs pour l'année suivante
MOMENTS // Moments forts / Moments difficiles
}
model YearReviewSession {
id String @id @default(cuid())
title String
participant String // Nom du participant
year Int // Année du bilan (ex: 2024)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items YearReviewItem[]
shares YRSessionShare[]
events YRSessionEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([year])
}
model YearReviewItem {
id String @id @default(cuid())
content String
category YearReviewCategory
order Int @default(0)
sessionId String
session YearReviewSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([sessionId])
@@index([sessionId, category])
}
model YRSessionShare {
id String @id @default(cuid())
sessionId String
session YearReviewSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role ShareRole @default(EDITOR)
createdAt DateTime @default(now())
@@unique([sessionId, userId])
@@index([sessionId])
@@index([userId])
}
model YRSessionEvent {
id String @id @default(cuid())
sessionId String
session YearReviewSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // ITEM_CREATED, ITEM_UPDATED, ITEM_DELETED, etc.
payload String // JSON payload
createdAt DateTime @default(now())
@@index([sessionId, createdAt])
}

View File

@@ -183,6 +183,7 @@ export async function updateAction(
description?: string;
priority?: number;
status?: string;
linkedItemIds?: string[];
}
) {
const session = await auth();

329
src/actions/year-review.ts Normal file
View File

@@ -0,0 +1,329 @@
'use server';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import * as yearReviewService from '@/services/year-review';
import type { YearReviewCategory } from '@prisma/client';
// ============================================
// Session Actions
// ============================================
export async function createYearReviewSession(data: {
title: string;
participant: string;
year: number;
}) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const yearReviewSession = await yearReviewService.createYearReviewSession(
session.user.id,
data
);
revalidatePath('/year-review');
revalidatePath('/sessions');
return { success: true, data: yearReviewSession };
} catch (error) {
console.error('Error creating year review session:', error);
return { success: false, error: 'Erreur lors de la création' };
}
}
export async function updateYearReviewSession(
sessionId: string,
data: { title?: string; participant?: string; year?: number }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await yearReviewService.updateYearReviewSession(sessionId, authSession.user.id, data);
// Emit event for real-time sync
await yearReviewService.createYearReviewSessionEvent(
sessionId,
authSession.user.id,
'SESSION_UPDATED',
data
);
revalidatePath(`/year-review/${sessionId}`);
revalidatePath('/year-review');
revalidatePath('/sessions');
return { success: true };
} catch (error) {
console.error('Error updating year review session:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function deleteYearReviewSession(sessionId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await yearReviewService.deleteYearReviewSession(sessionId, authSession.user.id);
revalidatePath('/year-review');
revalidatePath('/sessions');
return { success: true };
} catch (error) {
console.error('Error deleting year review session:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}
// ============================================
// Item Actions
// ============================================
export async function createYearReviewItem(
sessionId: string,
data: { content: string; category: YearReviewCategory }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
const item = await yearReviewService.createYearReviewItem(sessionId, data);
// Emit event for real-time sync
await yearReviewService.createYearReviewSessionEvent(
sessionId,
authSession.user.id,
'ITEM_CREATED',
{
itemId: item.id,
content: item.content,
category: item.category,
}
);
revalidatePath(`/year-review/${sessionId}`);
return { success: true, data: item };
} catch (error) {
console.error('Error creating year review item:', error);
return { success: false, error: 'Erreur lors de la création' };
}
}
export async function updateYearReviewItem(
itemId: string,
sessionId: string,
data: { content?: string; category?: YearReviewCategory }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
const item = await yearReviewService.updateYearReviewItem(itemId, data);
// Emit event for real-time sync
await yearReviewService.createYearReviewSessionEvent(
sessionId,
authSession.user.id,
'ITEM_UPDATED',
{
itemId: item.id,
...data,
}
);
revalidatePath(`/year-review/${sessionId}`);
return { success: true, data: item };
} catch (error) {
console.error('Error updating year review item:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function deleteYearReviewItem(itemId: string, sessionId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await yearReviewService.deleteYearReviewItem(itemId);
// Emit event for real-time sync
await yearReviewService.createYearReviewSessionEvent(
sessionId,
authSession.user.id,
'ITEM_DELETED',
{ itemId }
);
revalidatePath(`/year-review/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error deleting year review item:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}
export async function moveYearReviewItem(
itemId: string,
sessionId: string,
newCategory: YearReviewCategory,
newOrder: number
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await yearReviewService.moveYearReviewItem(itemId, newCategory, newOrder);
// Emit event for real-time sync
await yearReviewService.createYearReviewSessionEvent(
sessionId,
authSession.user.id,
'ITEM_MOVED',
{
itemId,
category: newCategory,
order: newOrder,
}
);
revalidatePath(`/year-review/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error moving year review item:', error);
return { success: false, error: 'Erreur lors du déplacement' };
}
}
export async function reorderYearReviewItems(
sessionId: string,
category: YearReviewCategory,
itemIds: string[]
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await yearReviewService.reorderYearReviewItems(sessionId, category, itemIds);
// Emit event for real-time sync
await yearReviewService.createYearReviewSessionEvent(
sessionId,
authSession.user.id,
'ITEMS_REORDERED',
{ category, itemIds }
);
revalidatePath(`/year-review/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error reordering year review items:', error);
return { success: false, error: 'Erreur lors du réordonnancement' };
}
}
// ============================================
// Sharing Actions
// ============================================
export async function shareYearReviewSession(
sessionId: string,
targetEmail: string,
role: 'VIEWER' | 'EDITOR' = 'EDITOR'
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const share = await yearReviewService.shareYearReviewSession(
sessionId,
authSession.user.id,
targetEmail,
role
);
revalidatePath(`/year-review/${sessionId}`);
return { success: true, data: share };
} catch (error) {
console.error('Error sharing year review session:', error);
const message = error instanceof Error ? error.message : 'Erreur lors du partage';
return { success: false, error: message };
}
}
export async function removeYearReviewShare(sessionId: string, shareUserId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await yearReviewService.removeYearReviewShare(sessionId, authSession.user.id, shareUserId);
revalidatePath(`/year-review/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error removing year review share:', error);
return { success: false, error: 'Erreur lors de la suppression du partage' };
}
}

View File

@@ -0,0 +1,123 @@
import { auth } from '@/lib/auth';
import {
canAccessYearReviewSession,
getYearReviewSessionEvents,
} from '@/services/year-review';
export const dynamic = 'force-dynamic';
// Store active connections per session
const connections = new Map<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 canAccessYearReviewSession(sessionId, session.user.id);
if (!hasAccess) {
return new Response('Forbidden', { status: 403 });
}
const userId = session.user.id;
let lastEventTime = new Date();
let controller: ReadableStreamDefaultController;
const stream = new ReadableStream({
start(ctrl) {
controller = ctrl;
// Register connection
if (!connections.has(sessionId)) {
connections.set(sessionId, new Set());
}
connections.get(sessionId)!.add(controller);
// Send initial ping
const encoder = new TextEncoder();
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`)
);
},
cancel() {
// Remove connection on close
connections.get(sessionId)?.delete(controller);
if (connections.get(sessionId)?.size === 0) {
connections.delete(sessionId);
}
},
});
// Poll for new events (simple approach, works with any DB)
const pollInterval = setInterval(async () => {
try {
const events = await getYearReviewSessionEvents(sessionId, lastEventTime);
if (events.length > 0) {
const encoder = new TextEncoder();
for (const event of events) {
// Don't send events to the user who created them
if (event.userId !== userId) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: event.type,
payload: JSON.parse(event.payload),
userId: event.userId,
user: event.user,
timestamp: event.createdAt,
})}\n\n`
)
);
}
lastEventTime = event.createdAt;
}
}
} catch {
// Connection might be closed
clearInterval(pollInterval);
}
}, 1000); // Poll every second
// Cleanup on abort
request.signal.addEventListener('abort', () => {
clearInterval(pollInterval);
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
// Helper to broadcast to all connections (called from actions)
export function broadcastToYearReviewSession(sessionId: string, event: object) {
const sessionConnections = connections.get(sessionId);
if (!sessionConnections || sessionConnections.size === 0) {
return;
}
const encoder = new TextEncoder();
const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
for (const controller of sessionConnections) {
try {
controller.enqueue(message);
} catch {
// Connection might be closed, remove it
sessionConnections.delete(controller);
}
}
// Clean up empty sets
if (sessionConnections.size === 0) {
connections.delete(sessionId);
}
}

View File

@@ -4,7 +4,7 @@ import { auth } from '@/lib/auth';
import { getMotivatorSessionById } from '@/services/moving-motivators';
import { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators';
import { Badge, CollaboratorDisplay } from '@/components/ui';
import { EditableMotivatorTitle } from './EditableTitle';
import { EditableMotivatorTitle } from '@/components/ui';
interface MotivatorSessionPageProps {
params: Promise<{ id: string }>;

View File

@@ -20,7 +20,7 @@ export default function Home() {
<h2 className="mb-8 text-center text-2xl font-bold text-foreground">
Choisissez votre atelier
</h2>
<div className="grid gap-8 md:grid-cols-2 max-w-4xl mx-auto">
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3 max-w-6xl mx-auto">
{/* SWOT Workshop Card */}
<WorkshopCard
href="/sessions?tab=swot"
@@ -52,6 +52,22 @@ export default function Home() {
accentColor="#8b5cf6"
newHref="/motivators/new"
/>
{/* Year Review Workshop Card */}
<WorkshopCard
href="/sessions?tab=year-review"
icon="📅"
title="Year Review"
tagline="Faites le bilan de l'année"
description="Réalisez un bilan complet de l'année écoulée. Identifiez réalisations, défis, apprentissages et définissez vos objectifs pour l'année à venir."
features={[
'5 catégories : Réalisations, Défis, Apprentissages, Objectifs, Moments',
'Organisation par drag & drop',
'Vue d\'ensemble de l\'année',
]}
accentColor="#f59e0b"
newHref="/year-review/new"
/>
</div>
</section>
@@ -250,6 +266,95 @@ export default function Home() {
</div>
</section>
{/* Year Review Deep Dive Section */}
<section className="mb-16">
<div className="flex items-center gap-3 mb-8">
<span className="text-4xl">📅</span>
<div>
<h2 className="text-3xl font-bold text-foreground">Year Review</h2>
<p className="text-amber-500 font-medium">Faites le bilan de l&apos;année écoulée</p>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-2">
{/* Why */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">💭</span>
Pourquoi faire un bilan annuel ?
</h3>
<p className="text-muted mb-4">
Le Year Review est un exercice de réflexion structuré qui permet de prendre du recul
sur l&apos;année écoulée. Il aide à identifier les patterns, célébrer les réussites,
apprendre des défis et préparer l&apos;avenir avec clarté.
</p>
<ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2">
<span className="text-amber-500"></span>
Prendre conscience de ses accomplissements et les célébrer
</li>
<li className="flex items-start gap-2">
<span className="text-amber-500"></span>
Identifier les apprentissages et compétences développées
</li>
<li className="flex items-start gap-2">
<span className="text-amber-500"></span>
Comprendre les défis rencontrés pour mieux les anticiper
</li>
<li className="flex items-start gap-2">
<span className="text-amber-500"></span>
Définir des objectifs clairs et motivants pour l&apos;année à venir
</li>
</ul>
</div>
{/* The 5 categories */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">📋</span>
Les 5 catégories du bilan
</h3>
<div className="space-y-3">
<CategoryPill icon="🏆" name="Réalisations" color="#22c55e" description="Ce que vous avez accompli" />
<CategoryPill icon="⚔️" name="Défis" color="#ef4444" description="Les difficultés rencontrées" />
<CategoryPill icon="📚" name="Apprentissages" color="#3b82f6" description="Ce que vous avez appris" />
<CategoryPill icon="🎯" name="Objectifs" color="#8b5cf6" description="Vos ambitions pour l'année prochaine" />
<CategoryPill icon="⭐" name="Moments" color="#f59e0b" description="Les moments forts et marquants" />
</div>
</div>
{/* How it works */}
<div className="rounded-xl border border-border bg-card p-6 lg:col-span-2">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl"></span>
Comment ça marche ?
</h3>
<div className="grid md:grid-cols-4 gap-4">
<StepCard
number={1}
title="Réfléchir"
description="Prenez le temps de revenir sur l'année écoulée, consultez votre agenda, vos notes, vos projets"
/>
<StepCard
number={2}
title="Catégoriser"
description="Organisez vos réflexions dans les 5 catégories : réalisations, défis, apprentissages, objectifs et moments"
/>
<StepCard
number={3}
title="Prioriser"
description="Classez les éléments par importance et impact pour identifier ce qui compte vraiment"
/>
<StepCard
number={4}
title="Planifier"
description="Utilisez ce bilan pour définir vos objectifs et actions pour l'année à venir"
/>
</div>
</div>
</div>
</section>
{/* Benefits Section */}
<section className="rounded-2xl border border-border bg-card p-8">
<h2 className="mb-8 text-center text-2xl font-bold text-foreground">
@@ -420,3 +525,30 @@ function MotivatorPill({ icon, name, color }: { icon: string; name: string; colo
</div>
);
}
function CategoryPill({
icon,
name,
color,
description,
}: {
icon: string;
name: string;
color: string;
description: string;
}) {
return (
<div
className="flex items-start gap-3 px-4 py-3 rounded-lg"
style={{ backgroundColor: `${color}10`, border: `1px solid ${color}30` }}
>
<span className="text-xl">{icon}</span>
<div className="flex-1">
<p className="font-semibold text-sm mb-0.5" style={{ color }}>
{name}
</p>
<p className="text-xs text-muted">{description}</p>
</div>
</div>
);
}

View File

@@ -14,10 +14,11 @@ import {
} from '@/components/ui';
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review';
type WorkshopType = 'all' | 'swot' | 'motivators' | 'byPerson';
type WorkshopType = 'all' | 'swot' | 'motivators' | 'year-review' | 'byPerson';
const VALID_TABS: WorkshopType[] = ['all', 'swot', 'motivators', 'byPerson'];
const VALID_TABS: WorkshopType[] = ['all', 'swot', 'motivators', 'year-review', 'byPerson'];
interface ShareUser {
id: string;
@@ -68,34 +69,38 @@ interface MotivatorSession {
workshopType: 'motivators';
}
type AnySession = SwotSession | MotivatorSession;
interface YearReviewSession {
id: string;
title: string;
participant: string;
resolvedParticipant: ResolvedCollaborator;
year: number;
updatedAt: Date;
isOwner: boolean;
role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[];
_count: { items: number };
workshopType: 'year-review';
}
type AnySession = SwotSession | MotivatorSession | YearReviewSession;
interface WorkshopTabsProps {
swotSessions: SwotSession[];
motivatorSessions: MotivatorSession[];
}
// Helper to get participant name from any session
function getParticipant(session: AnySession): string {
return session.workshopType === 'swot'
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant;
yearReviewSessions: YearReviewSession[];
}
// Helper to get resolved collaborator from any session
function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
return session.workshopType === 'swot'
? (session as SwotSession).resolvedCollaborator
: (session as MotivatorSession).resolvedParticipant;
}
// Get display name for grouping - prefer matched user name
function getDisplayName(session: AnySession): string {
const resolved = getResolvedCollaborator(session);
if (resolved.matchedUser?.name) {
return resolved.matchedUser.name;
if (session.workshopType === 'swot') {
return (session as SwotSession).resolvedCollaborator;
} else if (session.workshopType === 'year-review') {
return (session as YearReviewSession).resolvedParticipant;
} else {
return (session as MotivatorSession).resolvedParticipant;
}
return resolved.raw;
}
// Get grouping key - use matched user ID if available, otherwise normalized raw string
@@ -132,7 +137,11 @@ function groupByPerson(sessions: AnySession[]): Map<string, AnySession[]> {
return grouped;
}
export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsProps) {
export function WorkshopTabs({
swotSessions,
motivatorSessions,
yearReviewSessions,
}: WorkshopTabsProps) {
const searchParams = useSearchParams();
const router = useRouter();
@@ -152,9 +161,11 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
};
// Combine and sort all sessions
const allSessions: AnySession[] = [...swotSessions, ...motivatorSessions].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
const allSessions: AnySession[] = [
...swotSessions,
...motivatorSessions,
...yearReviewSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
// Filter based on active tab (for non-byPerson tabs)
const filteredSessions =
@@ -162,7 +173,9 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
? allSessions
: activeTab === 'swot'
? swotSessions
: motivatorSessions;
: activeTab === 'motivators'
? motivatorSessions
: yearReviewSessions;
// Separate by ownership
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
@@ -206,6 +219,13 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
label="Moving Motivators"
count={motivatorSessions.length}
/>
<TabButton
active={activeTab === 'year-review'}
onClick={() => setActiveTab('year-review')}
icon="📅"
label="Year Review"
count={yearReviewSessions.length}
/>
</div>
{/* Sessions */}
@@ -316,22 +336,33 @@ function SessionCard({ session }: { session: AnySession }) {
const [editParticipant, setEditParticipant] = useState(
session.workshopType === 'swot'
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant
: session.workshopType === 'year-review'
? (session as YearReviewSession).participant
: (session as MotivatorSession).participant
);
const isSwot = session.workshopType === 'swot';
const href = isSwot ? `/sessions/${session.id}` : `/motivators/${session.id}`;
const icon = isSwot ? '📊' : '🎯';
const isYearReview = session.workshopType === 'year-review';
const href = isSwot
? `/sessions/${session.id}`
: isYearReview
? `/year-review/${session.id}`
: `/motivators/${session.id}`;
const icon = isSwot ? '📊' : isYearReview ? '📅' : '🎯';
const participant = isSwot
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant;
const accentColor = isSwot ? '#06b6d4' : '#8b5cf6';
: isYearReview
? (session as YearReviewSession).participant
: (session as MotivatorSession).participant;
const accentColor = isSwot ? '#06b6d4' : isYearReview ? '#f59e0b' : '#8b5cf6';
const handleDelete = () => {
startTransition(async () => {
const result = isSwot
? await deleteSwotSession(session.id)
: await deleteMotivatorSession(session.id);
: isYearReview
? await deleteYearReviewSession(session.id)
: await deleteMotivatorSession(session.id);
if (result.success) {
setShowDeleteModal(false);
@@ -345,10 +376,15 @@ function SessionCard({ session }: { session: AnySession }) {
startTransition(async () => {
const result = isSwot
? await updateSwotSession(session.id, { title: editTitle, collaborator: editParticipant })
: await updateMotivatorSession(session.id, {
title: editTitle,
participant: editParticipant,
});
: isYearReview
? await updateYearReviewSession(session.id, {
title: editTitle,
participant: editParticipant,
})
: await updateMotivatorSession(session.id, {
title: editTitle,
participant: editParticipant,
});
if (result.success) {
setShowEditModal(false);
@@ -414,6 +450,12 @@ function SessionCard({ session }: { session: AnySession }) {
<span>·</span>
<span>{(session as SwotSession)._count.actions} actions</span>
</>
) : isYearReview ? (
<>
<span>{(session as YearReviewSession)._count.items} items</span>
<span>·</span>
<span>Année {(session as YearReviewSession).year}</span>
</>
) : (
<span>{(session as MotivatorSession)._count.cards}/10</span>
)}

View File

@@ -4,7 +4,7 @@ import { auth } from '@/lib/auth';
import { getSessionById } from '@/services/sessions';
import { SwotBoard } from '@/components/swot/SwotBoard';
import { SessionLiveWrapper } from '@/components/collaboration';
import { EditableTitle } from '@/components/session';
import { EditableSessionTitle } from '@/components/ui';
import { Badge, CollaboratorDisplay } from '@/components/ui';
interface SessionPageProps {
@@ -44,7 +44,7 @@ export default async function SessionPage({ params }: SessionPageProps) {
<div className="flex items-start justify-between">
<div>
<EditableTitle
<EditableSessionTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}

View File

@@ -3,6 +3,7 @@ import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getSessionsByUserId } from '@/services/sessions';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
import { getYearReviewSessionsByUserId } from '@/services/year-review';
import { Card, Button } from '@/components/ui';
import { WorkshopTabs } from './WorkshopTabs';
@@ -32,10 +33,11 @@ export default async function SessionsPage() {
return null;
}
// Fetch both SWOT and Moving Motivators sessions
const [swotSessions, motivatorSessions] = await Promise.all([
// Fetch SWOT, Moving Motivators, and Year Review sessions
const [swotSessions, motivatorSessions, yearReviewSessions] = await Promise.all([
getSessionsByUserId(session.user.id),
getMotivatorSessionsByUserId(session.user.id),
getYearReviewSessionsByUserId(session.user.id),
]);
// Add type to each session for unified display
@@ -49,10 +51,17 @@ export default async function SessionsPage() {
workshopType: 'motivators' as const,
}));
const allYearReviewSessions = yearReviewSessions.map((s) => ({
...s,
workshopType: 'year-review' as const,
}));
// Combine and sort by updatedAt
const allSessions = [...allSwotSessions, ...allMotivatorSessions].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
const allSessions = [
...allSwotSessions,
...allMotivatorSessions,
...allYearReviewSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const hasNoSessions = allSessions.length === 0;
@@ -72,11 +81,17 @@ export default async function SessionsPage() {
</Button>
</Link>
<Link href="/motivators/new">
<Button>
<Button variant="outline">
<span>🎯</span>
Nouveau Motivators
</Button>
</Link>
<Link href="/year-review/new">
<Button>
<span>📅</span>
Nouveau Year Review
</Button>
</Link>
</div>
</div>
@@ -88,8 +103,8 @@ export default async function SessionsPage() {
Commencez votre premier atelier
</h2>
<p className="text-muted mb-6 max-w-md mx-auto">
Créez un atelier SWOT pour analyser les forces et faiblesses, ou un Moving Motivators
pour découvrir les motivations de vos collaborateurs.
Créez un atelier SWOT pour analyser les forces et faiblesses, un Moving Motivators pour
découvrir les motivations, ou un Year Review pour faire le bilan de l&apos;année.
</p>
<div className="flex gap-3 justify-center">
<Link href="/sessions/new">
@@ -99,16 +114,26 @@ export default async function SessionsPage() {
</Button>
</Link>
<Link href="/motivators/new">
<Button>
<Button variant="outline">
<span>🎯</span>
Créer un Moving Motivators
</Button>
</Link>
<Link href="/year-review/new">
<Button>
<span>📅</span>
Créer un Year Review
</Button>
</Link>
</div>
</Card>
) : (
<Suspense fallback={<WorkshopTabsSkeleton />}>
<WorkshopTabs swotSessions={allSwotSessions} motivatorSessions={allMotivatorSessions} />
<WorkshopTabs
swotSessions={allSwotSessions}
motivatorSessions={allMotivatorSessions}
yearReviewSessions={allYearReviewSessions}
/>
</Suspense>
)}
</main>

View File

@@ -0,0 +1,82 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getYearReviewSessionById } from '@/services/year-review';
import { YearReviewBoard, YearReviewLiveWrapper } from '@/components/year-review';
import { Badge, CollaboratorDisplay } from '@/components/ui';
import { EditableYearReviewTitle } from '@/components/ui';
interface YearReviewSessionPageProps {
params: Promise<{ id: string }>;
}
export default async function YearReviewSessionPage({ params }: YearReviewSessionPageProps) {
const { id } = await params;
const authSession = await auth();
if (!authSession?.user?.id) {
return null;
}
const session = await getYearReviewSessionById(id, authSession.user.id);
if (!session) {
notFound();
}
return (
<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="/sessions?tab=year-review" className="hover:text-foreground">
Year Review
</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>
<EditableYearReviewTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
/>
<div className="mt-2">
<CollaboratorDisplay collaborator={session.resolvedParticipant} size="lg" showEmail />
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary">{session.items.length} items</Badge>
<Badge variant="default">Année {session.year}</Badge>
<span className="text-sm text-muted">
{new Date(session.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
{/* Live Wrapper + Board */}
<YearReviewLiveWrapper
sessionId={session.id}
sessionTitle={session.title}
currentUserId={authSession.user.id}
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
>
<YearReviewBoard sessionId={session.id} items={session.items} />
</YearReviewLiveWrapper>
</main>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
Input,
} from '@/components/ui';
import { createYearReviewSession } from '@/actions/year-review';
export default function NewYearReviewPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const currentYear = new Date().getFullYear();
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;
const year = parseInt(formData.get('year') as string, 10);
if (!title || !participant || !year) {
setError('Veuillez remplir tous les champs');
setLoading(false);
return;
}
const result = await createYearReviewSession({ title, participant, year });
if (!result.success) {
setError(result.error || 'Une erreur est survenue');
setLoading(false);
return;
}
router.push(`/year-review/${result.data?.id}`);
}
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>📅</span>
Nouveau Bilan Annuel
</CardTitle>
<CardDescription>
Créez un bilan de l&apos;année pour faire le point sur les réalisations, défis,
apprentissages et objectifs
</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 du bilan"
name="title"
placeholder={`Ex: Bilan annuel ${currentYear}`}
required
/>
<Input
label="Nom du participant"
name="participant"
placeholder="Ex: Jean Dupont"
required
/>
<div>
<label htmlFor="year" className="block text-sm font-medium text-foreground mb-1">
Année du bilan
</label>
<input
id="year"
name="year"
type="number"
min="2000"
max="2100"
defaultValue={currentYear}
required
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
/>
</div>
<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>
<strong>Réalisations</strong> : Notez ce que vous avez accompli cette année
</li>
<li>
<strong>Défis</strong> : Identifiez les difficultés rencontrées
</li>
<li>
<strong>Apprentissages</strong> : Listez ce que vous avez appris et développé
</li>
<li>
<strong>Objectifs</strong> : Définissez vos objectifs pour l&apos;année prochaine
</li>
<li>
<strong>Moments</strong> : Partagez les moments forts et marquants
</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 le bilan
</Button>
</div>
</form>
</CardContent>
</Card>
</main>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useTransition } from 'react';
import { useState, useTransition, useEffect } from 'react';
import {
DndContext,
closestCenter,
@@ -35,6 +35,11 @@ export function MotivatorBoard({ sessionId, cards: initialCards, canEdit }: Moti
const [step, setStep] = useState<Step>('ranking');
const [isPending, startTransition] = useTransition();
// Sync local state with props when they change (e.g., from SSE refresh)
useEffect(() => {
setCards(initialCards);
}, [initialCards]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {

View File

@@ -1,105 +0,0 @@
'use client';
import { useState, useTransition, useRef, useEffect } from 'react';
import { updateSessionTitle } from '@/actions/session';
interface EditableTitleProps {
sessionId: string;
initialTitle: string;
isOwner: boolean;
}
export function EditableTitle({ sessionId, initialTitle, isOwner }: EditableTitleProps) {
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 updateSessionTitle(sessionId, title.trim());
if (!result.success) {
setTitle(initialTitle);
console.error(result.error);
}
setIsEditing(false);
});
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
setTitle(initialTitle);
setIsEditing(false);
}
};
if (!isOwner) {
return <h1 className="text-3xl font-bold text-foreground">{title}</h1>;
}
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
disabled={isPending}
className="w-full max-w-md rounded-lg border border-border bg-input px-3 py-1.5 text-3xl font-bold text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 disabled:opacity-50"
/>
);
}
return (
<button
onClick={() => setIsEditing(true)}
className="group flex items-center gap-2 text-left"
title="Cliquez pour modifier"
>
<h1 className="text-3xl font-bold text-foreground">{title}</h1>
<svg
className="h-5 w-5 text-muted opacity-0 transition-opacity group-hover:opacity-100"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
);
}

View File

@@ -1 +0,0 @@
export { EditableTitle } from './EditableTitle';

View File

@@ -66,6 +66,7 @@ export function ActionPanel({
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState(1);
const [editingSelectedItems, setEditingSelectedItems] = useState<string[]>([]);
function openCreateModal() {
if (selectedItems.length < 2) {
@@ -75,6 +76,7 @@ export function ActionPanel({
setTitle('');
setDescription('');
setPriority(1);
setEditingSelectedItems([]);
setEditingAction(null);
setShowModal(true);
}
@@ -83,6 +85,7 @@ export function ActionPanel({
setTitle(action.title);
setDescription(action.description || '');
setPriority(action.priority);
setEditingSelectedItems(action.links.map((link) => link.swotItemId));
setEditingAction(action);
setShowModal(true);
}
@@ -90,9 +93,16 @@ export function ActionPanel({
function closeModal() {
setShowModal(false);
setEditingAction(null);
setEditingSelectedItems([]);
onExitLinkMode();
}
function toggleEditingItem(itemId: string) {
setEditingSelectedItems((prev) =>
prev.includes(itemId) ? prev.filter((id) => id !== itemId) : [...prev, itemId]
);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -100,10 +110,15 @@ export function ActionPanel({
startTransition(async () => {
if (editingAction) {
if (editingSelectedItems.length < 2) {
alert('Une action doit être liée à au moins 2 items SWOT');
return;
}
await updateAction(editingAction.id, sessionId, {
title: title.trim(),
description: description.trim() || undefined,
priority,
linkedItemIds: editingSelectedItems,
});
} else {
await createAction(sessionId, {
@@ -302,6 +317,48 @@ export function ActionPanel({
</div>
)}
{editingAction && (
<div className="mb-4">
<p className="mb-2 text-sm font-medium text-foreground">
Items liés ({editingSelectedItems.length}) :
</p>
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border border-border bg-background p-3">
{allItems.map((item) => {
const isSelected = editingSelectedItems.includes(item.id);
return (
<label
key={item.id}
className={`
flex cursor-pointer items-center gap-2 rounded-lg border p-2 transition-colors
${
isSelected
? 'border-primary bg-primary/10'
: 'border-border bg-card hover:bg-card-hover'
}
`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleEditingItem(item.id)}
className="h-4 w-4 rounded border-border text-primary focus:ring-primary"
/>
<Badge variant={categoryBadgeVariant[item.category]} className="shrink-0">
{categoryShort[item.category]}
</Badge>
<span className="text-sm text-foreground">{item.content}</span>
</label>
);
})}
</div>
{editingSelectedItems.length < 2 && (
<p className="mt-2 text-xs text-destructive">
Sélectionnez au moins 2 items SWOT
</p>
)}
</div>
)}
<div className="space-y-4">
<Input
label="Titre de l'action"

View File

@@ -1,6 +1,6 @@
'use client';
import { forwardRef, useState, useTransition, ReactNode } from 'react';
import { forwardRef, useState, useTransition, useRef, ReactNode } from 'react';
import type { SwotCategory } from '@prisma/client';
import { createSwotItem } from '@/actions/swot';
import { QuadrantHelpPanel } from './QuadrantHelp';
@@ -43,15 +43,17 @@ export const SwotQuadrant = forwardRef<HTMLDivElement, SwotQuadrantProps>(
const [newContent, setNewContent] = useState('');
const [isPending, startTransition] = useTransition();
const [showHelp, setShowHelp] = useState(false);
const isSubmittingRef = useRef(false);
const styles = categoryStyles[category];
async function handleAdd() {
if (!newContent.trim()) {
if (isSubmittingRef.current || !newContent.trim()) {
setIsAdding(false);
return;
}
isSubmittingRef.current = true;
startTransition(async () => {
await createSwotItem(sessionId, {
content: newContent.trim(),
@@ -59,6 +61,7 @@ export const SwotQuadrant = forwardRef<HTMLDivElement, SwotQuadrantProps>(
});
setNewContent('');
setIsAdding(false);
isSubmittingRef.current = false;
});
}
@@ -138,7 +141,12 @@ export const SwotQuadrant = forwardRef<HTMLDivElement, SwotQuadrantProps>(
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleAdd}
onBlur={(e) => {
// Don't trigger on blur if clicking on a button
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
handleAdd();
}
}}
placeholder="Décrivez cet élément..."
className="w-full resize-none rounded border-0 bg-transparent p-1 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-0"
rows={2}
@@ -156,6 +164,9 @@ export const SwotQuadrant = forwardRef<HTMLDivElement, SwotQuadrantProps>(
Annuler
</button>
<button
onMouseDown={(e) => {
e.preventDefault(); // Prevent blur from textarea
}}
onClick={handleAdd}
disabled={isPending || !newContent.trim()}
className={`rounded px-2 py-1 text-xs font-medium ${styles.text} hover:bg-white/50 disabled:opacity-50`}

View File

@@ -0,0 +1,29 @@
'use client';
import { EditableTitle } from './EditableTitle';
import { updateMotivatorSession } from '@/actions/moving-motivators';
interface EditableMotivatorTitleProps {
sessionId: string;
initialTitle: string;
isOwner: boolean;
}
export function EditableMotivatorTitle({
sessionId,
initialTitle,
isOwner,
}: EditableMotivatorTitleProps) {
return (
<EditableTitle
sessionId={sessionId}
initialTitle={initialTitle}
isOwner={isOwner}
onUpdate={async (id, title) => {
const result = await updateMotivatorSession(id, { title });
return result;
}}
/>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { EditableTitle } from './EditableTitle';
import { updateSessionTitle } from '@/actions/session';
interface EditableSessionTitleProps {
sessionId: string;
initialTitle: string;
isOwner: boolean;
}
export function EditableSessionTitle({
sessionId,
initialTitle,
isOwner,
}: EditableSessionTitleProps) {
return (
<EditableTitle
sessionId={sessionId}
initialTitle={initialTitle}
isOwner={isOwner}
onUpdate={async (id, title) => {
const result = await updateSessionTitle(id, title);
return result;
}}
/>
);
}

View File

@@ -1,24 +1,28 @@
'use client';
import { useState, useTransition, useRef, useEffect } from 'react';
import { updateMotivatorSession } from '@/actions/moving-motivators';
import { useState, useTransition, useRef, useEffect, useMemo } from 'react';
interface EditableMotivatorTitleProps {
interface EditableTitleProps {
sessionId: string;
initialTitle: string;
isOwner: boolean;
onUpdate: (sessionId: string, title: string) => Promise<{ success: boolean; error?: string }>;
}
export function EditableMotivatorTitle({
export function EditableTitle({
sessionId,
initialTitle,
isOwner,
}: EditableMotivatorTitleProps) {
onUpdate,
}: EditableTitleProps) {
const [isEditing, setIsEditing] = useState(false);
const [title, setTitle] = useState(initialTitle);
const [editingTitle, setEditingTitle] = useState('');
const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
// Use editingTitle when editing, otherwise use initialTitle (synced from SSE)
const title = useMemo(() => (isEditing ? editingTitle : initialTitle), [isEditing, editingTitle, initialTitle]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
@@ -26,32 +30,28 @@ export function EditableMotivatorTitle({
}
}, [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);
const trimmedTitle = editingTitle.trim();
if (!trimmedTitle) {
setEditingTitle('');
setIsEditing(false);
return;
}
if (title.trim() === initialTitle) {
if (trimmedTitle === initialTitle) {
setEditingTitle('');
setIsEditing(false);
return;
}
startTransition(async () => {
const result = await updateMotivatorSession(sessionId, { title: title.trim() });
const result = await onUpdate(sessionId, trimmedTitle);
if (!result.success) {
setTitle(initialTitle);
setEditingTitle('');
console.error(result.error);
}
setIsEditing(false);
setEditingTitle('');
});
};
@@ -60,7 +60,7 @@ export function EditableMotivatorTitle({
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
setTitle(initialTitle);
setEditingTitle('');
setIsEditing(false);
}
};
@@ -74,8 +74,8 @@ export function EditableMotivatorTitle({
<input
ref={inputRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
disabled={isPending}
@@ -86,7 +86,10 @@ export function EditableMotivatorTitle({
return (
<button
onClick={() => setIsEditing(true)}
onClick={() => {
setEditingTitle(initialTitle);
setIsEditing(true);
}}
className="group flex items-center gap-2 text-left"
title="Cliquez pour modifier"
>
@@ -107,3 +110,4 @@ export function EditableMotivatorTitle({
</button>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { EditableTitle } from './EditableTitle';
import { updateYearReviewSession } from '@/actions/year-review';
interface EditableYearReviewTitleProps {
sessionId: string;
initialTitle: string;
isOwner: boolean;
}
export function EditableYearReviewTitle({
sessionId,
initialTitle,
isOwner,
}: EditableYearReviewTitleProps) {
return (
<EditableTitle
sessionId={sessionId}
initialTitle={initialTitle}
isOwner={isOwner}
onUpdate={async (id, title) => {
const result = await updateYearReviewSession(id, { title });
return result;
}}
/>
);
}

View File

@@ -3,6 +3,10 @@ export { Badge } from './Badge';
export { Button } from './Button';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
export { CollaboratorDisplay } from './CollaboratorDisplay';
export { EditableTitle } from './EditableTitle';
export { EditableSessionTitle } from './EditableSessionTitle';
export { EditableMotivatorTitle } from './EditableMotivatorTitle';
export { EditableYearReviewTitle } from './EditableYearReviewTitle';
export { Input } from './Input';
export { Modal, ModalFooter } from './Modal';
export { Textarea } from './Textarea';

View File

@@ -0,0 +1,94 @@
'use client';
import { useTransition } from 'react';
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
import type { YearReviewItem, YearReviewCategory } from '@prisma/client';
import { YearReviewSection } from './YearReviewSection';
import { YearReviewCard } from './YearReviewCard';
import { moveYearReviewItem, reorderYearReviewItems } from '@/actions/year-review';
import { YEAR_REVIEW_SECTIONS } from '@/lib/types';
interface YearReviewBoardProps {
sessionId: string;
items: YearReviewItem[];
}
export function YearReviewBoard({ sessionId, items }: YearReviewBoardProps) {
const [isPending, startTransition] = useTransition();
const itemsByCategory = YEAR_REVIEW_SECTIONS.reduce(
(acc, section) => {
acc[section.category] = items
.filter((item) => item.category === section.category)
.sort((a, b) => a.order - b.order);
return acc;
},
{} as Record<YearReviewCategory, YearReviewItem[]>
);
function handleDragEnd(result: DropResult) {
if (!result.destination) return;
const { source, destination, draggableId } = result;
const sourceCategory = source.droppableId as YearReviewCategory;
const destCategory = destination.droppableId as YearReviewCategory;
// If same position, do nothing
if (sourceCategory === destCategory && source.index === destination.index) {
return;
}
startTransition(async () => {
if (sourceCategory === destCategory) {
// Same category - just reorder
const categoryItems = itemsByCategory[sourceCategory];
const itemIds = categoryItems.map((item) => item.id);
const [removed] = itemIds.splice(source.index, 1);
itemIds.splice(destination.index, 0, removed);
await reorderYearReviewItems(sessionId, sourceCategory, itemIds);
} else {
// Different category - move item
await moveYearReviewItem(draggableId, sessionId, destCategory, destination.index);
}
});
}
return (
<div className={`space-y-6 ${isPending ? 'opacity-70 pointer-events-none' : ''}`}>
{/* Year Review Sections */}
<DragDropContext onDragEnd={handleDragEnd}>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{YEAR_REVIEW_SECTIONS.map((section) => (
<Droppable key={section.category} droppableId={section.category}>
{(provided, snapshot) => (
<YearReviewSection
category={section.category}
sessionId={sessionId}
isDraggingOver={snapshot.isDraggingOver}
ref={provided.innerRef}
{...provided.droppableProps}
>
{itemsByCategory[section.category].map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(dragProvided, dragSnapshot) => (
<YearReviewCard
item={item}
sessionId={sessionId}
isDragging={dragSnapshot.isDragging}
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
/>
)}
</Draggable>
))}
{provided.placeholder}
</YearReviewSection>
)}
</Droppable>
))}
</div>
</DragDropContext>
</div>
);
}

View File

@@ -0,0 +1,131 @@
'use client';
import { forwardRef, useState, useTransition } from 'react';
import type { YearReviewItem } from '@prisma/client';
import { updateYearReviewItem, deleteYearReviewItem } from '@/actions/year-review';
import { YEAR_REVIEW_BY_CATEGORY } from '@/lib/types';
interface YearReviewCardProps {
item: YearReviewItem;
sessionId: string;
isDragging: boolean;
}
export const YearReviewCard = forwardRef<HTMLDivElement, YearReviewCardProps>(
({ item, sessionId, isDragging, ...props }, ref) => {
const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(item.content);
const [isPending, startTransition] = useTransition();
const config = YEAR_REVIEW_BY_CATEGORY[item.category];
async function handleSave() {
if (content.trim() === item.content) {
setIsEditing(false);
return;
}
if (!content.trim()) {
// If empty, delete
startTransition(async () => {
await deleteYearReviewItem(item.id, sessionId);
});
return;
}
startTransition(async () => {
await updateYearReviewItem(item.id, sessionId, { content: content.trim() });
setIsEditing(false);
});
}
async function handleDelete() {
startTransition(async () => {
await deleteYearReviewItem(item.id, sessionId);
});
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
setContent(item.content);
setIsEditing(false);
}
}
return (
<div
ref={ref}
className={`
group relative rounded-lg border bg-card p-3 shadow-sm transition-all
${isDragging ? 'shadow-lg ring-2 ring-primary' : 'border-border'}
${isPending ? 'opacity-50' : ''}
`}
style={{
borderLeftColor: config.color,
borderLeftWidth: '3px',
}}
{...props}
>
{isEditing ? (
<textarea
autoFocus
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
className="w-full resize-none rounded border-0 bg-transparent p-0 text-sm text-foreground focus:outline-none focus:ring-0"
rows={2}
disabled={isPending}
/>
) : (
<>
<p className="text-sm text-foreground whitespace-pre-wrap">{item.content}</p>
{/* Actions (visible on hover) */}
<div className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
aria-label="Modifier"
>
<svg className="h-3.5 w-3.5" 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>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
aria-label="Supprimer"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</>
)}
</div>
);
}
);
YearReviewCard.displayName = 'YearReviewCard';

View File

@@ -0,0 +1,133 @@
'use client';
import { useState, useCallback } from 'react';
import { useYearReviewLive, type YearReviewLiveEvent } from '@/hooks/useYearReviewLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { YearReviewShareModal } from './YearReviewShareModal';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
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 YearReviewLiveWrapperProps {
sessionId: string;
sessionTitle: string;
currentUserId: string;
shares: Share[];
isOwner: boolean;
canEdit: boolean;
children: React.ReactNode;
}
export function YearReviewLiveWrapper({
sessionId,
sessionTitle,
currentUserId,
shares,
isOwner,
canEdit,
children,
}: YearReviewLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: YearReviewLiveEvent) => {
// 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 } = useYearReviewLive({
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) => (
<Avatar
key={share.id}
email={share.user.email}
name={share.user.name}
size={32}
className="border-2 border-card"
/>
))}
{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 */}
<YearReviewShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
/>
</>
);
}

View File

@@ -0,0 +1,144 @@
'use client';
import { forwardRef, useState, useTransition, useRef, ReactNode } from 'react';
import type { YearReviewCategory } from '@prisma/client';
import { createYearReviewItem } from '@/actions/year-review';
import { YEAR_REVIEW_BY_CATEGORY } from '@/lib/types';
interface YearReviewSectionProps {
category: YearReviewCategory;
sessionId: string;
isDraggingOver: boolean;
children: ReactNode;
}
export const YearReviewSection = forwardRef<HTMLDivElement, YearReviewSectionProps>(
({ category, sessionId, isDraggingOver, children, ...props }, ref) => {
const [isAdding, setIsAdding] = useState(false);
const [newContent, setNewContent] = useState('');
const [isPending, startTransition] = useTransition();
const isSubmittingRef = useRef(false);
const config = YEAR_REVIEW_BY_CATEGORY[category];
async function handleAdd() {
if (isSubmittingRef.current || !newContent.trim()) {
setIsAdding(false);
return;
}
isSubmittingRef.current = true;
startTransition(async () => {
await createYearReviewItem(sessionId, {
content: newContent.trim(),
category,
});
setNewContent('');
setIsAdding(false);
isSubmittingRef.current = false;
});
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAdd();
} else if (e.key === 'Escape') {
setIsAdding(false);
setNewContent('');
}
}
return (
<div
ref={ref}
className={`
rounded-xl border-2 p-4 min-h-[200px] transition-colors
bg-card border-border
${isDraggingOver ? 'ring-2 ring-primary ring-offset-2' : ''}
`}
style={{
borderLeftColor: config.color,
borderLeftWidth: '4px',
}}
{...props}
>
{/* Header */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xl">{config.icon}</span>
<div>
<h3 className="font-semibold text-foreground">{config.title}</h3>
<p className="text-xs text-muted">{config.description}</p>
</div>
</div>
<button
onClick={() => setIsAdding(true)}
className="rounded-lg p-1.5 transition-colors hover:bg-card-hover text-muted hover:text-foreground"
aria-label={`Ajouter un item ${config.title}`}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
</div>
{/* Items */}
<div className="space-y-2">
{children}
{/* Add Form */}
{isAdding && (
<div className="rounded-lg border border-border bg-card p-2 shadow-sm">
<textarea
autoFocus
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={(e) => {
// Don't trigger on blur if clicking on a button
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
handleAdd();
}
}}
placeholder={`Décrivez ${config.title.toLowerCase()}...`}
className="w-full resize-none rounded border-0 bg-transparent p-1 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-0"
rows={2}
disabled={isPending}
/>
<div className="mt-1 flex justify-end gap-1">
<button
onClick={() => {
setIsAdding(false);
setNewContent('');
}}
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
disabled={isPending}
>
Annuler
</button>
<button
onMouseDown={(e) => {
e.preventDefault(); // Prevent blur from textarea
}}
onClick={handleAdd}
disabled={isPending || !newContent.trim()}
className="rounded px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10 disabled:opacity-50"
>
{isPending ? '...' : 'Ajouter'}
</button>
</div>
</div>
)}
</div>
</div>
);
}
);
YearReviewSection.displayName = 'YearReviewSection';

View File

@@ -0,0 +1,173 @@
'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 { Avatar } from '@/components/ui/Avatar';
import { shareYearReviewSession, removeYearReviewShare } from '@/actions/year-review';
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 YearReviewShareModalProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
sessionTitle: string;
shares: Share[];
isOwner: boolean;
}
export function YearReviewShareModal({
isOpen,
onClose,
sessionId,
sessionTitle,
shares,
isOwner,
}: YearReviewShareModalProps) {
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 shareYearReviewSession(sessionId, email, role);
if (result.success) {
setEmail('');
} else {
setError(result.error || 'Erreur lors du partage');
}
});
}
async function handleRemove(userId: string) {
startTransition(async () => {
await removeYearReviewShare(sessionId, userId);
});
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Partager le bilan">
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Bilan annuel</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">
<Avatar email={share.user.email} name={share.user.name} size={32} />
<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 items et leurs catégories
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</p>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,5 @@
export { YearReviewBoard } from './YearReviewBoard';
export { YearReviewCard } from './YearReviewCard';
export { YearReviewSection } from './YearReviewSection';
export { YearReviewLiveWrapper } from './YearReviewLiveWrapper';
export { YearReviewShareModal } from './YearReviewShareModal';

134
src/hooks/useLive.ts Normal file
View File

@@ -0,0 +1,134 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
export type LiveEvent = {
type: string;
payload: Record<string, unknown>;
userId?: string;
user?: { id: string; name: string | null; email: string };
timestamp: string;
};
interface UseLiveOptions {
sessionId: string;
apiPath: string; // e.g., 'sessions', 'motivators', 'year-review'
currentUserId?: string;
enabled?: boolean;
onEvent?: (event: LiveEvent) => void;
}
interface UseLiveReturn {
isConnected: boolean;
lastEvent: LiveEvent | null;
error: string | null;
}
export function useLive({
sessionId,
apiPath,
currentUserId,
enabled = true,
onEvent,
}: UseLiveOptions): UseLiveReturn {
const [isConnected, setIsConnected] = useState(false);
const [lastEvent, setLastEvent] = useState<LiveEvent | 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/${apiPath}/${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 LiveEvent;
// Handle connection event
if (data.type === 'connected') {
return;
}
// Client-side filter: ignore events created by current user
// This prevents duplicates when revalidatePath already refreshed the data
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, apiPath, enabled, router]);
return { isConnected, lastEvent, error };
}

View File

@@ -1,15 +1,8 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { useLive, type LiveEvent } from './useLive';
export type MotivatorLiveEvent = {
type: string;
payload: Record<string, unknown>;
userId?: string;
user?: { id: string; name: string | null; email: string };
timestamp: string;
};
export type MotivatorLiveEvent = LiveEvent;
interface UseMotivatorLiveOptions {
sessionId: string;
@@ -30,101 +23,11 @@ export function useMotivatorLive({
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 };
return useLive({
sessionId,
apiPath: 'motivators',
currentUserId,
enabled,
onEvent,
});
}

View File

@@ -1,19 +1,10 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
export type LiveEvent = {
type: string;
payload: Record<string, unknown>;
userId?: string; // ID of the user who created the event
user?: { id: string; name: string | null; email: string };
timestamp: string;
};
import { useLive, type LiveEvent } from './useLive';
interface UseSessionLiveOptions {
sessionId: string;
currentUserId?: string; // Current user ID for client-side filtering
currentUserId?: string;
enabled?: boolean;
onEvent?: (event: LiveEvent) => void;
}
@@ -30,102 +21,13 @@ export function useSessionLive({
enabled = true,
onEvent,
}: UseSessionLiveOptions): UseSessionLiveReturn {
const [isConnected, setIsConnected] = useState(false);
const [lastEvent, setLastEvent] = useState<LiveEvent | 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/sessions/${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 LiveEvent;
// Handle connection event
if (data.type === 'connected') {
return;
}
// Client-side filter: ignore events created by current user
// This prevents duplicates when revalidatePath already refreshed the data
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 };
return useLive({
sessionId,
apiPath: 'sessions',
currentUserId,
enabled,
onEvent,
});
}
export type { LiveEvent };

View File

@@ -0,0 +1,33 @@
'use client';
import { useLive, type LiveEvent } from './useLive';
interface UseYearReviewLiveOptions {
sessionId: string;
currentUserId?: string;
enabled?: boolean;
onEvent?: (event: YearReviewLiveEvent) => void;
}
interface UseYearReviewLiveReturn {
isConnected: boolean;
lastEvent: YearReviewLiveEvent | null;
error: string | null;
}
export type YearReviewLiveEvent = LiveEvent;
export function useYearReviewLive({
sessionId,
currentUserId,
enabled = true,
onEvent,
}: UseYearReviewLiveOptions): UseYearReviewLiveReturn {
return useLive({
sessionId,
apiPath: 'year-review',
currentUserId,
enabled,
onEvent,
});
}

View File

@@ -298,3 +298,120 @@ export const MOTIVATOR_BY_TYPE: Record<MotivatorType, MotivatorConfig> = MOTIVAT
},
{} as Record<MotivatorType, MotivatorConfig>
);
// ============================================
// Year Review - Type Definitions
// ============================================
export type YearReviewCategory =
| 'ACHIEVEMENTS' // Réalisations / Accomplissements
| 'CHALLENGES' // Défis / Difficultés rencontrées
| 'LEARNINGS' // Apprentissages / Compétences développées
| 'GOALS' // Objectifs pour l'année suivante
| 'MOMENTS'; // Moments forts / Moments difficiles
export interface YearReviewItem {
id: string;
content: string;
category: YearReviewCategory;
order: number;
sessionId: string;
createdAt: Date;
updatedAt: Date;
}
export interface YearReviewSession {
id: string;
title: string;
participant: string;
year: number;
userId: string;
items: YearReviewItem[];
createdAt: Date;
updatedAt: Date;
}
export interface CreateYearReviewSessionInput {
title: string;
participant: string;
year: number;
}
export interface UpdateYearReviewSessionInput {
title?: string;
participant?: string;
year?: number;
}
export interface CreateYearReviewItemInput {
content: string;
category: YearReviewCategory;
order?: number;
}
export interface UpdateYearReviewItemInput {
content?: string;
category?: YearReviewCategory;
order?: number;
}
// ============================================
// Year Review - UI Config
// ============================================
export interface YearReviewSectionConfig {
category: YearReviewCategory;
title: string;
icon: string;
description: string;
color: string;
}
export const YEAR_REVIEW_SECTIONS: YearReviewSectionConfig[] = [
{
category: 'ACHIEVEMENTS',
title: 'Réalisations',
icon: '🏆',
description: 'Ce que vous avez accompli cette année',
color: '#22c55e', // green
},
{
category: 'CHALLENGES',
title: 'Défis',
icon: '⚔️',
description: 'Les difficultés que vous avez rencontrées',
color: '#ef4444', // red
},
{
category: 'LEARNINGS',
title: 'Apprentissages',
icon: '📚',
description: 'Ce que vous avez appris et développé',
color: '#3b82f6', // blue
},
{
category: 'GOALS',
title: 'Objectifs',
icon: '🎯',
description: 'Ce que vous souhaitez accomplir l\'année prochaine',
color: '#8b5cf6', // purple
},
{
category: 'MOMENTS',
title: 'Moments',
icon: '⭐',
description: 'Les moments forts et marquants de l\'année',
color: '#f59e0b', // amber
},
];
export const YEAR_REVIEW_BY_CATEGORY: Record<
YearReviewCategory,
YearReviewSectionConfig
> = YEAR_REVIEW_SECTIONS.reduce(
(acc, config) => {
acc[config.category] = config;
return acc;
},
{} as Record<YearReviewCategory, YearReviewSectionConfig>
);

View File

@@ -305,11 +305,39 @@ export async function updateAction(
priority?: number;
status?: string;
dueDate?: Date | null;
linkedItemIds?: string[];
}
) {
const { linkedItemIds, ...updateData } = data;
// If linkedItemIds is provided, update the links
if (linkedItemIds !== undefined) {
// Delete all existing links
await prisma.actionLink.deleteMany({
where: { actionId },
});
// Create new links
if (linkedItemIds.length > 0) {
await prisma.actionLink.createMany({
data: linkedItemIds.map((swotItemId) => ({
actionId,
swotItemId,
})),
});
}
}
return prisma.action.update({
where: { id: actionId },
data,
data: updateData,
include: {
links: {
include: {
swotItem: true,
},
},
},
});
}

370
src/services/year-review.ts Normal file
View File

@@ -0,0 +1,370 @@
import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth';
import type { ShareRole, YearReviewCategory } from '@prisma/client';
// ============================================
// Year Review Session CRUD
// ============================================
export async function getYearReviewSessionsByUserId(userId: string) {
// Get owned sessions + shared sessions
const [owned, shared] = await Promise.all([
prisma.yearReviewSession.findMany({
where: { userId },
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
items: true,
},
},
},
orderBy: { updatedAt: 'desc' },
}),
prisma.yRSessionShare.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: {
items: 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,
}));
const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
// Resolve participants to users
const sessionsWithResolved = await Promise.all(
allSessions.map(async (s) => ({
...s,
resolvedParticipant: await resolveCollaborator(s.participant),
}))
);
return sessionsWithResolved;
}
export async function getYearReviewSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared
const session = await prisma.yearReviewSession.findFirst({
where: {
id: sessionId,
OR: [
{ userId }, // Owner
{ shares: { some: { userId } } }, // Shared with user
],
},
include: {
user: { select: { id: true, name: true, email: true } },
items: {
orderBy: [{ category: 'asc' }, { order: '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';
// Resolve participant to user if it's an email
const resolvedParticipant = await resolveCollaborator(session.participant);
return { ...session, isOwner, role, canEdit, resolvedParticipant };
}
// Check if user can access session (owner or shared)
export async function canAccessYearReviewSession(sessionId: string, userId: string) {
const count = await prisma.yearReviewSession.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 canEditYearReviewSession(sessionId: string, userId: string) {
const count = await prisma.yearReviewSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }],
},
});
return count > 0;
}
export async function createYearReviewSession(
userId: string,
data: { title: string; participant: string; year: number }
) {
return prisma.yearReviewSession.create({
data: {
...data,
userId,
},
include: {
items: {
orderBy: [{ category: 'asc' }, { order: 'asc' }],
},
},
});
}
export async function updateYearReviewSession(
sessionId: string,
userId: string,
data: { title?: string; participant?: string; year?: number }
) {
return prisma.yearReviewSession.updateMany({
where: { id: sessionId, userId },
data,
});
}
export async function deleteYearReviewSession(sessionId: string, userId: string) {
return prisma.yearReviewSession.deleteMany({
where: { id: sessionId, userId },
});
}
// ============================================
// Year Review Items CRUD
// ============================================
export async function createYearReviewItem(
sessionId: string,
data: { content: string; category: YearReviewCategory }
) {
// Get max order for this category in this session
const maxOrder = await prisma.yearReviewItem.findFirst({
where: { sessionId, category: data.category },
orderBy: { order: 'desc' },
select: { order: true },
});
return prisma.yearReviewItem.create({
data: {
...data,
sessionId,
order: (maxOrder?.order ?? -1) + 1,
},
});
}
export async function updateYearReviewItem(
itemId: string,
data: { content?: string; category?: YearReviewCategory; order?: number }
) {
return prisma.yearReviewItem.update({
where: { id: itemId },
data,
});
}
export async function deleteYearReviewItem(itemId: string) {
return prisma.yearReviewItem.delete({
where: { id: itemId },
});
}
export async function moveYearReviewItem(
itemId: string,
newCategory: YearReviewCategory,
newOrder: number
) {
return prisma.yearReviewItem.update({
where: { id: itemId },
data: {
category: newCategory,
order: newOrder,
},
});
}
export async function reorderYearReviewItems(
sessionId: string,
category: YearReviewCategory,
itemIds: string[]
) {
const updates = itemIds.map((id, index) =>
prisma.yearReviewItem.update({
where: { id },
data: { order: index },
})
);
return prisma.$transaction(updates);
}
// ============================================
// Session Sharing
// ============================================
export async function shareYearReviewSession(
sessionId: string,
ownerId: string,
targetEmail: string,
role: ShareRole = 'EDITOR'
) {
// Verify owner
const session = await prisma.yearReviewSession.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.yRSessionShare.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 removeYearReviewShare(
sessionId: string,
ownerId: string,
shareUserId: string
) {
// Verify owner
const session = await prisma.yearReviewSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
return prisma.yRSessionShare.deleteMany({
where: { sessionId, userId: shareUserId },
});
}
export async function getYearReviewSessionShares(sessionId: string, userId: string) {
// Verify access
if (!(await canAccessYearReviewSession(sessionId, userId))) {
throw new Error('Access denied');
}
return prisma.yRSessionShare.findMany({
where: { sessionId },
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
// ============================================
// Session Events (for real-time sync)
// ============================================
export type YRSessionEventType =
| 'ITEM_CREATED'
| 'ITEM_UPDATED'
| 'ITEM_DELETED'
| 'ITEM_MOVED'
| 'ITEMS_REORDERED'
| 'SESSION_UPDATED';
export async function createYearReviewSessionEvent(
sessionId: string,
userId: string,
type: YRSessionEventType,
payload: Record<string, unknown>
) {
return prisma.yRSessionEvent.create({
data: {
sessionId,
userId,
type,
payload: JSON.stringify(payload),
},
});
}
export async function getYearReviewSessionEvents(sessionId: string, since?: Date) {
return prisma.yRSessionEvent.findMany({
where: {
sessionId,
...(since && { createdAt: { gt: since } }),
},
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'asc' },
});
}
export async function getLatestYearReviewEventTimestamp(sessionId: string) {
const event = await prisma.yRSessionEvent.findFirst({
where: { sessionId },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
});
return event?.createdAt;
}