Compare commits
73 Commits
master
...
9a43980412
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a43980412 | |||
| 09a849279b | |||
| b1ba43fd30 | |||
| 2e00522bfc | |||
| 66ac190c15 | |||
| 7be296231c | |||
| c3b653601c | |||
| 8de4c1985f | |||
| 766f3d5a59 | |||
| 7c68fb81e3 | |||
| 9298eef0cb | |||
| a10205994c | |||
| c828ab1a48 | |||
| 6dfeab5eb8 | |||
| 74b1b2e838 | |||
| 73219c89fb | |||
| 30c2b6cc1e | |||
| 51bc187374 | |||
| 3e869bf8ad | |||
| 3b212d6dda | |||
| 9b8c9efbd6 | |||
| 11c770da9c | |||
| 220dcf87b9 | |||
| 6b8d3c42f7 | |||
|
|
739b0bf87d | ||
|
|
35228441e3 | ||
|
|
ee13f8ba99 | ||
|
|
d50a8a0266 | ||
|
|
520a1f4838 | ||
|
|
d05157d498 | ||
|
|
4d04d3ede8 | ||
|
|
aad4b7f111 | ||
|
|
5e9ae0936f | ||
|
|
4e14112ffa | ||
|
|
7f3eabbdb2 | ||
|
|
cc7e73ce7b | ||
|
|
a8f53bfe2a | ||
|
|
e8282bb118 | ||
|
|
31d9c00b6d | ||
|
|
7805e8dcd0 | ||
|
|
390c4c653e | ||
|
|
39910f559e | ||
|
|
057732f00e | ||
|
|
e8ffccd286 | ||
|
|
ef0772f894 | ||
|
|
163caa398c | ||
|
|
3a2eb83197 | ||
|
|
e848e85b63 | ||
|
|
53ee344ae7 | ||
|
|
67d685d346 | ||
|
|
47703db348 | ||
|
|
86c26b5af8 | ||
|
|
97045342b7 | ||
|
|
ca9b68ebbd | ||
|
|
5f661c8bfd | ||
|
|
e3a47dd7e5 | ||
|
|
35b9ac8a66 | ||
|
|
fd65e0d5b9 | ||
|
|
246298dd82 | ||
|
|
56a9c2c3be | ||
|
|
48ff86fb5f | ||
|
|
d735e1c4c5 | ||
|
|
0cf7437efe | ||
|
|
ccb5338aa6 | ||
|
|
fa2879c903 | ||
|
|
0daade6533 | ||
|
|
acdcc37091 | ||
|
|
7a4de67b9c | ||
|
|
27995e7e7f | ||
|
|
8a3966e6a9 | ||
|
|
e2232ca595 | ||
|
|
434043041c | ||
| 9764402ef2 |
23
.gitea/workflows/deploy.yml
Normal 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
|
||||
4
.gitignore
vendored
@@ -41,3 +41,7 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
# data
|
||||
data/
|
||||
*.db
|
||||
@@ -6,4 +6,3 @@
|
||||
"printWidth": 100,
|
||||
"plugins": []
|
||||
}
|
||||
|
||||
|
||||
96
CLAUDE.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm dev # Start dev server (http://localhost:3000)
|
||||
pnpm build # Production build (standalone output)
|
||||
pnpm lint # Run ESLint
|
||||
|
||||
# Database (use pnpm instead of npx)
|
||||
pnpm prisma migrate dev --name <name> # Create and apply migration
|
||||
pnpm prisma generate # Regenerate Prisma client
|
||||
pnpm prisma studio # Open DB GUI
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
- **Next.js 16** (App Router, standalone output)
|
||||
- **SQLite + Prisma 7** via `@prisma/adapter-better-sqlite3` (not the default SQLite adapter)
|
||||
- **NextAuth.js v5** (beta.30) — JWT sessions, Credentials provider only
|
||||
- **Tailwind CSS v4** — CSS Variables theming in `src/app/globals.css`
|
||||
- **Drag & Drop** — `@dnd-kit` and `@hello-pangea/dnd` (both present)
|
||||
- **pnpm** — package manager (use pnpm, not npm/yarn)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Workshop Types
|
||||
|
||||
There are 5 workshop types: `swot` (sessions), `motivators`, `year-review`, `weekly-checkin`, `weather`.
|
||||
|
||||
**Single source of truth**: `src/lib/workshops.ts` exports `WORKSHOPS`, `WORKSHOP_BY_ID`, and helpers. Every place that lists or routes workshop types must use this file.
|
||||
|
||||
**All types and UI config constants** are in `src/lib/types.ts` (e.g. `MOTIVATORS_CONFIG`, `YEAR_REVIEW_SECTIONS`, `SWOT_QUADRANTS`, `EMOTIONS_CONFIG`).
|
||||
|
||||
### Layer Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── actions/ # Next.js Server Actions ('use server') — call services, revalidate paths
|
||||
├── services/ # Prisma queries + business logic (server-only)
|
||||
│ ├── database.ts # Prisma singleton (global for dev HMR)
|
||||
│ ├── session-permissions.ts # Shared permission factories
|
||||
│ └── session-share-events.ts # Shared share + SSE event handlers
|
||||
├── app/
|
||||
│ ├── api/ # Route handlers (SSE subscribe + auth)
|
||||
│ ├── (auth)/ # Login/register pages
|
||||
│ └── [workshop]/ # One folder per workshop type
|
||||
├── components/
|
||||
│ └── collaboration/ # BaseSessionLiveWrapper + share/live UI
|
||||
├── hooks/
|
||||
│ └── useLive.ts # SSE client hook (EventSource + reconnect)
|
||||
└── lib/
|
||||
├── workshops.ts # Workshop metadata registry
|
||||
├── types.ts # All TypeScript types + UI config
|
||||
└── share-utils.ts # Shared share types
|
||||
```
|
||||
|
||||
### Real-Time Collaboration (SSE)
|
||||
|
||||
Each workshop has `/api/[path]/[id]/subscribe` — a GET route that opens a `ReadableStream` (SSE). The server polls the DB every 1 second for new events and pushes them to connected clients. Server Actions write events to the DB after mutations.
|
||||
|
||||
Client side: `useLive` hook (`src/hooks/useLive.ts`) connects to the subscribe endpoint with `EventSource`, filters out events from the current user (to avoid duplicates), and calls `router.refresh()` on incoming events.
|
||||
|
||||
`BaseSessionLiveWrapper` (`src/components/collaboration/`) is the shared wrapper component that wires `useLive`, `CollaborationToolbar`, and `ShareModal` for all workshop session pages.
|
||||
|
||||
### Shared Permission System
|
||||
|
||||
`createSessionPermissionChecks(model)` in `src/services/session-permissions.ts` returns `canAccess`, `canEdit`, `canDelete` for any Prisma model that follows the session shape (has `userId` + `shares` relation). Team admins have implicit access to their members' sessions.
|
||||
|
||||
`createShareAndEventHandlers(...)` in `src/services/session-share-events.ts` returns `share`, `removeShare`, `getShares`, `createEvent`, `getEvents` — used by all workshop services.
|
||||
|
||||
### Auth
|
||||
|
||||
- `src/lib/auth.ts` — NextAuth config (signIn, signOut, auth exports)
|
||||
- `src/lib/auth.config.ts` — config object (used separately for Edge middleware)
|
||||
- `src/middleware.ts` — protects all routes except `/api/auth`, `_next/static`, `_next/image`, `favicon.ico`
|
||||
- Session user ID is available via `auth()` call server-side; token includes `id` field
|
||||
|
||||
### Database
|
||||
|
||||
Prisma client is a singleton in `src/services/database.ts`. `DATABASE_URL` env var controls the SQLite file path (default: `file:./prisma/dev.db`). Schema is at `prisma/schema.prisma`.
|
||||
|
||||
### Adding a New Workshop
|
||||
|
||||
Pattern followed by all existing workshops:
|
||||
|
||||
1. Add entry to `WORKSHOPS` in `src/lib/workshops.ts`
|
||||
2. Add Prisma models (Session, Item, Share, Event) following the existing pattern
|
||||
3. Create service in `src/services/` using `createSessionPermissionChecks` and `createShareAndEventHandlers`
|
||||
4. Create server actions in `src/actions/`
|
||||
5. Create API route `src/app/api/[path]/[id]/subscribe/route.ts` (copy from existing)
|
||||
6. Create pages under `src/app/[path]/`
|
||||
7. Use `BaseSessionLiveWrapper` for the session live page
|
||||
@@ -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
|
||||
|
||||
84
PERF_OPTIMIZATIONS.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Optimisations de performance
|
||||
|
||||
## Requêtes DB (impact critique)
|
||||
|
||||
### resolveCollaborator — suppression du scan complet de la table User
|
||||
|
||||
**Fichier:** `src/services/auth.ts`
|
||||
|
||||
Avant : `findMany` sur tous les users puis `find()` en JS pour un match case-insensitive par nom.
|
||||
Après : `findFirst` avec `contains` + vérification exacte. O(1) au lieu de O(N users).
|
||||
|
||||
### getAllUsersWithStats — suppression du N+1
|
||||
|
||||
**Fichier:** `src/services/auth.ts`
|
||||
|
||||
Avant : 2 queries `count` par utilisateur (`Promise.all` avec map).
|
||||
Après : 2 `groupBy` en bulk + construction d'une Map. 3 queries au lieu de 2N+1.
|
||||
|
||||
### React.cache sur les fonctions teams
|
||||
|
||||
**Fichier:** `src/services/teams.ts`
|
||||
|
||||
`getTeamMemberIdsForAdminTeams` et `isAdminOfUser` wrappées avec `React.cache()`.
|
||||
Sur la page `/sessions`, ces fonctions étaient appelées ~10 fois par requête (5 workshop types × 2). Maintenant dédupliquées en 1 appel.
|
||||
|
||||
## SSE / Temps réel (impact haut)
|
||||
|
||||
### Polling interval 1s → 2s
|
||||
|
||||
**Fichiers:** 5 routes `src/app/api/*/[id]/subscribe/route.ts`
|
||||
|
||||
Réduit de 50% le nombre de queries DB en temps réel. Imperceptible côté UX (la plupart des outils collab utilisent 2-5s).
|
||||
|
||||
### Nettoyage des events
|
||||
|
||||
**Fichier:** `src/services/session-share-events.ts`
|
||||
|
||||
Ajout de `cleanupOldEvents(maxAgeHours)` pour purger les events périmés. Les tables d'events n'ont pas de mécanisme de TTL — cette méthode peut être appelée périodiquement ou à la connexion SSE.
|
||||
|
||||
## Rendu client (impact haut)
|
||||
|
||||
### React.memo sur les composants de cartes
|
||||
|
||||
**Fichiers:**
|
||||
|
||||
- `src/components/swot/SwotCard.tsx`
|
||||
- `src/components/moving-motivators/MotivatorCard.tsx` (+ `MotivatorCardStatic`)
|
||||
- `src/components/weather/WeatherCard.tsx`
|
||||
- `src/components/weekly-checkin/WeeklyCheckInCard.tsx`
|
||||
- `src/components/year-review/YearReviewCard.tsx`
|
||||
|
||||
Ces composants sont rendus en liste et re-rendaient tous à chaque drag, changement d'état, ou `router.refresh()` SSE.
|
||||
|
||||
### WeatherCard — fix du pattern useEffect + setState
|
||||
|
||||
**Fichier:** `src/components/weather/WeatherCard.tsx`
|
||||
|
||||
Remplacé le `useEffect` qui appelait 5 `setState` (cascading renders, erreur lint React 19) par le pattern idiomatique de state-driven prop sync (comparaison directe dans le render body).
|
||||
|
||||
## Configuration Next.js (impact moyen)
|
||||
|
||||
### next.config.ts
|
||||
|
||||
**Fichier:** `next.config.ts`
|
||||
|
||||
- `poweredByHeader: false` — supprime le header `X-Powered-By` (sécurité)
|
||||
- `optimizePackageImports` — tree-shaking amélioré pour `@dnd-kit/*` et `@hello-pangea/dnd`
|
||||
|
||||
### Fix FOUC dark mode
|
||||
|
||||
**Fichier:** `src/app/layout.tsx`
|
||||
|
||||
Script inline dans `<head>` qui lit `localStorage` et applique la classe `dark`/`light` sur `<html>` avant l'hydratation React. Élimine le flash blanc pour les utilisateurs en dark mode.
|
||||
|
||||
## Nettoyage
|
||||
|
||||
- Suppression de 5 SVGs inutilisés du template Next.js (`file.svg`, `globe.svg`, `next.svg`, `vercel.svg`, `window.svg`)
|
||||
|
||||
## Non traité (pour plus tard)
|
||||
|
||||
- **Migration DnD** : consolider `@hello-pangea/dnd` et `@dnd-kit` en une seule lib (~45KB économisés) — 3 boards à réécrire
|
||||
- **Split WorkshopTabs** (879 lignes) — découper en sous-composants par type
|
||||
- **Suspense boundaries** sur les pages de détail de session
|
||||
- **Appel périodique de `cleanupOldEvents`** — à brancher via cron ou à la connexion SSE
|
||||
@@ -5,18 +5,23 @@ Plateforme d'ateliers managériaux interactifs et collaboratifs.
|
||||
## ✨ Fonctionnalités
|
||||
|
||||
### 📊 Analyse SWOT
|
||||
|
||||
Cartographiez les forces, faiblesses, opportunités et menaces de vos collaborateurs.
|
||||
|
||||
- Matrice interactive avec drag & drop
|
||||
- Actions croisées et plan de développement
|
||||
- Collaboration en temps réel
|
||||
|
||||
### 🎯 Moving Motivators
|
||||
|
||||
Explorez les 10 motivations intrinsèques (Management 3.0).
|
||||
|
||||
- Classement par importance
|
||||
- Évaluation de l'influence positive/négative
|
||||
- Récapitulatif personnalisé
|
||||
|
||||
### 🤝 Collaboration
|
||||
|
||||
- Partage de sessions (Éditeur / Lecteur)
|
||||
- Synchronisation temps réel (SSE)
|
||||
- Historique sauvegardé
|
||||
|
||||
BIN
data/dev.db
BIN
data/prod.db
@@ -1,6 +1,6 @@
|
||||
# SWOT Manager - Development Book
|
||||
# Workshop Manager - Development Book
|
||||
|
||||
Application de gestion d'ateliers SWOT pour entretiens managériaux.
|
||||
Application de gestion d'ateliers pour entretiens managériaux.
|
||||
|
||||
## Stack Technique
|
||||
|
||||
@@ -47,6 +47,7 @@ Application de gestion d'ateliers SWOT pour entretiens managériaux.
|
||||
|
||||
- [x] Installer et configurer Prisma
|
||||
- [x] Créer le schéma de base de données :
|
||||
|
||||
```prisma
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
@@ -114,6 +115,7 @@ Application de gestion d'ateliers SWOT pour entretiens managériaux.
|
||||
@@unique([actionId, swotItemId])
|
||||
}
|
||||
```
|
||||
|
||||
- [x] Générer le client Prisma
|
||||
- [x] Créer les migrations initiales
|
||||
- [x] Créer le service database.ts (pool de connexion)
|
||||
@@ -260,7 +262,7 @@ Application de gestion d'ateliers SWOT pour entretiens managériaux.
|
||||
|
||||
```typescript
|
||||
// actions/swot-items.ts
|
||||
'use server'
|
||||
'use server';
|
||||
|
||||
import { swotService } from '@/services/swot';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
@@ -310,4 +312,3 @@ npm run build
|
||||
# Lint
|
||||
npm run lint
|
||||
```
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
DB_PATH="/app/data/dev.db"
|
||||
BACKUP_DIR="/app/data/backups"
|
||||
|
||||
if [ -f "$DB_PATH" ]; then
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
BACKUP_FILE="$BACKUP_DIR/dev-$(date +%Y%m%d-%H%M%S).db"
|
||||
cp "$DB_PATH" "$BACKUP_FILE"
|
||||
echo "💾 Database backed up to $BACKUP_FILE"
|
||||
|
||||
# Keep only the 10 most recent backups
|
||||
ls -t "$BACKUP_DIR"/*.db 2>/dev/null | tail -n +11 | xargs rm -f
|
||||
fi
|
||||
|
||||
echo "🔄 Running database migrations..."
|
||||
pnpm prisma migrate deploy
|
||||
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { NextConfig } from "next";
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
output: 'standalone',
|
||||
poweredByHeader: false,
|
||||
experimental: {
|
||||
optimizePackageImports: [
|
||||
'@dnd-kit/core',
|
||||
'@dnd-kit/sortable',
|
||||
'@dnd-kit/utilities',
|
||||
'@hello-pangea/dnd',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"prettier": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -24,7 +25,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",
|
||||
|
||||
3580
pnpm-lock.yaml
generated
@@ -1,6 +1,6 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// This file was generated by Prisma and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
import 'dotenv/config';
|
||||
import { defineConfig, env } from 'prisma/config';
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
schema: 'prisma/schema.prisma',
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
path: 'prisma/migrations',
|
||||
},
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
url: env('DATABASE_URL'),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
-- CreateEnum
|
||||
CREATE TABLE "WeeklyCheckInCategory" (
|
||||
"value" TEXT NOT NULL PRIMARY KEY
|
||||
);
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TABLE "Emotion" (
|
||||
"value" TEXT NOT NULL PRIMARY KEY
|
||||
);
|
||||
|
||||
-- InsertEnumValues
|
||||
INSERT INTO "WeeklyCheckInCategory" ("value") VALUES ('WENT_WELL');
|
||||
INSERT INTO "WeeklyCheckInCategory" ("value") VALUES ('WENT_WRONG');
|
||||
INSERT INTO "WeeklyCheckInCategory" ("value") VALUES ('CURRENT_FOCUS');
|
||||
INSERT INTO "WeeklyCheckInCategory" ("value") VALUES ('NEXT_FOCUS');
|
||||
|
||||
-- InsertEnumValues
|
||||
INSERT INTO "Emotion" ("value") VALUES ('PRIDE');
|
||||
INSERT INTO "Emotion" ("value") VALUES ('JOY');
|
||||
INSERT INTO "Emotion" ("value") VALUES ('SATISFACTION');
|
||||
INSERT INTO "Emotion" ("value") VALUES ('GRATITUDE');
|
||||
INSERT INTO "Emotion" ("value") VALUES ('CONFIDENCE');
|
||||
INSERT INTO "Emotion" ("value") VALUES ('FRUSTRATION');
|
||||
INSERT INTO "Emotion" ("value") VALUES ('WORRY');
|
||||
INSERT INTO "Emotion" ("value") VALUES ('DISAPPOINTMENT');
|
||||
INSERT INTO "Emotion" ("value") VALUES ('EXCITEMENT');
|
||||
INSERT INTO "Emotion" ("value") VALUES ('ANTICIPATION');
|
||||
INSERT INTO "Emotion" ("value") VALUES ('DETERMINATION');
|
||||
INSERT INTO "Emotion" ("value") VALUES ('NONE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WeeklyCheckInSession" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"title" TEXT NOT NULL,
|
||||
"participant" TEXT NOT NULL,
|
||||
"date" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "WeeklyCheckInSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WeeklyCheckInItem" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"content" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"emotion" TEXT NOT NULL DEFAULT 'NONE',
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "WeeklyCheckInItem_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeeklyCheckInSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "WeeklyCheckInItem_category_fkey" FOREIGN KEY ("category") REFERENCES "WeeklyCheckInCategory" ("value") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "WeeklyCheckInItem_emotion_fkey" FOREIGN KEY ("emotion") REFERENCES "Emotion" ("value") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WCISessionShare" (
|
||||
"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 "WCISessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeeklyCheckInSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "WCISessionShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WCISessionEvent" (
|
||||
"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 "WCISessionEvent_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeeklyCheckInSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "WCISessionEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WeeklyCheckInSession_userId_idx" ON "WeeklyCheckInSession"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WeeklyCheckInSession_date_idx" ON "WeeklyCheckInSession"("date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WeeklyCheckInItem_sessionId_idx" ON "WeeklyCheckInItem"("sessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WeeklyCheckInItem_sessionId_category_idx" ON "WeeklyCheckInItem"("sessionId", "category");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WCISessionShare_sessionId_idx" ON "WCISessionShare"("sessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WCISessionShare_userId_idx" ON "WCISessionShare"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WCISessionShare_sessionId_userId_key" ON "WCISessionShare"("sessionId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WCISessionEvent_sessionId_createdAt_idx" ON "WCISessionEvent"("sessionId", "createdAt");
|
||||
@@ -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");
|
||||
@@ -0,0 +1,98 @@
|
||||
-- CreateEnum
|
||||
CREATE TABLE "TeamRole" (
|
||||
"value" TEXT NOT NULL PRIMARY KEY
|
||||
);
|
||||
INSERT INTO "TeamRole" ("value") VALUES ('ADMIN'), ('MEMBER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TABLE "OKRStatus" (
|
||||
"value" TEXT NOT NULL PRIMARY KEY
|
||||
);
|
||||
INSERT INTO "OKRStatus" ("value") VALUES ('NOT_STARTED'), ('IN_PROGRESS'), ('COMPLETED'), ('CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TABLE "KeyResultStatus" (
|
||||
"value" TEXT NOT NULL PRIMARY KEY
|
||||
);
|
||||
INSERT INTO "KeyResultStatus" ("value") VALUES ('NOT_STARTED'), ('IN_PROGRESS'), ('COMPLETED'), ('AT_RISK');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Team" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"createdById" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Team_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamMember" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"teamId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'MEMBER',
|
||||
"joinedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OKR" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"teamMemberId" TEXT NOT NULL,
|
||||
"objective" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"period" TEXT NOT NULL,
|
||||
"startDate" DATETIME NOT NULL,
|
||||
"endDate" DATETIME NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'NOT_STARTED',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "OKR_teamMemberId_fkey" FOREIGN KEY ("teamMemberId") REFERENCES "TeamMember" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "KeyResult" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"okrId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"targetValue" REAL NOT NULL,
|
||||
"currentValue" REAL NOT NULL DEFAULT 0,
|
||||
"unit" TEXT NOT NULL DEFAULT '%',
|
||||
"status" TEXT NOT NULL DEFAULT 'NOT_STARTED',
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"notes" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "KeyResult_okrId_fkey" FOREIGN KEY ("okrId") REFERENCES "OKR" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Team_createdById_idx" ON "Team"("createdById");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamMember_teamId_idx" ON "TeamMember"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamMember_userId_idx" ON "TeamMember"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TeamMember_teamId_userId_key" ON "TeamMember"("teamId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OKR_teamMemberId_idx" ON "OKR"("teamMemberId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OKR_teamMemberId_period_idx" ON "OKR"("teamMemberId", "period");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OKR_status_idx" ON "OKR"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KeyResult_okrId_idx" ON "KeyResult"("okrId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KeyResult_okrId_order_idx" ON "KeyResult"("okrId", "order");
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "WeatherSession" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"title" TEXT NOT NULL,
|
||||
"date" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "WeatherSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WeatherEntry" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"performanceEmoji" TEXT,
|
||||
"moralEmoji" TEXT,
|
||||
"fluxEmoji" TEXT,
|
||||
"valueCreationEmoji" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "WeatherEntry_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeatherSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "WeatherEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WeatherSessionShare" (
|
||||
"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 "WeatherSessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeatherSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "WeatherSessionShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WeatherSessionEvent" (
|
||||
"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 "WeatherSessionEvent_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeatherSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "WeatherSessionEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WeatherSession_userId_idx" ON "WeatherSession"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WeatherSession_date_idx" ON "WeatherSession"("date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WeatherEntry_sessionId_userId_key" ON "WeatherEntry"("sessionId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WeatherEntry_sessionId_idx" ON "WeatherEntry"("sessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WeatherEntry_userId_idx" ON "WeatherEntry"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WeatherSessionShare_sessionId_userId_key" ON "WeatherSessionShare"("sessionId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WeatherSessionShare_sessionId_idx" ON "WeatherSessionShare"("sessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WeatherSessionShare_userId_idx" ON "WeatherSessionShare"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WeatherSessionEvent_sessionId_createdAt_idx" ON "WeatherSessionEvent"("sessionId", "createdAt");
|
||||
@@ -0,0 +1,137 @@
|
||||
-- DropTable
|
||||
PRAGMA foreign_keys=off;
|
||||
DROP TABLE "Emotion";
|
||||
PRAGMA foreign_keys=on;
|
||||
|
||||
-- DropTable
|
||||
PRAGMA foreign_keys=off;
|
||||
DROP TABLE "KeyResultStatus";
|
||||
PRAGMA foreign_keys=on;
|
||||
|
||||
-- DropTable
|
||||
PRAGMA foreign_keys=off;
|
||||
DROP TABLE "OKRStatus";
|
||||
PRAGMA foreign_keys=on;
|
||||
|
||||
-- DropTable
|
||||
PRAGMA foreign_keys=off;
|
||||
DROP TABLE "TeamRole";
|
||||
PRAGMA foreign_keys=on;
|
||||
|
||||
-- DropTable
|
||||
PRAGMA foreign_keys=off;
|
||||
DROP TABLE "WeeklyCheckInCategory";
|
||||
PRAGMA foreign_keys=on;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GifMoodSession" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"title" TEXT NOT NULL,
|
||||
"date" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "GifMoodSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GifMoodUserRating" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"rating" INTEGER NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "GifMoodUserRating_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "GifMoodSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "GifMoodUserRating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GifMoodItem" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"gifUrl" TEXT NOT NULL,
|
||||
"note" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "GifMoodItem_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "GifMoodSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "GifMoodItem_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GMSessionShare" (
|
||||
"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 "GMSessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "GifMoodSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "GMSessionShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GMSessionEvent" (
|
||||
"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 "GMSessionEvent_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "GifMoodSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "GMSessionEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_WeeklyCheckInItem" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"content" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"emotion" TEXT NOT NULL DEFAULT 'NONE',
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "WeeklyCheckInItem_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeeklyCheckInSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_WeeklyCheckInItem" ("category", "content", "createdAt", "emotion", "id", "order", "sessionId", "updatedAt") SELECT "category", "content", "createdAt", "emotion", "id", "order", "sessionId", "updatedAt" FROM "WeeklyCheckInItem";
|
||||
DROP TABLE "WeeklyCheckInItem";
|
||||
ALTER TABLE "new_WeeklyCheckInItem" RENAME TO "WeeklyCheckInItem";
|
||||
CREATE INDEX "WeeklyCheckInItem_sessionId_idx" ON "WeeklyCheckInItem"("sessionId");
|
||||
CREATE INDEX "WeeklyCheckInItem_sessionId_category_idx" ON "WeeklyCheckInItem"("sessionId", "category");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GifMoodSession_userId_idx" ON "GifMoodSession"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GifMoodSession_date_idx" ON "GifMoodSession"("date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GifMoodUserRating_sessionId_idx" ON "GifMoodUserRating"("sessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GifMoodUserRating_sessionId_userId_key" ON "GifMoodUserRating"("sessionId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GifMoodItem_sessionId_userId_idx" ON "GifMoodItem"("sessionId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GifMoodItem_sessionId_idx" ON "GifMoodItem"("sessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GMSessionShare_sessionId_idx" ON "GMSessionShare"("sessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GMSessionShare_userId_idx" ON "GMSessionShare"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GMSessionShare_sessionId_userId_key" ON "GMSessionShare"("sessionId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GMSessionEvent_sessionId_createdAt_idx" ON "GMSessionEvent"("sessionId", "createdAt");
|
||||
|
||||
@@ -21,6 +21,28 @@ model User {
|
||||
motivatorSessions MovingMotivatorsSession[]
|
||||
sharedMotivatorSessions MMSessionShare[]
|
||||
motivatorSessionEvents MMSessionEvent[]
|
||||
// Year Review relations
|
||||
yearReviewSessions YearReviewSession[]
|
||||
sharedYearReviewSessions YRSessionShare[]
|
||||
yearReviewSessionEvents YRSessionEvent[]
|
||||
// Weekly Check-in relations
|
||||
weeklyCheckInSessions WeeklyCheckInSession[]
|
||||
sharedWeeklyCheckInSessions WCISessionShare[]
|
||||
weeklyCheckInSessionEvents WCISessionEvent[]
|
||||
// Weather Workshop relations
|
||||
weatherSessions WeatherSession[]
|
||||
sharedWeatherSessions WeatherSessionShare[]
|
||||
weatherSessionEvents WeatherSessionEvent[]
|
||||
weatherEntries WeatherEntry[]
|
||||
// GIF Mood Board relations
|
||||
gifMoodSessions GifMoodSession[]
|
||||
gifMoodItems GifMoodItem[]
|
||||
sharedGifMoodSessions GMSessionShare[]
|
||||
gifMoodSessionEvents GMSessionEvent[]
|
||||
gifMoodRatings GifMoodUserRating[]
|
||||
// Teams & OKRs relations
|
||||
createdTeams Team[]
|
||||
teamMembers TeamMember[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
@@ -200,3 +222,390 @@ 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])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Teams & OKRs
|
||||
// ============================================
|
||||
|
||||
enum TeamRole {
|
||||
ADMIN
|
||||
MEMBER
|
||||
}
|
||||
|
||||
enum OKRStatus {
|
||||
NOT_STARTED
|
||||
IN_PROGRESS
|
||||
COMPLETED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum KeyResultStatus {
|
||||
NOT_STARTED
|
||||
IN_PROGRESS
|
||||
COMPLETED
|
||||
AT_RISK
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
createdById String
|
||||
creator User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||
members TeamMember[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([createdById])
|
||||
}
|
||||
|
||||
model TeamMember {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role TeamRole @default(MEMBER)
|
||||
okrs OKR[]
|
||||
joinedAt DateTime @default(now())
|
||||
|
||||
@@unique([teamId, userId])
|
||||
@@index([teamId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model OKR {
|
||||
id String @id @default(cuid())
|
||||
teamMemberId String
|
||||
teamMember TeamMember @relation(fields: [teamMemberId], references: [id], onDelete: Cascade)
|
||||
objective String
|
||||
description String?
|
||||
period String // Q1 2025, Q2 2025, H1 2025, 2025, etc.
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
status OKRStatus @default(NOT_STARTED)
|
||||
keyResults KeyResult[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([teamMemberId])
|
||||
@@index([teamMemberId, period])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model KeyResult {
|
||||
id String @id @default(cuid())
|
||||
okrId String
|
||||
okr OKR @relation(fields: [okrId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
targetValue Float
|
||||
currentValue Float @default(0)
|
||||
unit String @default("%") // %, nombre, etc.
|
||||
status KeyResultStatus @default(NOT_STARTED)
|
||||
order Int @default(0)
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([okrId])
|
||||
@@index([okrId, order])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Weekly Check-in Workshop
|
||||
// ============================================
|
||||
|
||||
enum WeeklyCheckInCategory {
|
||||
WENT_WELL // Ce qui s'est bien passé
|
||||
WENT_WRONG // Ce qui s'est mal passé
|
||||
CURRENT_FOCUS // Les enjeux du moment (je me concentre sur ...)
|
||||
NEXT_FOCUS // Les prochains enjeux
|
||||
}
|
||||
|
||||
enum Emotion {
|
||||
PRIDE // Fierté
|
||||
JOY // Joie
|
||||
SATISFACTION // Satisfaction
|
||||
GRATITUDE // Gratitude
|
||||
CONFIDENCE // Confiance
|
||||
FRUSTRATION // Frustration
|
||||
WORRY // Inquiétude
|
||||
DISAPPOINTMENT // Déception
|
||||
EXCITEMENT // Excitement
|
||||
ANTICIPATION // Anticipation
|
||||
DETERMINATION // Détermination
|
||||
NONE // Aucune émotion
|
||||
}
|
||||
|
||||
model WeeklyCheckInSession {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
participant String // Nom du participant
|
||||
date DateTime @default(now())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
items WeeklyCheckInItem[]
|
||||
shares WCISessionShare[]
|
||||
events WCISessionEvent[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([date])
|
||||
}
|
||||
|
||||
model WeeklyCheckInItem {
|
||||
id String @id @default(cuid())
|
||||
content String
|
||||
category WeeklyCheckInCategory
|
||||
emotion Emotion @default(NONE)
|
||||
order Int @default(0)
|
||||
sessionId String
|
||||
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([sessionId])
|
||||
@@index([sessionId, category])
|
||||
}
|
||||
|
||||
model WCISessionShare {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
session WeeklyCheckInSession @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 WCISessionEvent {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
session WeeklyCheckInSession @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])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Weather Workshop
|
||||
// ============================================
|
||||
|
||||
model WeatherSession {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
date DateTime @default(now())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
entries WeatherEntry[]
|
||||
shares WeatherSessionShare[]
|
||||
events WeatherSessionEvent[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([date])
|
||||
}
|
||||
|
||||
model WeatherEntry {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
session WeatherSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
performanceEmoji String? // Emoji météo pour Performance
|
||||
moralEmoji String? // Emoji météo pour Moral
|
||||
fluxEmoji String? // Emoji météo pour Flux
|
||||
valueCreationEmoji String? // Emoji météo pour Création de valeur
|
||||
notes String? // Notes globales
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([sessionId, userId]) // Un seul entry par membre par session
|
||||
@@index([sessionId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model WeatherSessionShare {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
session WeatherSession @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 WeatherSessionEvent {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
session WeatherSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
type String // ENTRY_CREATED, ENTRY_UPDATED, ENTRY_DELETED, SESSION_UPDATED, etc.
|
||||
payload String // JSON payload
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([sessionId, createdAt])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GIF Mood Board Workshop
|
||||
// ============================================
|
||||
|
||||
model GifMoodSession {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
date DateTime @default(now())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
items GifMoodItem[]
|
||||
shares GMSessionShare[]
|
||||
events GMSessionEvent[]
|
||||
ratings GifMoodUserRating[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([date])
|
||||
}
|
||||
|
||||
model GifMoodUserRating {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
session GifMoodSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
rating Int // 1-5
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([sessionId, userId])
|
||||
@@index([sessionId])
|
||||
}
|
||||
|
||||
model GifMoodItem {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
session GifMoodSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
gifUrl String
|
||||
note String?
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([sessionId, userId])
|
||||
@@index([sessionId])
|
||||
}
|
||||
|
||||
model GMSessionShare {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
session GifMoodSession @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 GMSessionEvent {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
session GifMoodSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
type String // GIF_ADDED, GIF_UPDATED, GIF_DELETED, SESSION_UPDATED
|
||||
payload String // JSON payload
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([sessionId, createdAt])
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
6
public/icon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#0891b2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" />
|
||||
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" />
|
||||
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
|
||||
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
BIN
public/rocket_blue_gradient_large_logo.jpg
Normal file
|
After Width: | Height: | Size: 75 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
Before Width: | Height: | Size: 385 B |
340
src/actions/gif-mood.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { auth } from '@/lib/auth';
|
||||
import * as gifMoodService from '@/services/gif-mood';
|
||||
import { getUserById } from '@/services/auth';
|
||||
import { broadcastToGifMoodSession } from '@/app/api/gif-mood/[id]/subscribe/route';
|
||||
|
||||
// ============================================
|
||||
// Session Actions
|
||||
// ============================================
|
||||
|
||||
export async function createGifMoodSession(data: { title: string; date?: Date }) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
const gifMoodSession = await gifMoodService.createGifMoodSession(session.user.id, data);
|
||||
revalidatePath('/gif-mood');
|
||||
revalidatePath('/sessions');
|
||||
return { success: true, data: gifMoodSession };
|
||||
} catch (error) {
|
||||
console.error('Error creating gif mood session:', error);
|
||||
return { success: false, error: 'Erreur lors de la création' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGifMoodSession(
|
||||
sessionId: string,
|
||||
data: { title?: string; date?: Date }
|
||||
) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
await gifMoodService.updateGifMoodSession(sessionId, authSession.user.id, data);
|
||||
|
||||
const user = await getUserById(authSession.user.id);
|
||||
if (!user) {
|
||||
return { success: false, error: 'Utilisateur non trouvé' };
|
||||
}
|
||||
|
||||
const event = await gifMoodService.createGifMoodSessionEvent(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
'SESSION_UPDATED',
|
||||
data
|
||||
);
|
||||
|
||||
broadcastToGifMoodSession(sessionId, {
|
||||
type: 'SESSION_UPDATED',
|
||||
payload: data,
|
||||
userId: authSession.user.id,
|
||||
user: { id: user.id, name: user.name, email: user.email },
|
||||
timestamp: event.createdAt,
|
||||
});
|
||||
|
||||
revalidatePath(`/gif-mood/${sessionId}`);
|
||||
revalidatePath('/gif-mood');
|
||||
revalidatePath('/sessions');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error updating gif mood session:', error);
|
||||
return { success: false, error: 'Erreur lors de la mise à jour' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGifMoodSession(sessionId: string) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
await gifMoodService.deleteGifMoodSession(sessionId, authSession.user.id);
|
||||
revalidatePath('/gif-mood');
|
||||
revalidatePath('/sessions');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting gif mood session:', error);
|
||||
return { success: false, error: 'Erreur lors de la suppression' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Item Actions
|
||||
// ============================================
|
||||
|
||||
export async function addGifMoodItem(
|
||||
sessionId: string,
|
||||
data: { gifUrl: string; note?: string }
|
||||
) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
const canEdit = await gifMoodService.canEditGifMoodSession(sessionId, authSession.user.id);
|
||||
if (!canEdit) {
|
||||
return { success: false, error: 'Permission refusée' };
|
||||
}
|
||||
|
||||
try {
|
||||
const item = await gifMoodService.addGifMoodItem(sessionId, authSession.user.id, data);
|
||||
|
||||
const user = await getUserById(authSession.user.id);
|
||||
if (!user) {
|
||||
return { success: false, error: 'Utilisateur non trouvé' };
|
||||
}
|
||||
|
||||
const event = await gifMoodService.createGifMoodSessionEvent(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
'GIF_ADDED',
|
||||
{ itemId: item.id, userId: item.userId, gifUrl: item.gifUrl, note: item.note }
|
||||
);
|
||||
|
||||
broadcastToGifMoodSession(sessionId, {
|
||||
type: 'GIF_ADDED',
|
||||
payload: { itemId: item.id, userId: item.userId, gifUrl: item.gifUrl, note: item.note },
|
||||
userId: authSession.user.id,
|
||||
user: { id: user.id, name: user.name, email: user.email },
|
||||
timestamp: event.createdAt,
|
||||
});
|
||||
|
||||
revalidatePath(`/gif-mood/${sessionId}`);
|
||||
return { success: true, data: item };
|
||||
} catch (error) {
|
||||
console.error('Error adding gif mood item:', error);
|
||||
const message = error instanceof Error ? error.message : "Erreur lors de l'ajout";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGifMoodItem(
|
||||
sessionId: string,
|
||||
itemId: string,
|
||||
data: { note?: string; order?: number }
|
||||
) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
const canEdit = await gifMoodService.canEditGifMoodSession(sessionId, authSession.user.id);
|
||||
if (!canEdit) {
|
||||
return { success: false, error: 'Permission refusée' };
|
||||
}
|
||||
|
||||
try {
|
||||
await gifMoodService.updateGifMoodItem(itemId, authSession.user.id, data);
|
||||
|
||||
const user = await getUserById(authSession.user.id);
|
||||
if (!user) {
|
||||
return { success: false, error: 'Utilisateur non trouvé' };
|
||||
}
|
||||
|
||||
const event = await gifMoodService.createGifMoodSessionEvent(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
'GIF_UPDATED',
|
||||
{ itemId, ...data }
|
||||
);
|
||||
|
||||
broadcastToGifMoodSession(sessionId, {
|
||||
type: 'GIF_UPDATED',
|
||||
payload: { itemId, ...data },
|
||||
userId: authSession.user.id,
|
||||
user: { id: user.id, name: user.name, email: user.email },
|
||||
timestamp: event.createdAt,
|
||||
});
|
||||
|
||||
revalidatePath(`/gif-mood/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error updating gif mood item:', error);
|
||||
return { success: false, error: 'Erreur lors de la mise à jour' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGifMoodItem(sessionId: string, itemId: string) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
const canEdit = await gifMoodService.canEditGifMoodSession(sessionId, authSession.user.id);
|
||||
if (!canEdit) {
|
||||
return { success: false, error: 'Permission refusée' };
|
||||
}
|
||||
|
||||
try {
|
||||
await gifMoodService.deleteGifMoodItem(itemId, authSession.user.id);
|
||||
|
||||
const user = await getUserById(authSession.user.id);
|
||||
if (!user) {
|
||||
return { success: false, error: 'Utilisateur non trouvé' };
|
||||
}
|
||||
|
||||
const event = await gifMoodService.createGifMoodSessionEvent(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
'GIF_DELETED',
|
||||
{ itemId, userId: authSession.user.id }
|
||||
);
|
||||
|
||||
broadcastToGifMoodSession(sessionId, {
|
||||
type: 'GIF_DELETED',
|
||||
payload: { itemId, userId: authSession.user.id },
|
||||
userId: authSession.user.id,
|
||||
user: { id: user.id, name: user.name, email: user.email },
|
||||
timestamp: event.createdAt,
|
||||
});
|
||||
|
||||
revalidatePath(`/gif-mood/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting gif mood item:', error);
|
||||
return { success: false, error: 'Erreur lors de la suppression' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Week Rating Actions
|
||||
// ============================================
|
||||
|
||||
export async function setGifMoodUserRating(sessionId: string, rating: number) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
const canEdit = await gifMoodService.canEditGifMoodSession(sessionId, authSession.user.id);
|
||||
if (!canEdit) {
|
||||
return { success: false, error: 'Permission refusée' };
|
||||
}
|
||||
|
||||
try {
|
||||
await gifMoodService.upsertGifMoodUserRating(sessionId, authSession.user.id, rating);
|
||||
|
||||
const user = await getUserById(authSession.user.id);
|
||||
if (user) {
|
||||
const event = await gifMoodService.createGifMoodSessionEvent(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
'SESSION_UPDATED',
|
||||
{ rating, userId: authSession.user.id }
|
||||
);
|
||||
broadcastToGifMoodSession(sessionId, {
|
||||
type: 'SESSION_UPDATED',
|
||||
payload: { rating, userId: authSession.user.id },
|
||||
userId: authSession.user.id,
|
||||
user: { id: user.id, name: user.name, email: user.email },
|
||||
timestamp: event.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath(`/gif-mood/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error setting gif mood user rating:', error);
|
||||
return { success: false, error: 'Erreur lors de la mise à jour' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Sharing Actions
|
||||
// ============================================
|
||||
|
||||
export async function shareGifMoodSession(
|
||||
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 gifMoodService.shareGifMoodSession(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
targetEmail,
|
||||
role
|
||||
);
|
||||
revalidatePath(`/gif-mood/${sessionId}`);
|
||||
return { success: true, data: share };
|
||||
} catch (error) {
|
||||
console.error('Error sharing gif mood session:', error);
|
||||
const message = error instanceof Error ? error.message : 'Erreur lors du partage';
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function shareGifMoodSessionToTeam(
|
||||
sessionId: string,
|
||||
teamId: string,
|
||||
role: 'VIEWER' | 'EDITOR' = 'EDITOR'
|
||||
) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
const shares = await gifMoodService.shareGifMoodSessionToTeam(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
teamId,
|
||||
role
|
||||
);
|
||||
revalidatePath(`/gif-mood/${sessionId}`);
|
||||
return { success: true, data: shares };
|
||||
} catch (error) {
|
||||
console.error('Error sharing gif mood session to team:', error);
|
||||
const message = error instanceof Error ? error.message : "Erreur lors du partage à l'équipe";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeGifMoodShare(sessionId: string, shareUserId: string) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
await gifMoodService.removeGifMoodShare(sessionId, authSession.user.id, shareUserId);
|
||||
revalidatePath(`/gif-mood/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error removing gif mood share:', error);
|
||||
return { success: false, error: 'Erreur lors de la suppression du partage' };
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,16 @@ export async function createMotivatorSession(data: { title: string; participant:
|
||||
|
||||
try {
|
||||
const motivatorSession = await motivatorsService.createMotivatorSession(session.user.id, data);
|
||||
try {
|
||||
await motivatorsService.shareMotivatorSession(
|
||||
motivatorSession.id,
|
||||
session.user.id,
|
||||
data.participant,
|
||||
'EDITOR'
|
||||
);
|
||||
} catch (shareError) {
|
||||
console.error('Auto-share failed:', shareError);
|
||||
}
|
||||
revalidatePath('/motivators');
|
||||
return { success: true, data: motivatorSession };
|
||||
} catch (error) {
|
||||
|
||||
@@ -17,6 +17,9 @@ export async function createSwotItem(
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
const item = await sessionsService.createSwotItem(sessionId, data);
|
||||
@@ -45,6 +48,9 @@ export async function updateSwotItem(
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
const item = await sessionsService.updateSwotItem(itemId, data);
|
||||
@@ -68,6 +74,9 @@ export async function deleteSwotItem(itemId: string, sessionId: string) {
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
await sessionsService.deleteSwotItem(itemId);
|
||||
@@ -90,6 +99,9 @@ export async function duplicateSwotItem(itemId: string, sessionId: string) {
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
const item = await sessionsService.duplicateSwotItem(itemId);
|
||||
@@ -120,6 +132,9 @@ export async function moveSwotItem(
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
const item = await sessionsService.moveSwotItem(itemId, newCategory, newOrder);
|
||||
@@ -156,6 +171,9 @@ export async function createAction(
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
const action = await sessionsService.createAction(sessionId, data);
|
||||
@@ -183,12 +201,16 @@ export async function updateAction(
|
||||
description?: string;
|
||||
priority?: number;
|
||||
status?: string;
|
||||
linkedItemIds?: string[];
|
||||
}
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
const action = await sessionsService.updateAction(actionId, data);
|
||||
@@ -212,6 +234,9 @@ export async function deleteAction(actionId: string, sessionId: string) {
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
await sessionsService.deleteAction(actionId);
|
||||
|
||||
281
src/actions/weather.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { auth } from '@/lib/auth';
|
||||
import * as weatherService from '@/services/weather';
|
||||
import { getUserById } from '@/services/auth';
|
||||
import { broadcastToWeatherSession } from '@/app/api/weather/[id]/subscribe/route';
|
||||
|
||||
// ============================================
|
||||
// Session Actions
|
||||
// ============================================
|
||||
|
||||
export async function createWeatherSession(data: { title: string; date?: Date }) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
const weatherSession = await weatherService.createWeatherSession(session.user.id, data);
|
||||
revalidatePath('/weather');
|
||||
revalidatePath('/sessions');
|
||||
return { success: true, data: weatherSession };
|
||||
} catch (error) {
|
||||
console.error('Error creating weather session:', error);
|
||||
return { success: false, error: 'Erreur lors de la création' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateWeatherSession(
|
||||
sessionId: string,
|
||||
data: { title?: string; date?: Date }
|
||||
) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
await weatherService.updateWeatherSession(sessionId, authSession.user.id, data);
|
||||
|
||||
// Get user info for broadcast
|
||||
const user = await getUserById(authSession.user.id);
|
||||
if (!user) {
|
||||
return { success: false, error: 'Utilisateur non trouvé' };
|
||||
}
|
||||
|
||||
// Emit event for real-time sync
|
||||
const event = await weatherService.createWeatherSessionEvent(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
'SESSION_UPDATED',
|
||||
data
|
||||
);
|
||||
|
||||
// Broadcast immediately via SSE
|
||||
broadcastToWeatherSession(sessionId, {
|
||||
type: 'SESSION_UPDATED',
|
||||
payload: data,
|
||||
userId: authSession.user.id,
|
||||
user: { id: user.id, name: user.name, email: user.email },
|
||||
timestamp: event.createdAt,
|
||||
});
|
||||
|
||||
revalidatePath(`/weather/${sessionId}`);
|
||||
revalidatePath('/weather');
|
||||
revalidatePath('/sessions');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error updating weather session:', error);
|
||||
return { success: false, error: 'Erreur lors de la mise à jour' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWeatherSession(sessionId: string) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
await weatherService.deleteWeatherSession(sessionId, authSession.user.id);
|
||||
revalidatePath('/weather');
|
||||
revalidatePath('/sessions');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting weather session:', error);
|
||||
return { success: false, error: 'Erreur lors de la suppression' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Entry Actions
|
||||
// ============================================
|
||||
|
||||
export async function createOrUpdateWeatherEntry(
|
||||
sessionId: string,
|
||||
data: {
|
||||
performanceEmoji?: string | null;
|
||||
moralEmoji?: string | null;
|
||||
fluxEmoji?: string | null;
|
||||
valueCreationEmoji?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
// Check edit permission
|
||||
const canEdit = await weatherService.canEditWeatherSession(sessionId, authSession.user.id);
|
||||
if (!canEdit) {
|
||||
return { success: false, error: 'Permission refusée' };
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = await weatherService.createOrUpdateWeatherEntry(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
data
|
||||
);
|
||||
|
||||
// Get user info for broadcast
|
||||
const user = await getUserById(authSession.user.id);
|
||||
if (!user) {
|
||||
return { success: false, error: 'Utilisateur non trouvé' };
|
||||
}
|
||||
|
||||
// Emit event for real-time sync
|
||||
const eventType =
|
||||
entry.createdAt.getTime() === entry.updatedAt.getTime() ? 'ENTRY_CREATED' : 'ENTRY_UPDATED';
|
||||
const event = await weatherService.createWeatherSessionEvent(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
eventType,
|
||||
{
|
||||
entryId: entry.id,
|
||||
userId: entry.userId,
|
||||
...data,
|
||||
}
|
||||
);
|
||||
|
||||
// Broadcast immediately via SSE
|
||||
broadcastToWeatherSession(sessionId, {
|
||||
type: eventType,
|
||||
payload: {
|
||||
entryId: entry.id,
|
||||
userId: entry.userId,
|
||||
...data,
|
||||
},
|
||||
userId: authSession.user.id,
|
||||
user: { id: user.id, name: user.name, email: user.email },
|
||||
timestamp: event.createdAt,
|
||||
});
|
||||
|
||||
revalidatePath(`/weather/${sessionId}`);
|
||||
return { success: true, data: entry };
|
||||
} catch (error) {
|
||||
console.error('Error creating/updating weather entry:', error);
|
||||
return { success: false, error: 'Erreur lors de la sauvegarde' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWeatherEntry(sessionId: string) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
// Check edit permission
|
||||
const canEdit = await weatherService.canEditWeatherSession(sessionId, authSession.user.id);
|
||||
if (!canEdit) {
|
||||
return { success: false, error: 'Permission refusée' };
|
||||
}
|
||||
|
||||
try {
|
||||
await weatherService.deleteWeatherEntry(sessionId, authSession.user.id);
|
||||
|
||||
// Get user info for broadcast
|
||||
const user = await getUserById(authSession.user.id);
|
||||
if (!user) {
|
||||
return { success: false, error: 'Utilisateur non trouvé' };
|
||||
}
|
||||
|
||||
// Emit event for real-time sync
|
||||
const event = await weatherService.createWeatherSessionEvent(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
'ENTRY_DELETED',
|
||||
{ userId: authSession.user.id }
|
||||
);
|
||||
|
||||
// Broadcast immediately via SSE
|
||||
broadcastToWeatherSession(sessionId, {
|
||||
type: 'ENTRY_DELETED',
|
||||
payload: { userId: authSession.user.id },
|
||||
userId: authSession.user.id,
|
||||
user: { id: user.id, name: user.name, email: user.email },
|
||||
timestamp: event.createdAt,
|
||||
});
|
||||
|
||||
revalidatePath(`/weather/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting weather entry:', error);
|
||||
return { success: false, error: 'Erreur lors de la suppression' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Sharing Actions
|
||||
// ============================================
|
||||
|
||||
export async function shareWeatherSession(
|
||||
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 weatherService.shareWeatherSession(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
targetEmail,
|
||||
role
|
||||
);
|
||||
revalidatePath(`/weather/${sessionId}`);
|
||||
return { success: true, data: share };
|
||||
} catch (error) {
|
||||
console.error('Error sharing weather session:', error);
|
||||
const message = error instanceof Error ? error.message : 'Erreur lors du partage';
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function shareWeatherSessionToTeam(
|
||||
sessionId: string,
|
||||
teamId: string,
|
||||
role: 'VIEWER' | 'EDITOR' = 'EDITOR'
|
||||
) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
const shares = await weatherService.shareWeatherSessionToTeam(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
teamId,
|
||||
role
|
||||
);
|
||||
revalidatePath(`/weather/${sessionId}`);
|
||||
return { success: true, data: shares };
|
||||
} catch (error) {
|
||||
console.error('Error sharing weather session to team:', error);
|
||||
const message = error instanceof Error ? error.message : "Erreur lors du partage à l'équipe";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeWeatherShare(sessionId: string, shareUserId: string) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
await weatherService.removeWeatherShare(sessionId, authSession.user.id, shareUserId);
|
||||
revalidatePath(`/weather/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error removing weather share:', error);
|
||||
return { success: false, error: 'Erreur lors de la suppression du partage' };
|
||||
}
|
||||
}
|
||||
343
src/actions/weekly-checkin.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { auth } from '@/lib/auth';
|
||||
import * as weeklyCheckInService from '@/services/weekly-checkin';
|
||||
import type { WeeklyCheckInCategory, Emotion } from '@prisma/client';
|
||||
|
||||
// ============================================
|
||||
// Session Actions
|
||||
// ============================================
|
||||
|
||||
export async function createWeeklyCheckInSession(data: {
|
||||
title: string;
|
||||
participant: string;
|
||||
date?: Date;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
const weeklyCheckInSession = await weeklyCheckInService.createWeeklyCheckInSession(
|
||||
session.user.id,
|
||||
data
|
||||
);
|
||||
try {
|
||||
await weeklyCheckInService.shareWeeklyCheckInSession(
|
||||
weeklyCheckInSession.id,
|
||||
session.user.id,
|
||||
data.participant,
|
||||
'EDITOR'
|
||||
);
|
||||
} catch (shareError) {
|
||||
console.error('Auto-share failed:', shareError);
|
||||
}
|
||||
revalidatePath('/weekly-checkin');
|
||||
revalidatePath('/sessions');
|
||||
return { success: true, data: weeklyCheckInSession };
|
||||
} catch (error) {
|
||||
console.error('Error creating weekly check-in session:', error);
|
||||
return { success: false, error: 'Erreur lors de la création' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateWeeklyCheckInSession(
|
||||
sessionId: string,
|
||||
data: { title?: string; participant?: string; date?: Date }
|
||||
) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
await weeklyCheckInService.updateWeeklyCheckInSession(sessionId, authSession.user.id, data);
|
||||
|
||||
// Emit event for real-time sync
|
||||
await weeklyCheckInService.createWeeklyCheckInSessionEvent(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
'SESSION_UPDATED',
|
||||
data
|
||||
);
|
||||
|
||||
revalidatePath(`/weekly-checkin/${sessionId}`);
|
||||
revalidatePath('/weekly-checkin');
|
||||
revalidatePath('/sessions');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error updating weekly check-in session:', error);
|
||||
return { success: false, error: 'Erreur lors de la mise à jour' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWeeklyCheckInSession(sessionId: string) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
await weeklyCheckInService.deleteWeeklyCheckInSession(sessionId, authSession.user.id);
|
||||
revalidatePath('/weekly-checkin');
|
||||
revalidatePath('/sessions');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting weekly check-in session:', error);
|
||||
return { success: false, error: 'Erreur lors de la suppression' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Item Actions
|
||||
// ============================================
|
||||
|
||||
export async function createWeeklyCheckInItem(
|
||||
sessionId: string,
|
||||
data: { content: string; category: WeeklyCheckInCategory; emotion?: Emotion }
|
||||
) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
// Check edit permission
|
||||
const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession(
|
||||
sessionId,
|
||||
authSession.user.id
|
||||
);
|
||||
if (!canEdit) {
|
||||
return { success: false, error: 'Permission refusée' };
|
||||
}
|
||||
|
||||
try {
|
||||
const item = await weeklyCheckInService.createWeeklyCheckInItem(sessionId, data);
|
||||
|
||||
// Emit event for real-time sync
|
||||
await weeklyCheckInService.createWeeklyCheckInSessionEvent(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
'ITEM_CREATED',
|
||||
{
|
||||
itemId: item.id,
|
||||
content: item.content,
|
||||
category: item.category,
|
||||
emotion: item.emotion,
|
||||
}
|
||||
);
|
||||
|
||||
revalidatePath(`/weekly-checkin/${sessionId}`);
|
||||
return { success: true, data: item };
|
||||
} catch (error) {
|
||||
console.error('Error creating weekly check-in item:', error);
|
||||
return { success: false, error: 'Erreur lors de la création' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateWeeklyCheckInItem(
|
||||
itemId: string,
|
||||
sessionId: string,
|
||||
data: { content?: string; category?: WeeklyCheckInCategory; emotion?: Emotion }
|
||||
) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
// Check edit permission
|
||||
const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession(
|
||||
sessionId,
|
||||
authSession.user.id
|
||||
);
|
||||
if (!canEdit) {
|
||||
return { success: false, error: 'Permission refusée' };
|
||||
}
|
||||
|
||||
try {
|
||||
const item = await weeklyCheckInService.updateWeeklyCheckInItem(itemId, data);
|
||||
|
||||
// Emit event for real-time sync
|
||||
await weeklyCheckInService.createWeeklyCheckInSessionEvent(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
'ITEM_UPDATED',
|
||||
{
|
||||
itemId: item.id,
|
||||
...data,
|
||||
}
|
||||
);
|
||||
|
||||
revalidatePath(`/weekly-checkin/${sessionId}`);
|
||||
return { success: true, data: item };
|
||||
} catch (error) {
|
||||
console.error('Error updating weekly check-in item:', error);
|
||||
return { success: false, error: 'Erreur lors de la mise à jour' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWeeklyCheckInItem(itemId: string, sessionId: string) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
// Check edit permission
|
||||
const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession(
|
||||
sessionId,
|
||||
authSession.user.id
|
||||
);
|
||||
if (!canEdit) {
|
||||
return { success: false, error: 'Permission refusée' };
|
||||
}
|
||||
|
||||
try {
|
||||
await weeklyCheckInService.deleteWeeklyCheckInItem(itemId);
|
||||
|
||||
// Emit event for real-time sync
|
||||
await weeklyCheckInService.createWeeklyCheckInSessionEvent(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
'ITEM_DELETED',
|
||||
{ itemId }
|
||||
);
|
||||
|
||||
revalidatePath(`/weekly-checkin/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting weekly check-in item:', error);
|
||||
return { success: false, error: 'Erreur lors de la suppression' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function moveWeeklyCheckInItem(
|
||||
itemId: string,
|
||||
sessionId: string,
|
||||
newCategory: WeeklyCheckInCategory,
|
||||
newOrder: number
|
||||
) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
// Check edit permission
|
||||
const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession(
|
||||
sessionId,
|
||||
authSession.user.id
|
||||
);
|
||||
if (!canEdit) {
|
||||
return { success: false, error: 'Permission refusée' };
|
||||
}
|
||||
|
||||
try {
|
||||
await weeklyCheckInService.moveWeeklyCheckInItem(itemId, newCategory, newOrder);
|
||||
|
||||
// Emit event for real-time sync
|
||||
await weeklyCheckInService.createWeeklyCheckInSessionEvent(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
'ITEM_MOVED',
|
||||
{
|
||||
itemId,
|
||||
category: newCategory,
|
||||
order: newOrder,
|
||||
}
|
||||
);
|
||||
|
||||
revalidatePath(`/weekly-checkin/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error moving weekly check-in item:', error);
|
||||
return { success: false, error: 'Erreur lors du déplacement' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderWeeklyCheckInItems(
|
||||
sessionId: string,
|
||||
category: WeeklyCheckInCategory,
|
||||
itemIds: string[]
|
||||
) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
// Check edit permission
|
||||
const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession(
|
||||
sessionId,
|
||||
authSession.user.id
|
||||
);
|
||||
if (!canEdit) {
|
||||
return { success: false, error: 'Permission refusée' };
|
||||
}
|
||||
|
||||
try {
|
||||
await weeklyCheckInService.reorderWeeklyCheckInItems(sessionId, category, itemIds);
|
||||
|
||||
// Emit event for real-time sync
|
||||
await weeklyCheckInService.createWeeklyCheckInSessionEvent(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
'ITEMS_REORDERED',
|
||||
{ category, itemIds }
|
||||
);
|
||||
|
||||
revalidatePath(`/weekly-checkin/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error reordering weekly check-in items:', error);
|
||||
return { success: false, error: 'Erreur lors du réordonnancement' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Sharing Actions
|
||||
// ============================================
|
||||
|
||||
export async function shareWeeklyCheckInSession(
|
||||
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 weeklyCheckInService.shareWeeklyCheckInSession(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
targetEmail,
|
||||
role
|
||||
);
|
||||
revalidatePath(`/weekly-checkin/${sessionId}`);
|
||||
return { success: true, data: share };
|
||||
} catch (error) {
|
||||
console.error('Error sharing weekly check-in session:', error);
|
||||
const message = error instanceof Error ? error.message : 'Erreur lors du partage';
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeWeeklyCheckInShare(sessionId: string, shareUserId: string) {
|
||||
const authSession = await auth();
|
||||
if (!authSession?.user?.id) {
|
||||
return { success: false, error: 'Non autorisé' };
|
||||
}
|
||||
|
||||
try {
|
||||
await weeklyCheckInService.removeWeeklyCheckInShare(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
shareUserId
|
||||
);
|
||||
revalidatePath(`/weekly-checkin/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error removing weekly check-in share:', error);
|
||||
return { success: false, error: 'Erreur lors de la suppression du partage' };
|
||||
}
|
||||
}
|
||||
323
src/actions/year-review.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
'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
|
||||
);
|
||||
try {
|
||||
await yearReviewService.shareYearReviewSession(
|
||||
yearReviewSession.id,
|
||||
session.user.id,
|
||||
data.participant,
|
||||
'EDITOR'
|
||||
);
|
||||
} catch (shareError) {
|
||||
console.error('Auto-share failed:', shareError);
|
||||
}
|
||||
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' };
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { RocketIcon } from '@/components/ui';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -44,8 +45,8 @@ export default function LoginPage() {
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="inline-flex items-center gap-2">
|
||||
<span className="text-3xl">📊</span>
|
||||
<span className="text-2xl font-bold text-foreground">SWOT Manager</span>
|
||||
<RocketIcon className="h-8 w-8 shrink-0 text-primary" />
|
||||
<span className="text-2xl font-bold text-foreground">Workshop Manager</span>
|
||||
</Link>
|
||||
<p className="mt-2 text-muted">Connectez-vous à votre compte</p>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { RocketIcon } from '@/components/ui';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
@@ -73,8 +74,8 @@ export default function RegisterPage() {
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="inline-flex items-center gap-2">
|
||||
<span className="text-3xl">📊</span>
|
||||
<span className="text-2xl font-bold text-foreground">SWOT Manager</span>
|
||||
<RocketIcon className="h-8 w-8 shrink-0 text-primary" />
|
||||
<span className="text-2xl font-bold text-foreground">Workshop Manager</span>
|
||||
</Link>
|
||||
<p className="mt-2 text-muted">Créez votre compte</p>
|
||||
</div>
|
||||
|
||||
112
src/app/api/gif-mood/[id]/subscribe/route.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { canAccessGifMoodSession, getGifMoodSessionEvents } from '@/services/gif-mood';
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const hasAccess = await canAccessGifMoodSession(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;
|
||||
|
||||
if (!connections.has(sessionId)) {
|
||||
connections.set(sessionId, new Set());
|
||||
}
|
||||
connections.get(sessionId)!.add(controller);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`)
|
||||
);
|
||||
},
|
||||
cancel() {
|
||||
connections.get(sessionId)?.delete(controller);
|
||||
if (connections.get(sessionId)?.size === 0) {
|
||||
connections.delete(sessionId);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const events = await getGifMoodSessionEvents(sessionId, lastEventTime);
|
||||
if (events.length > 0) {
|
||||
const encoder = new TextEncoder();
|
||||
for (const event of events) {
|
||||
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 {
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
request.signal.addEventListener('abort', () => {
|
||||
clearInterval(pollInterval);
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function broadcastToGifMoodSession(sessionId: string, event: object) {
|
||||
try {
|
||||
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 {
|
||||
sessionConnections.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionConnections.size === 0) {
|
||||
connections.delete(sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SSE Broadcast] Error broadcasting:', error);
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
||||
// Connection might be closed
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
}, 1000); // Poll every second
|
||||
}, 2000); // Poll every 2 seconds
|
||||
|
||||
// Cleanup on abort
|
||||
request.signal.addEventListener('abort', () => {
|
||||
|
||||
61
src/app/api/okrs/[id]/key-results/[krId]/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { updateKeyResult } from '@/services/okrs';
|
||||
import { getOKR } from '@/services/okrs';
|
||||
import { isTeamMember, isTeamAdmin } from '@/services/teams';
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; krId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id, krId } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get OKR to check permissions
|
||||
const okr = await getOKR(id);
|
||||
if (!okr) {
|
||||
return NextResponse.json({ error: 'OKR non trouvé' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user is a member of the team
|
||||
const isMember = await isTeamMember(okr.teamMember.team.id, session.user.id);
|
||||
if (!isMember) {
|
||||
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if user is admin or the concerned member
|
||||
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
|
||||
const isConcernedMember = okr.teamMember.userId === session.user.id;
|
||||
|
||||
if (!isAdmin && !isConcernedMember) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Seuls les administrateurs et le membre concerné peuvent mettre à jour les Key Results',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { currentValue, notes } = body;
|
||||
|
||||
if (currentValue === undefined) {
|
||||
return NextResponse.json({ error: 'Valeur actuelle requise' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await updateKeyResult(krId, Number(currentValue), notes || null);
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Error updating key result:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Erreur lors de la mise à jour du Key Result';
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||
}
|
||||
}
|
||||
149
src/app/api/okrs/[id]/route.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getOKR, updateOKR, deleteOKR } from '@/services/okrs';
|
||||
import { isTeamMember, isTeamAdmin } from '@/services/teams';
|
||||
import type { UpdateOKRInput } from '@/lib/types';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
const okr = await getOKR(id);
|
||||
|
||||
if (!okr) {
|
||||
return NextResponse.json({ error: 'OKR non trouvé' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user is a member of the team
|
||||
const isMember = await isTeamMember(okr.teamMember.team.id, session.user.id);
|
||||
if (!isMember) {
|
||||
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
|
||||
const isConcernedMember = okr.teamMember.userId === session.user.id;
|
||||
|
||||
return NextResponse.json({
|
||||
...okr,
|
||||
permissions: {
|
||||
isAdmin,
|
||||
isConcernedMember,
|
||||
canEdit: isAdmin || isConcernedMember,
|
||||
canDelete: isAdmin,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching OKR:', error);
|
||||
return NextResponse.json({ error: "Erreur lors de la récupération de l'OKR" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
const okr = await getOKR(id);
|
||||
if (!okr) {
|
||||
return NextResponse.json({ error: 'OKR non trouvé' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user is admin of the team or the concerned member
|
||||
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
|
||||
const isConcernedMember = okr.teamMember.userId === session.user.id;
|
||||
if (!isAdmin && !isConcernedMember) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Seuls les administrateurs et le membre concerné peuvent modifier les OKRs' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body: UpdateOKRInput & {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
keyResultsUpdates?: {
|
||||
create?: Array<{ title: string; targetValue: number; unit: string; order: number }>;
|
||||
update?: Array<{
|
||||
id: string;
|
||||
title?: string;
|
||||
targetValue?: number;
|
||||
unit?: string;
|
||||
order?: number;
|
||||
}>;
|
||||
delete?: string[];
|
||||
};
|
||||
} = await request.json();
|
||||
|
||||
// Convert date strings to Date objects if provided
|
||||
const updateData: UpdateOKRInput = { ...body };
|
||||
if (body.startDate) {
|
||||
updateData.startDate = new Date(body.startDate);
|
||||
}
|
||||
if (body.endDate) {
|
||||
updateData.endDate = new Date(body.endDate);
|
||||
}
|
||||
|
||||
// Remove keyResultsUpdates from updateData as it's not part of UpdateOKRInput
|
||||
const { keyResultsUpdates, ...okrUpdateData } = body;
|
||||
const finalUpdateData: UpdateOKRInput = { ...okrUpdateData };
|
||||
if (finalUpdateData.startDate && typeof finalUpdateData.startDate === 'string') {
|
||||
finalUpdateData.startDate = new Date(finalUpdateData.startDate);
|
||||
}
|
||||
if (finalUpdateData.endDate && typeof finalUpdateData.endDate === 'string') {
|
||||
finalUpdateData.endDate = new Date(finalUpdateData.endDate);
|
||||
}
|
||||
|
||||
const updated = await updateOKR(id, finalUpdateData, keyResultsUpdates);
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Error updating OKR:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Erreur lors de la mise à jour de l'OKR";
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
const okr = await getOKR(id);
|
||||
if (!okr) {
|
||||
return NextResponse.json({ error: 'OKR non trouvé' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user is admin of the team
|
||||
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Seuls les administrateurs peuvent supprimer les OKRs' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await deleteOKR(id);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting OKR:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Erreur lors de la suppression de l'OKR";
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||
}
|
||||
}
|
||||
74
src/app/api/okrs/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { createOKR } from '@/services/okrs';
|
||||
import { getTeamMemberById, isTeamAdmin } from '@/services/teams';
|
||||
import type { CreateOKRInput, CreateKeyResultInput } from '@/lib/types';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { teamMemberId, objective, description, period, startDate, endDate, keyResults } =
|
||||
body as CreateOKRInput & {
|
||||
startDate: string | Date;
|
||||
endDate: string | Date;
|
||||
};
|
||||
|
||||
if (!teamMemberId || !objective || !period || !startDate || !endDate || !keyResults) {
|
||||
return NextResponse.json({ error: 'Champs requis manquants' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get team member to check permissions
|
||||
const teamMember = await getTeamMemberById(teamMemberId);
|
||||
if (!teamMember) {
|
||||
return NextResponse.json({ error: "Membre de l'équipe non trouvé" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user is admin of the team
|
||||
const isAdmin = await isTeamAdmin(teamMember.team.id, session.user.id);
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Seuls les administrateurs peuvent créer des OKRs' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Convert dates to Date objects if they are strings
|
||||
const startDateObj = startDate instanceof Date ? startDate : new Date(startDate);
|
||||
const endDateObj = endDate instanceof Date ? endDate : new Date(endDate);
|
||||
|
||||
// Validate dates
|
||||
if (isNaN(startDateObj.getTime()) || isNaN(endDateObj.getTime())) {
|
||||
return NextResponse.json({ error: 'Dates invalides' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Ensure all key results have a unit and order
|
||||
const keyResultsWithUnit = keyResults.map((kr: CreateKeyResultInput, index: number) => ({
|
||||
...kr,
|
||||
unit: kr.unit || '%',
|
||||
order: kr.order !== undefined ? kr.order : index,
|
||||
}));
|
||||
|
||||
const okr = await createOKR(
|
||||
teamMemberId,
|
||||
objective,
|
||||
description || null,
|
||||
period,
|
||||
startDateObj,
|
||||
endDateObj,
|
||||
keyResultsWithUnit
|
||||
);
|
||||
|
||||
return NextResponse.json(okr, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating OKR:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Erreur lors de la création de l'OKR";
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
||||
// Connection might be closed
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
}, 1000); // Poll every second
|
||||
}, 2000); // Poll every 2 seconds
|
||||
|
||||
// Cleanup on abort
|
||||
request.signal.addEventListener('abort', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { prisma } from '@/services/database';
|
||||
import { shareSession } from '@/services/sessions';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -56,6 +57,12 @@ export async function POST(request: Request) {
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await shareSession(newSession.id, session.user.id, collaborator, 'EDITOR');
|
||||
} catch (shareError) {
|
||||
console.error('Auto-share failed:', shareError);
|
||||
}
|
||||
|
||||
return NextResponse.json(newSession, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating session:', error);
|
||||
|
||||
108
src/app/api/teams/[id]/members/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { addTeamMember, removeTeamMember, updateMemberRole, isTeamAdmin } from '@/services/teams';
|
||||
import type { AddTeamMemberInput, UpdateMemberRoleInput } from '@/lib/types';
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const isAdmin = await isTeamAdmin(id, session.user.id);
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Seuls les administrateurs peuvent ajouter des membres' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body: AddTeamMemberInput = await request.json();
|
||||
const { userId, role } = body;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'ID utilisateur requis' }, { status: 400 });
|
||||
}
|
||||
|
||||
const member = await addTeamMember(id, userId, role || 'MEMBER');
|
||||
|
||||
return NextResponse.json(member, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error adding team member:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Erreur lors de l'ajout du membre";
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const isAdmin = await isTeamAdmin(id, session.user.id);
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Seuls les administrateurs peuvent modifier les rôles' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body: UpdateMemberRoleInput & { userId: string } = await request.json();
|
||||
const { userId, role } = body;
|
||||
|
||||
if (!userId || !role) {
|
||||
return NextResponse.json({ error: 'ID utilisateur et rôle requis' }, { status: 400 });
|
||||
}
|
||||
|
||||
const member = await updateMemberRole(id, userId, role);
|
||||
|
||||
return NextResponse.json(member);
|
||||
} catch (error) {
|
||||
console.error('Error updating member role:', error);
|
||||
return NextResponse.json({ error: 'Erreur lors de la mise à jour du rôle' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const isAdmin = await isTeamAdmin(id, session.user.id);
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Seuls les administrateurs peuvent retirer des membres' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const userId = searchParams.get('userId');
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'ID utilisateur requis' }, { status: 400 });
|
||||
}
|
||||
|
||||
await removeTeamMember(id, userId);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error removing team member:', error);
|
||||
return NextResponse.json({ error: 'Erreur lors de la suppression du membre' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
96
src/app/api/teams/[id]/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getTeam, updateTeam, deleteTeam, isTeamAdmin, isTeamMember } from '@/services/teams';
|
||||
import type { UpdateTeamInput } from '@/lib/types';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
const team = await getTeam(id);
|
||||
|
||||
if (!team) {
|
||||
return NextResponse.json({ error: 'Équipe non trouvée' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user is a member
|
||||
const isMember = await isTeamMember(id, session.user.id);
|
||||
if (!isMember) {
|
||||
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(team);
|
||||
} catch (error) {
|
||||
console.error('Error fetching team:', error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur lors de la récupération de l'équipe" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const isAdmin = await isTeamAdmin(id, session.user.id);
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: "Seuls les administrateurs peuvent modifier l'équipe" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body: UpdateTeamInput = await request.json();
|
||||
const team = await updateTeam(id, body);
|
||||
|
||||
return NextResponse.json(team);
|
||||
} catch (error) {
|
||||
console.error('Error updating team:', error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur lors de la mise à jour de l'équipe" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const isAdmin = await isTeamAdmin(id, session.user.id);
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: "Seuls les administrateurs peuvent supprimer l'équipe" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await deleteTeam(id);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting team:', error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur lors de la suppression de l'équipe" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/app/api/teams/members/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getUserTeams } from '@/services/teams';
|
||||
import { getTeamMembersForShare } from '@/lib/share-utils';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
const teams = await getUserTeams(session.user.id);
|
||||
const otherMembers = getTeamMembersForShare(teams, session.user.id);
|
||||
const currentUser = {
|
||||
id: session.user.id,
|
||||
email: session.user.email ?? '',
|
||||
name: session.user.name ?? null,
|
||||
};
|
||||
const members = [currentUser, ...otherMembers];
|
||||
|
||||
return NextResponse.json({ members });
|
||||
} catch (error) {
|
||||
console.error('Error fetching team members:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erreur lors de la récupération des membres' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
48
src/app/api/teams/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getUserTeams, createTeam } from '@/services/teams';
|
||||
import type { CreateTeamInput } from '@/lib/types';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
const teams = await getUserTeams(session.user.id);
|
||||
|
||||
return NextResponse.json(teams);
|
||||
} catch (error) {
|
||||
console.error('Error fetching teams:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erreur lors de la récupération des équipes' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body: CreateTeamInput = await request.json();
|
||||
const { name, description } = body;
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "Le nom de l'équipe est requis" }, { status: 400 });
|
||||
}
|
||||
|
||||
const team = await createTeam(name, description || null, session.user.id);
|
||||
|
||||
return NextResponse.json(team, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating team:', error);
|
||||
return NextResponse.json({ error: "Erreur lors de la création de l'équipe" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
32
src/app/api/users/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { prisma } from '@/services/database';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(users);
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erreur lors de la récupération des utilisateurs' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
136
src/app/api/weather/[id]/subscribe/route.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { canAccessWeatherSession, getWeatherSessionEvents } from '@/services/weather';
|
||||
|
||||
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 canAccessWeatherSession(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 getWeatherSessionEvents(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);
|
||||
}
|
||||
}, 2000); // Poll every 2 seconds
|
||||
|
||||
// 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 broadcastToWeatherSession(sessionId: string, event: object) {
|
||||
try {
|
||||
const sessionConnections = connections.get(sessionId);
|
||||
if (!sessionConnections || sessionConnections.size === 0) {
|
||||
// No active connections, event will be picked up by polling
|
||||
console.log(
|
||||
`[SSE Broadcast] No connections for session ${sessionId}, will be picked up by polling`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[SSE Broadcast] Broadcasting to ${sessionConnections.size} connections for session ${sessionId}`
|
||||
);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
|
||||
|
||||
let sentCount = 0;
|
||||
for (const controller of sessionConnections) {
|
||||
try {
|
||||
controller.enqueue(message);
|
||||
sentCount++;
|
||||
} catch (error) {
|
||||
// Connection might be closed, remove it
|
||||
console.log(`[SSE Broadcast] Failed to send, removing connection:`, error);
|
||||
sessionConnections.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[SSE Broadcast] Sent to ${sentCount} connections`);
|
||||
|
||||
// Clean up empty sets
|
||||
if (sessionConnections.size === 0) {
|
||||
connections.delete(sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SSE Broadcast] Error broadcasting:', error);
|
||||
}
|
||||
}
|
||||
122
src/app/api/weekly-checkin/[id]/subscribe/route.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import {
|
||||
canAccessWeeklyCheckInSession,
|
||||
getWeeklyCheckInSessionEvents,
|
||||
} from '@/services/weekly-checkin';
|
||||
|
||||
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 canAccessWeeklyCheckInSession(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 getWeeklyCheckInSessionEvents(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);
|
||||
}
|
||||
}, 2000); // Poll every 2 seconds
|
||||
|
||||
// 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 broadcastToWeeklyCheckInSession(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);
|
||||
}
|
||||
}
|
||||
119
src/app/api/year-review/[id]/subscribe/route.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
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);
|
||||
}
|
||||
}, 2000); // Poll every 2 seconds
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
67
src/app/gif-mood/[id]/page.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getGifMoodSessionById } from '@/services/gif-mood';
|
||||
import { getUserTeams } from '@/services/teams';
|
||||
import { GifMoodBoard, GifMoodLiveWrapper } from '@/components/gif-mood';
|
||||
import { Badge, SessionPageHeader } from '@/components/ui';
|
||||
|
||||
interface GifMoodSessionPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function GifMoodSessionPage({ params }: GifMoodSessionPageProps) {
|
||||
const { id } = await params;
|
||||
const authSession = await auth();
|
||||
|
||||
if (!authSession?.user?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await getGifMoodSessionById(id, authSession.user.id);
|
||||
|
||||
if (!session) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const userTeams = await getUserTeams(authSession.user.id);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4">
|
||||
<SessionPageHeader
|
||||
workshopType="gif-mood"
|
||||
sessionId={session.id}
|
||||
sessionTitle={session.title}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
ownerUser={session.user}
|
||||
date={session.date}
|
||||
badges={<Badge variant="primary">{session.items.length} GIFs</Badge>}
|
||||
/>
|
||||
|
||||
{/* Live Wrapper + Board */}
|
||||
<GifMoodLiveWrapper
|
||||
sessionId={session.id}
|
||||
sessionTitle={session.title}
|
||||
currentUserId={authSession.user.id}
|
||||
shares={session.shares}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
userTeams={userTeams}
|
||||
>
|
||||
<GifMoodBoard
|
||||
sessionId={session.id}
|
||||
currentUserId={authSession.user.id}
|
||||
items={session.items}
|
||||
shares={session.shares}
|
||||
owner={{
|
||||
id: session.user.id,
|
||||
name: session.user.name ?? null,
|
||||
email: session.user.email ?? '',
|
||||
}}
|
||||
ratings={session.ratings}
|
||||
canEdit={session.canEdit}
|
||||
/>
|
||||
</GifMoodLiveWrapper>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
124
src/app/gif-mood/new/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Button,
|
||||
Input,
|
||||
} from '@/components/ui';
|
||||
import { createGifMoodSession } from '@/actions/gif-mood';
|
||||
import { GIF_MOOD_MAX_ITEMS } from '@/lib/types';
|
||||
|
||||
export default function NewGifMoodPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [title, setTitle] = useState(
|
||||
() =>
|
||||
`GIF Mood - ${new Date().toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}`
|
||||
);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const date = selectedDate ? new Date(selectedDate) : undefined;
|
||||
|
||||
if (!title) {
|
||||
setError('Veuillez remplir le titre');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await createGifMoodSession({ title, date });
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Une erreur est survenue');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/gif-mood/${result.data?.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span>🎞️</span>
|
||||
Nouveau GIF Mood Board
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Créez un tableau de bord GIF pour exprimer et partager votre humeur avec votre équipe
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Titre de la session"
|
||||
name="title"
|
||||
placeholder="Ex: GIF Mood - Mars 2026"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label htmlFor="date" className="block text-sm font-medium text-foreground mb-1">
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
id="date"
|
||||
name="date"
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
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>Partagez la session avec votre équipe</li>
|
||||
<li>Chaque membre peut ajouter jusqu'à {GIF_MOOD_MAX_ITEMS} GIFs</li>
|
||||
<li>Ajoutez une note à chaque GIF pour expliquer votre humeur</li>
|
||||
<li>Les GIFs apparaissent en temps réel pour tous les membres</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 GIF Mood Board
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* ============================================
|
||||
SWOT Manager - CSS Variables Theme System
|
||||
Workshop Manager - CSS Variables Theme System
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
/* Cards & Surfaces */
|
||||
--card: #ffffff;
|
||||
--card-hover: #f1f5f9;
|
||||
--card-hover: #e2e8f0;
|
||||
--card-border: #e2e8f0;
|
||||
|
||||
/* Primary - Cyan/Teal */
|
||||
@@ -39,6 +39,7 @@
|
||||
/* Accent Colors */
|
||||
--accent: #8b5cf6;
|
||||
--accent-hover: #7c3aed;
|
||||
--purple: #8b5cf6;
|
||||
|
||||
/* Status */
|
||||
--success: #059669;
|
||||
@@ -75,7 +76,7 @@
|
||||
|
||||
/* Cards & Surfaces */
|
||||
--card: #1e293b;
|
||||
--card-hover: #283548;
|
||||
--card-hover: #334155;
|
||||
--card-border: #2d3d53;
|
||||
|
||||
/* Primary - Cyan/Teal (softened) */
|
||||
@@ -103,6 +104,7 @@
|
||||
/* Accent Colors */
|
||||
--accent: #a78bfa;
|
||||
--accent-hover: #c4b5fd;
|
||||
--purple: #a78bfa;
|
||||
|
||||
/* Status (softened) */
|
||||
--success: #4ade80;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import { Geist, Geist_Mono, Caveat } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { Providers } from '@/components/Providers';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
@@ -13,9 +14,18 @@ const geistMono = Geist_Mono({
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const caveat = Caveat({
|
||||
variable: '--font-caveat',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Workshop Manager',
|
||||
description: "Application de gestion d'ateliers SWOT pour entretiens managériaux",
|
||||
description: "Application de gestion d'ateliers pour entretiens managériaux",
|
||||
icons: {
|
||||
icon: '/icon.svg',
|
||||
apple: '/rocket_blue_gradient_large_logo.jpg',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -25,8 +35,22 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="fr" suppressHydrationWarning>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<Providers>{children}</Providers>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme:dark)').matches)){document.documentElement.classList.add('dark')}else{document.documentElement.classList.add('light')}}catch(e){}})()`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} ${caveat.variable} antialiased`}>
|
||||
<Providers>
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="py-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
25
src/app/manifest.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: 'Workshop Manager',
|
||||
short_name: 'Workshop',
|
||||
description: "Application de gestion d'ateliers pour entretiens managériaux",
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
icons: [
|
||||
{
|
||||
src: '/rocket_blue_gradient_large_logo.jpg',
|
||||
sizes: '192x192',
|
||||
type: 'image/jpeg',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: '/rocket_blue_gradient_large_logo.jpg',
|
||||
sizes: '512x512',
|
||||
type: 'image/jpeg',
|
||||
purpose: 'any',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition, useRef, useEffect } from 'react';
|
||||
import { updateMotivatorSession } from '@/actions/moving-motivators';
|
||||
|
||||
interface EditableMotivatorTitleProps {
|
||||
sessionId: string;
|
||||
initialTitle: string;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
export function EditableMotivatorTitle({
|
||||
sessionId,
|
||||
initialTitle,
|
||||
isOwner,
|
||||
}: EditableMotivatorTitleProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [title, setTitle] = useState(initialTitle);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
// Update local state when prop changes (e.g., from SSE)
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setTitle(initialTitle);
|
||||
}
|
||||
}, [initialTitle, isEditing]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!title.trim()) {
|
||||
setTitle(initialTitle);
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (title.trim() === initialTitle) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updateMotivatorSession(sessionId, { title: title.trim() });
|
||||
if (!result.success) {
|
||||
setTitle(initialTitle);
|
||||
console.error(result.error);
|
||||
}
|
||||
setIsEditing(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
setTitle(initialTitle);
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOwner) {
|
||||
return <h1 className="text-3xl font-bold text-foreground">{title}</h1>;
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isPending}
|
||||
className="w-full max-w-md rounded-lg border border-border bg-input px-3 py-1.5 text-3xl font-bold text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 disabled:opacity-50"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="group flex items-center gap-2 text-left"
|
||||
title="Cliquez pour modifier"
|
||||
>
|
||||
<h1 className="text-3xl font-bold text-foreground">{title}</h1>
|
||||
<svg
|
||||
className="h-5 w-5 text-muted opacity-0 transition-opacity group-hover:opacity-100"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getMotivatorSessionById } from '@/services/moving-motivators';
|
||||
import { getUserTeams } from '@/services/teams';
|
||||
import type { ResolvedCollaborator } from '@/services/auth';
|
||||
import { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators';
|
||||
import { Badge, CollaboratorDisplay } from '@/components/ui';
|
||||
import { EditableMotivatorTitle } from './EditableTitle';
|
||||
import { Badge, SessionPageHeader } from '@/components/ui';
|
||||
|
||||
interface MotivatorSessionPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -18,54 +18,32 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await getMotivatorSessionById(id, authSession.user.id);
|
||||
const [session, userTeams] = await Promise.all([
|
||||
getMotivatorSessionById(id, authSession.user.id),
|
||||
getUserTeams(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=motivators" className="hover:text-foreground">
|
||||
Moving Motivators
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{session.title}</span>
|
||||
{!session.isOwner && (
|
||||
<Badge variant="accent" className="ml-2">
|
||||
Partagé par {session.user.name || session.user.email}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<EditableMotivatorTitle
|
||||
sessionId={session.id}
|
||||
initialTitle={session.title}
|
||||
isOwner={session.isOwner}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<CollaboratorDisplay collaborator={session.resolvedParticipant} size="lg" showEmail />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="primary">
|
||||
{session.cards.filter((c) => c.influence !== 0).length} / 10 évalués
|
||||
</Badge>
|
||||
<span className="text-sm text-muted">
|
||||
{new Date(session.date).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<main className="mx-auto max-w-7xl px-4">
|
||||
<SessionPageHeader
|
||||
workshopType="motivators"
|
||||
sessionId={session.id}
|
||||
sessionTitle={session.title}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
ownerUser={session.user}
|
||||
date={session.date}
|
||||
collaborator={session.resolvedParticipant as ResolvedCollaborator}
|
||||
badges={
|
||||
<Badge variant="primary">
|
||||
{session.cards.filter((c) => c.influence !== 0).length} / 10 évalués
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Live Wrapper + Board */}
|
||||
<MotivatorLiveWrapper
|
||||
@@ -75,6 +53,7 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
|
||||
shares={session.shares}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
userTeams={userTeams}
|
||||
>
|
||||
<MotivatorBoard sessionId={session.id} cards={session.cards} canEdit={session.canEdit} />
|
||||
</MotivatorLiveWrapper>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CardContent,
|
||||
Button,
|
||||
Input,
|
||||
ParticipantInput,
|
||||
} from '@/components/ui';
|
||||
import { createMotivatorSession } from '@/actions/moving-motivators';
|
||||
|
||||
@@ -45,7 +46,7 @@ export default function NewMotivatorSessionPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-8">
|
||||
<main className="mx-auto max-w-2xl px-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -72,12 +73,7 @@ export default function NewMotivatorSessionPage() {
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Nom du participant"
|
||||
name="participant"
|
||||
placeholder="Ex: Jean Dupont"
|
||||
required
|
||||
/>
|
||||
<ParticipantInput name="participant" required />
|
||||
|
||||
<div className="rounded-lg border border-border bg-card-hover p-4">
|
||||
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
|
||||
|
||||
60
src/app/objectives/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { getUserOKRs } from '@/services/okrs';
|
||||
import { Card, PageHeader } from '@/components/ui';
|
||||
import { ObjectivesList } from '@/components/okrs/ObjectivesList';
|
||||
import { comparePeriods } from '@/lib/okr-utils';
|
||||
|
||||
export default async function ObjectivesPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const okrs = await getUserOKRs(session.user.id);
|
||||
|
||||
// Group OKRs by period
|
||||
const okrsByPeriod = okrs.reduce(
|
||||
(acc, okr) => {
|
||||
const period = okr.period;
|
||||
if (!acc[period]) {
|
||||
acc[period] = [];
|
||||
}
|
||||
acc[period].push(okr);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof okrs>
|
||||
);
|
||||
|
||||
const periods = Object.keys(okrsByPeriod).sort(comparePeriods);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4">
|
||||
<PageHeader
|
||||
emoji="🎯"
|
||||
title="Mes Objectifs"
|
||||
subtitle="Suivez la progression de vos OKRs à travers toutes vos équipes"
|
||||
/>
|
||||
|
||||
{okrs.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<div className="text-5xl mb-4">🎯</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">Aucun OKR défini</h3>
|
||||
<p className="text-muted mb-6">
|
||||
Vous n'avez pas encore d'OKR défini. Contactez un administrateur d'équipe
|
||||
pour en créer.
|
||||
</p>
|
||||
<Link href="/teams">
|
||||
<span className="inline-block rounded-lg bg-[var(--purple)] px-4 py-2 text-white hover:opacity-90">
|
||||
Voir mes équipes
|
||||
</span>
|
||||
</Link>
|
||||
</Card>
|
||||
) : (
|
||||
<ObjectivesList okrsByPeriod={okrsByPeriod} periods={periods} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
659
src/app/page.tsx
@@ -1,9 +1,10 @@
|
||||
import Link from 'next/link';
|
||||
import { WORKSHOPS, getSessionsTabUrl } from '@/lib/workshops';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto max-w-7xl px-4 py-12">
|
||||
<main className="mx-auto max-w-7xl px-4">
|
||||
{/* Hero Section */}
|
||||
<section className="mb-16 text-center">
|
||||
<h1 className="mb-4 text-5xl font-bold text-foreground">
|
||||
@@ -20,38 +21,20 @@ 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">
|
||||
{/* SWOT Workshop Card */}
|
||||
<WorkshopCard
|
||||
href="/sessions?tab=swot"
|
||||
icon="📊"
|
||||
title="Analyse SWOT"
|
||||
tagline="Analysez. Planifiez. Progressez."
|
||||
description="Cartographiez les forces et faiblesses de vos collaborateurs. Identifiez opportunités et menaces pour définir des actions concrètes."
|
||||
features={[
|
||||
'Matrice interactive Forces/Faiblesses/Opportunités/Menaces',
|
||||
'Actions croisées et plan de développement',
|
||||
'Collaboration en temps réel',
|
||||
]}
|
||||
accentColor="#06b6d4"
|
||||
newHref="/sessions/new"
|
||||
/>
|
||||
|
||||
{/* Moving Motivators Workshop Card */}
|
||||
<WorkshopCard
|
||||
href="/sessions?tab=motivators"
|
||||
icon="🎯"
|
||||
title="Moving Motivators"
|
||||
tagline="Révélez ce qui motive vraiment"
|
||||
description="Explorez les 10 motivations intrinsèques de vos collaborateurs. Comprenez leur impact et alignez aspirations et missions."
|
||||
features={[
|
||||
'10 cartes de motivation à classer',
|
||||
"Évaluation de l'influence positive/négative",
|
||||
'Récapitulatif personnalisé des motivations',
|
||||
]}
|
||||
accentColor="#8b5cf6"
|
||||
newHref="/motivators/new"
|
||||
/>
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3 max-w-6xl mx-auto">
|
||||
{WORKSHOPS.map((w) => (
|
||||
<WorkshopCard
|
||||
key={w.id}
|
||||
href={getSessionsTabUrl(w.id)}
|
||||
icon={w.icon}
|
||||
title={w.cardLabel}
|
||||
tagline={w.home.tagline}
|
||||
description={w.home.description}
|
||||
features={w.home.features}
|
||||
accentColor={w.accentColor}
|
||||
newHref={w.newPath}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -250,6 +233,560 @@ 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'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'année écoulée. Il aide à identifier les patterns, célébrer les réussites,
|
||||
apprendre des défis et préparer l'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'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>
|
||||
|
||||
{/* Weekly Check-in 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">Weekly Check-in</h2>
|
||||
<p className="text-green-500 font-medium">
|
||||
Le point hebdomadaire avec vos collaborateurs
|
||||
</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 check-in hebdomadaire ?
|
||||
</h3>
|
||||
<p className="text-muted mb-4">
|
||||
Le Weekly Check-in est un rituel de management qui permet de maintenir un lien
|
||||
régulier avec vos collaborateurs. Il favorise la communication, l'alignement et
|
||||
la détection précoce des problèmes ou opportunités.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-muted">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-500">•</span>
|
||||
Maintenir un suivi régulier et structuré avec chaque collaborateur
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-500">•</span>
|
||||
Identifier rapidement les points positifs et les difficultés rencontrées
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-500">•</span>
|
||||
Comprendre les priorités et enjeux du moment pour mieux accompagner
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-500">•</span>
|
||||
Créer un espace d'échange ouvert où les émotions peuvent être exprimées
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* The 4 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 4 catégories du check-in
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<CategoryPill
|
||||
icon="✅"
|
||||
name="Ce qui s'est bien passé"
|
||||
color="#22c55e"
|
||||
description="Les réussites et points positifs"
|
||||
/>
|
||||
<CategoryPill
|
||||
icon="⚠️"
|
||||
name="Ce qui s'est mal passé"
|
||||
color="#ef4444"
|
||||
description="Les difficultés et points d'amélioration"
|
||||
/>
|
||||
<CategoryPill
|
||||
icon="🎯"
|
||||
name="Enjeux du moment"
|
||||
color="#3b82f6"
|
||||
description="Sur quoi je me concentre actuellement"
|
||||
/>
|
||||
<CategoryPill
|
||||
icon="🚀"
|
||||
name="Prochains enjeux"
|
||||
color="#8b5cf6"
|
||||
description="Ce sur quoi je vais me concentrer prochainement"
|
||||
/>
|
||||
</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="Créer le check-in"
|
||||
description="Créez un nouveau check-in pour la semaine avec votre collaborateur"
|
||||
/>
|
||||
<StepCard
|
||||
number={2}
|
||||
title="Remplir les catégories"
|
||||
description="Pour chaque catégorie, ajoutez les éléments pertinents de la semaine"
|
||||
/>
|
||||
<StepCard
|
||||
number={3}
|
||||
title="Ajouter des émotions"
|
||||
description="Associez une émotion à chaque item pour mieux exprimer votre ressenti"
|
||||
/>
|
||||
<StepCard
|
||||
number={4}
|
||||
title="Partager et discuter"
|
||||
description="Partagez le check-in avec votre collaborateur pour un échange constructif"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Weather 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">Météo</h2>
|
||||
<p className="text-blue-500 font-medium">Votre état en un coup d'œil</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 créer une météo personnelle ?
|
||||
</h3>
|
||||
<p className="text-muted mb-4">
|
||||
La météo est un outil simple et visuel pour exprimer rapidement votre état sur 4
|
||||
axes clés. En la partageant avec votre équipe, vous créez de la transparence et
|
||||
facilitez la communication sur votre bien-être et votre performance.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-muted">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500">•</span>
|
||||
Exprimer rapidement votre état avec des emojis météo intuitifs
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500">•</span>
|
||||
Partager votre météo avec votre équipe pour une meilleure visibilité
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500">•</span>
|
||||
Créer un espace de dialogue ouvert sur votre performance et votre moral
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500">•</span>
|
||||
Suivre l'évolution de votre état dans le temps
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* The 4 axes */}
|
||||
<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 4 axes de la météo
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<CategoryPill
|
||||
icon="☀️"
|
||||
name="Performance"
|
||||
color="#f59e0b"
|
||||
description="Votre performance personnelle et l'atteinte de vos objectifs"
|
||||
/>
|
||||
<CategoryPill
|
||||
icon="😊"
|
||||
name="Moral"
|
||||
color="#22c55e"
|
||||
description="Votre moral actuel et votre ressenti"
|
||||
/>
|
||||
<CategoryPill
|
||||
icon="🌊"
|
||||
name="Flux"
|
||||
color="#3b82f6"
|
||||
description="Votre flux de travail personnel et les blocages éventuels"
|
||||
/>
|
||||
<CategoryPill
|
||||
icon="💎"
|
||||
name="Création de valeur"
|
||||
color="#8b5cf6"
|
||||
description="Votre création de valeur et votre apport"
|
||||
/>
|
||||
</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="Créer votre météo"
|
||||
description="Créez une nouvelle météo personnelle avec un titre et une date"
|
||||
/>
|
||||
<StepCard
|
||||
number={2}
|
||||
title="Choisir vos emojis"
|
||||
description="Pour chaque axe, sélectionnez un emoji météo qui reflète votre état"
|
||||
/>
|
||||
<StepCard
|
||||
number={3}
|
||||
title="Ajouter des notes"
|
||||
description="Complétez avec des notes globales pour détailler votre ressenti"
|
||||
/>
|
||||
<StepCard
|
||||
number={4}
|
||||
title="Partager avec l'équipe"
|
||||
description="Partagez votre météo avec votre équipe ou une équipe entière pour qu'ils puissent voir votre état"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* GIF Mood Board 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">GIF Mood Board</h2>
|
||||
<p className="font-medium" style={{ color: '#ec4899' }}>
|
||||
Exprimez l'humeur de l'équipe en images
|
||||
</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 un GIF Mood Board ?
|
||||
</h3>
|
||||
<p className="text-muted mb-4">
|
||||
Les GIFs sont un langage universel pour exprimer ce que les mots peinent parfois à
|
||||
traduire. Le GIF Mood Board transforme un rituel d'équipe en moment visuel et
|
||||
ludique, idéal pour les rétrospectives, les stand-ups ou tout point d'équipe
|
||||
récurrent.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-muted">
|
||||
<li className="flex items-start gap-2">
|
||||
<span style={{ color: '#ec4899' }}>•</span>
|
||||
Rendre les rétrospectives plus vivantes et engageantes
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span style={{ color: '#ec4899' }}>•</span>
|
||||
Libérer l'expression émotionnelle avec humour et créativité
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span style={{ color: '#ec4899' }}>•</span>
|
||||
Voir en un coup d'œil l'humeur collective de l'équipe
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span style={{ color: '#ec4899' }}>•</span>
|
||||
Briser la glace et créer de la cohésion d'équipe
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* What's in it */}
|
||||
<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>
|
||||
Ce que chaque membre peut faire
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<FeaturePill
|
||||
icon="🎞️"
|
||||
name="Jusqu'à 5 GIFs par session"
|
||||
color="#ec4899"
|
||||
description="Choisissez les GIFs qui reflètent le mieux votre humeur du moment"
|
||||
/>
|
||||
<FeaturePill
|
||||
icon="✍️"
|
||||
name="Notes manuscrites"
|
||||
color="#8b5cf6"
|
||||
description="Ajoutez un contexte ou une explication à chaque GIF"
|
||||
/>
|
||||
<FeaturePill
|
||||
icon="⭐"
|
||||
name="Note de la semaine sur 5"
|
||||
color="#f59e0b"
|
||||
description="Résumez votre semaine en une note globale visible par toute l'équipe"
|
||||
/>
|
||||
<FeaturePill
|
||||
icon="⚡"
|
||||
name="Mise à jour en temps réel"
|
||||
color="#10b981"
|
||||
description="Voir les GIFs des collègues apparaître au fur et à mesure"
|
||||
/>
|
||||
</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="Créer la session"
|
||||
description="Le manager crée une session GIF Mood Board et la partage avec son équipe"
|
||||
/>
|
||||
<StepCard
|
||||
number={2}
|
||||
title="Choisir ses GIFs"
|
||||
description="Chaque membre ajoute jusqu'à 5 GIFs (Giphy, Tenor, ou toute URL d'image) pour exprimer son humeur"
|
||||
/>
|
||||
<StepCard
|
||||
number={3}
|
||||
title="Annoter et noter"
|
||||
description="Ajoutez une note manuscrite à chaque GIF et une note de semaine sur 5 étoiles"
|
||||
/>
|
||||
<StepCard
|
||||
number={4}
|
||||
title="Partager et discuter"
|
||||
description="Parcourez le board ensemble, repérez les GIFs qui reflètent le mieux l'humeur de l'équipe et lancez la discussion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* OKRs 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">OKRs & Équipes</h2>
|
||||
<p className="text-purple-500 font-medium">
|
||||
Définissez et suivez les objectifs de votre équipe
|
||||
</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 utiliser les OKRs ?
|
||||
</h3>
|
||||
<p className="text-muted mb-4">
|
||||
Les OKRs (Objectives and Key Results) sont un cadre de gestion d'objectifs qui
|
||||
permet d'aligner les efforts de l'équipe autour d'objectifs communs
|
||||
et mesurables. Cette méthode favorise la transparence, la responsabilisation et la
|
||||
performance collective.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-muted">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-500">•</span>
|
||||
Aligner les objectifs individuels avec ceux de l'équipe
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-500">•</span>
|
||||
Suivre la progression en temps réel avec des métriques claires
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-500">•</span>
|
||||
Favoriser la transparence et la visibilité des objectifs de chacun
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-500">•</span>
|
||||
Créer une culture de responsabilisation et de résultats
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<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>
|
||||
Fonctionnalités principales
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<FeaturePill
|
||||
icon="👥"
|
||||
name="Gestion d'équipes"
|
||||
color="#8b5cf6"
|
||||
description="Créez des équipes et gérez les membres avec des rôles admin/membre"
|
||||
/>
|
||||
<FeaturePill
|
||||
icon="🎯"
|
||||
name="OKRs par période"
|
||||
color="#3b82f6"
|
||||
description="Définissez des OKRs pour des trimestres ou périodes personnalisées"
|
||||
/>
|
||||
<FeaturePill
|
||||
icon="📊"
|
||||
name="Key Results mesurables"
|
||||
color="#10b981"
|
||||
description="Suivez la progression de chaque Key Result avec des valeurs et pourcentages"
|
||||
/>
|
||||
<FeaturePill
|
||||
icon="👁️"
|
||||
name="Visibilité transparente"
|
||||
color="#f59e0b"
|
||||
description="Tous les membres de l'équipe peuvent voir les OKRs de chacun"
|
||||
/>
|
||||
</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="Créer une équipe"
|
||||
description="Formez votre équipe et ajoutez les membres avec leurs rôles (admin ou membre)"
|
||||
/>
|
||||
<StepCard
|
||||
number={2}
|
||||
title="Définir les OKRs"
|
||||
description="Pour chaque membre, créez un Objectif avec plusieurs Key Results mesurables"
|
||||
/>
|
||||
<StepCard
|
||||
number={3}
|
||||
title="Suivre la progression"
|
||||
description="Mettez à jour régulièrement les valeurs des Key Results pour suivre l'avancement"
|
||||
/>
|
||||
<StepCard
|
||||
number={4}
|
||||
title="Visualiser et analyser"
|
||||
description="Consultez les OKRs par membre ou en grille, avec les progressions et statuts colorés"
|
||||
/>
|
||||
</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">
|
||||
@@ -303,7 +840,7 @@ function WorkshopCard({
|
||||
newHref: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-2xl border-2 border-border bg-card p-8 transition-all hover:border-primary/50 hover:shadow-xl">
|
||||
<div className="group relative overflow-hidden rounded-2xl border-2 border-border bg-card p-8 transition-all hover:border-primary/50 hover:bg-card-hover hover:shadow-xl">
|
||||
{/* Accent gradient */}
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 h-1 opacity-80"
|
||||
@@ -420,3 +957,57 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturePill({
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,18 +19,19 @@ export default async function ProfilePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-8">
|
||||
<div className="mb-8 flex items-center gap-6">
|
||||
<main className="mx-auto max-w-2xl px-4">
|
||||
<div className="mb-8 flex items-center gap-5">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getGravatarUrl(user.email, 160)}
|
||||
alt={user.name || user.email}
|
||||
width={80}
|
||||
height={80}
|
||||
className="rounded-full border-2 border-border"
|
||||
width={72}
|
||||
height={72}
|
||||
className="rounded-full border-2 border-border shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Mon Profil</h1>
|
||||
<p className="mt-1 text-muted">Gérez vos informations personnelles</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">Mon Profil</h1>
|
||||
<p className="mt-1.5 text-sm text-muted">Gérez vos informations personnelles</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
61
src/app/sessions/NewWorkshopDropdown.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui';
|
||||
import { WORKSHOPS } from '@/lib/workshops';
|
||||
import { useClickOutside } from '@/hooks/useClickOutside';
|
||||
|
||||
export function NewWorkshopDropdown() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(containerRef, () => setOpen(false), open);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Nouvel atelier
|
||||
<svg
|
||||
className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="absolute right-0 z-20 mt-2 w-60 rounded-xl border border-border bg-card py-1.5 shadow-lg">
|
||||
{WORKSHOPS.map((w) => (
|
||||
<Link
|
||||
key={w.id}
|
||||
href={w.newPath}
|
||||
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover transition-colors"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<span
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-base flex-shrink-0"
|
||||
style={{ backgroundColor: `${w.accentColor}18` }}
|
||||
>
|
||||
{w.icon}
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-medium">{w.label}</div>
|
||||
<div className="text-xs text-muted">{w.description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
282
src/app/sessions/SessionCard.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button, Modal, ModalFooter, Input, CollaboratorDisplay } from '@/components/ui';
|
||||
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
|
||||
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
|
||||
import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review';
|
||||
import { deleteWeeklyCheckInSession, updateWeeklyCheckInSession } from '@/actions/weekly-checkin';
|
||||
import { deleteWeatherSession, updateWeatherSession } from '@/actions/weather';
|
||||
import { deleteGifMoodSession, updateGifMoodSession } from '@/actions/gif-mood';
|
||||
import { type WorkshopTypeId, getWorkshop, getSessionPath } from '@/lib/workshops';
|
||||
import type { Share } from '@/lib/share-utils';
|
||||
import type {
|
||||
AnySession, CardView,
|
||||
SwotSession, MotivatorSession, YearReviewSession,
|
||||
WeeklyCheckInSession, WeatherSession, GifMoodSession,
|
||||
} from './workshop-session-types';
|
||||
import { TABLE_COLS } from './workshop-session-types';
|
||||
import { getResolvedCollaborator, formatDate, getStatsText } from './workshop-session-helpers';
|
||||
|
||||
// ─── RoleBadge ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function RoleBadge({ role }: { role: 'OWNER' | 'VIEWER' | 'EDITOR' }) {
|
||||
return (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded font-medium flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: role === 'EDITOR' ? 'rgba(6,182,212,0.12)' : 'rgba(234,179,8,0.12)',
|
||||
color: role === 'EDITOR' ? '#06b6d4' : '#ca8a04',
|
||||
}}
|
||||
>
|
||||
{role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── SharesList ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function SharesList({ shares }: { shares: Share[] }) {
|
||||
if (!shares.length) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[10px] text-muted">Partagé avec</span>
|
||||
{shares.slice(0, 3).map((s) => (
|
||||
<span key={s.id} className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
|
||||
{s.user.name?.split(' ')[0] || s.user.email.split('@')[0]}
|
||||
</span>
|
||||
))}
|
||||
{shares.length > 3 && <span className="text-[10px] text-muted">+{shares.length - 3}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── SessionCard ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function SessionCard({
|
||||
session, isTeamCollab = false, view = 'grid',
|
||||
}: {
|
||||
session: AnySession; isTeamCollab?: boolean; view?: CardView;
|
||||
}) {
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const isSwot = session.workshopType === 'swot';
|
||||
const isYearReview = session.workshopType === 'year-review';
|
||||
const isWeeklyCheckIn = session.workshopType === 'weekly-checkin';
|
||||
const isWeather = session.workshopType === 'weather';
|
||||
const isGifMood = session.workshopType === 'gif-mood';
|
||||
|
||||
const participant = isSwot ? (session as SwotSession).collaborator
|
||||
: isYearReview ? (session as YearReviewSession).participant
|
||||
: isWeeklyCheckIn ? (session as WeeklyCheckInSession).participant
|
||||
: isWeather ? (session as WeatherSession).user.name || (session as WeatherSession).user.email
|
||||
: isGifMood ? (session as GifMoodSession).user.name || (session as GifMoodSession).user.email
|
||||
: (session as MotivatorSession).participant;
|
||||
|
||||
const [editTitle, setEditTitle] = useState(session.title);
|
||||
const [editParticipant, setEditParticipant] = useState(
|
||||
isSwot ? (session as SwotSession).collaborator
|
||||
: isYearReview ? (session as YearReviewSession).participant
|
||||
: isWeather || isGifMood ? ''
|
||||
: (session as MotivatorSession).participant
|
||||
);
|
||||
|
||||
const workshop = getWorkshop(session.workshopType as WorkshopTypeId);
|
||||
const href = getSessionPath(session.workshopType as WorkshopTypeId, session.id);
|
||||
const accentColor = workshop.accentColor;
|
||||
const resolved = getResolvedCollaborator(session);
|
||||
const participantName = resolved.matchedUser?.name || resolved.matchedUser?.email?.split('@')[0] || resolved.raw;
|
||||
const statsText = getStatsText(session);
|
||||
|
||||
const handleDelete = () => {
|
||||
startTransition(async () => {
|
||||
const result = isSwot ? await deleteSwotSession(session.id)
|
||||
: isYearReview ? await deleteYearReviewSession(session.id)
|
||||
: isWeeklyCheckIn ? await deleteWeeklyCheckInSession(session.id)
|
||||
: isWeather ? await deleteWeatherSession(session.id)
|
||||
: isGifMood ? await deleteGifMoodSession(session.id)
|
||||
: await deleteMotivatorSession(session.id);
|
||||
if (result.success) setShowDeleteModal(false);
|
||||
else console.error('Error deleting:', result.error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
startTransition(async () => {
|
||||
const result = isSwot ? await updateSwotSession(session.id, { title: editTitle, collaborator: editParticipant })
|
||||
: isYearReview ? await updateYearReviewSession(session.id, { title: editTitle, participant: editParticipant })
|
||||
: isWeeklyCheckIn ? await updateWeeklyCheckInSession(session.id, { title: editTitle, participant: editParticipant })
|
||||
: isWeather ? await updateWeatherSession(session.id, { title: editTitle })
|
||||
: isGifMood ? await updateGifMoodSession(session.id, { title: editTitle })
|
||||
: await updateMotivatorSession(session.id, { title: editTitle, participant: editParticipant });
|
||||
if (result.success) setShowEditModal(false);
|
||||
else console.error('Error updating:', result.error);
|
||||
});
|
||||
};
|
||||
|
||||
const openEditModal = () => { setEditTitle(session.title); setEditParticipant(participant); setShowEditModal(true); };
|
||||
const hoverCard = !isTeamCollab ? 'hover:-translate-y-0.5 hover:shadow-md' : '';
|
||||
const opacity = isTeamCollab ? 'opacity-60' : '';
|
||||
|
||||
// ── Vue Grille ───────────────────────────────────────────────────────────
|
||||
const gridCard = (
|
||||
<div className={`h-full flex rounded-xl bg-card border border-border overflow-hidden transition-all duration-200 ${hoverCard} ${opacity}`}>
|
||||
<div className="w-1 flex-shrink-0" style={{ backgroundColor: accentColor }} />
|
||||
<div className="flex flex-col flex-1 px-4 py-4 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 className="font-semibold text-foreground line-clamp-2 leading-snug text-[15px] flex-1">{session.title}</h3>
|
||||
{!session.isOwner && <RoleBadge role={session.role} />}
|
||||
</div>
|
||||
<CollaboratorDisplay collaborator={resolved} size="sm" />
|
||||
{!session.isOwner && <p className="text-xs text-muted mt-0.5 truncate">par {session.user.name || session.user.email}</p>}
|
||||
{session.isOwner && session.shares.length > 0 && <div className="mt-2"><SharesList shares={session.shares} /></div>}
|
||||
<div className="flex-1 min-h-4" />
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border text-xs text-muted">
|
||||
<div className="flex items-center gap-1.5 min-w-0 truncate">
|
||||
<span className="text-sm leading-none flex-shrink-0">{workshop.icon}</span>
|
||||
<span className="font-medium flex-shrink-0" style={{ color: accentColor }}>{workshop.labelShort}</span>
|
||||
<span className="opacity-30 flex-shrink-0">·</span>
|
||||
<span className="truncate">{statsText}</span>
|
||||
</div>
|
||||
<span className="text-[11px] whitespace-nowrap ml-3 flex-shrink-0">{formatDate(session.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Vue Liste ────────────────────────────────────────────────────────────
|
||||
const listCard = (
|
||||
<div className={`flex items-center gap-3 rounded-xl bg-card border border-border overflow-hidden transition-all duration-150 ${!isTeamCollab ? 'hover:shadow-sm' : ''} ${opacity} px-4 py-3`}>
|
||||
<div className="w-0.5 self-stretch rounded-full flex-shrink-0" style={{ backgroundColor: accentColor }} />
|
||||
<span className="text-lg leading-none flex-shrink-0">{workshop.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="font-semibold text-foreground text-sm truncate">{session.title}</span>
|
||||
{!session.isOwner && <RoleBadge role={session.role} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted flex-wrap">
|
||||
<span className="truncate max-w-[140px]">{participantName}</span>
|
||||
<span className="opacity-30">·</span>
|
||||
<span className="font-medium flex-shrink-0" style={{ color: accentColor }}>{workshop.labelShort}</span>
|
||||
<span className="opacity-30">·</span>
|
||||
<span className="whitespace-nowrap">{statsText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted whitespace-nowrap flex-shrink-0 ml-2">{formatDate(session.updatedAt)}</span>
|
||||
<svg className="w-3.5 h-3.5 text-muted/40 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Vue Tableau ──────────────────────────────────────────────────────────
|
||||
const tableRow = (
|
||||
<div
|
||||
className={`grid border-b border-border last:border-0 transition-colors ${!isTeamCollab ? 'hover:bg-card-hover/60' : ''} ${opacity}`}
|
||||
style={{ gridTemplateColumns: TABLE_COLS }}
|
||||
>
|
||||
<div className="px-4 py-3 flex items-center gap-2">
|
||||
<div className="w-0.5 h-5 rounded-full flex-shrink-0" style={{ backgroundColor: accentColor }} />
|
||||
<span className="text-base leading-none">{workshop.icon}</span>
|
||||
<span className="text-xs font-semibold truncate" style={{ color: accentColor }}>{workshop.labelShort}</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center gap-2 min-w-0">
|
||||
<span className="font-medium text-foreground text-sm truncate">{session.title}</span>
|
||||
{!session.isOwner && <RoleBadge role={session.role} />}
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center">
|
||||
<CollaboratorDisplay
|
||||
collaborator={{ raw: session.user.name || session.user.email, matchedUser: { id: session.user.id, email: session.user.email, name: session.user.name } }}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center">
|
||||
<CollaboratorDisplay collaborator={resolved} size="sm" />
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center text-xs text-muted">
|
||||
<span className="truncate">{statsText}</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center text-xs text-muted whitespace-nowrap">
|
||||
{formatDate(session.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const cardContent = view === 'list' ? listCard : view === 'table' ? tableRow : gridCard;
|
||||
|
||||
const actionButtons = (
|
||||
<>
|
||||
{(session.isOwner || session.role === 'EDITOR' || session.isTeamCollab) && (
|
||||
<div className={`absolute flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-20 ${view === 'table' ? 'top-1/2 -translate-y-1/2 right-3' : 'top-2.5 right-2.5'}`}>
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); openEditModal(); }}
|
||||
className="p-1.5 rounded-lg bg-card border border-border text-muted hover:text-primary hover:bg-primary/5 shadow-sm"
|
||||
title="Modifier"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
{(session.isOwner || session.isTeamCollab) && (
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setShowDeleteModal(true); }}
|
||||
className="p-1.5 rounded-lg bg-card border border-border text-muted hover:text-destructive hover:bg-destructive/5 shadow-sm"
|
||||
title="Supprimer"
|
||||
>
|
||||
<svg className="w-3.5 h-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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative group">
|
||||
<Link href={href} className={view === 'table' ? 'block' : undefined} title={isTeamCollab ? "Atelier de l'équipe" : undefined}>
|
||||
{cardContent}
|
||||
</Link>
|
||||
{actionButtons}
|
||||
</div>
|
||||
|
||||
<Modal isOpen={showEditModal} onClose={() => setShowEditModal(false)} title="Modifier l'atelier" size="sm">
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleEdit(); }} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="edit-title" className="block text-sm font-medium text-foreground mb-1">Titre</label>
|
||||
<Input id="edit-title" value={editTitle} onChange={(e) => setEditTitle(e.target.value)} placeholder="Titre de l'atelier" required />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="edit-participant" className="block text-sm font-medium text-foreground mb-1">{workshop.participantLabel}</label>
|
||||
{!isWeather && !isGifMood && (
|
||||
<Input id="edit-participant" value={editParticipant} onChange={(e) => setEditParticipant(e.target.value)}
|
||||
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'} required />
|
||||
)}
|
||||
</div>
|
||||
<ModalFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setShowEditModal(false)} disabled={isPending}>Annuler</Button>
|
||||
<Button type="submit" disabled={isPending || !editTitle.trim() || (!isWeather && !isGifMood && !editParticipant.trim())}>
|
||||
{isPending ? 'Enregistrement...' : 'Enregistrer'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={showDeleteModal} onClose={() => setShowDeleteModal(false)} title="Supprimer l'atelier" size="sm">
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted">Êtes-vous sûr de vouloir supprimer <strong className="text-foreground">"{session.title}"</strong> ?</p>
|
||||
<p className="text-sm text-destructive">Cette action est irréversible. Toutes les données seront perdues.</p>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" onClick={() => setShowDeleteModal(false)} disabled={isPending}>Annuler</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={isPending}>{isPending ? 'Suppression...' : 'Supprimer'}</Button>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,216 +1,387 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { CollaboratorDisplay } from '@/components/ui';
|
||||
import { type WorkshopTabType, WORKSHOPS, VALID_TAB_PARAMS } from '@/lib/workshops';
|
||||
import { useClickOutside } from '@/hooks/useClickOutside';
|
||||
import {
|
||||
Card,
|
||||
Badge,
|
||||
Button,
|
||||
Modal,
|
||||
ModalFooter,
|
||||
Input,
|
||||
CollaboratorDisplay,
|
||||
} from '@/components/ui';
|
||||
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
|
||||
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
|
||||
type CardView, type SortCol, type WorkshopTabsProps, type AnySession,
|
||||
TABLE_COLS, SORT_COLUMNS, TYPE_TABS,
|
||||
} from './workshop-session-types';
|
||||
import {
|
||||
getResolvedCollaborator, groupByPerson, getMonthGroup, sortSessions,
|
||||
} from './workshop-session-helpers';
|
||||
import { SessionCard } from './SessionCard';
|
||||
|
||||
type WorkshopType = 'all' | 'swot' | 'motivators' | 'byPerson';
|
||||
// ─── SectionHeader ────────────────────────────────────────────────────────────
|
||||
|
||||
const VALID_TABS: WorkshopType[] = ['all', 'swot', 'motivators', 'byPerson'];
|
||||
|
||||
interface ShareUser {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Share {
|
||||
id: string;
|
||||
role: 'VIEWER' | 'EDITOR';
|
||||
user: ShareUser;
|
||||
}
|
||||
|
||||
interface ResolvedCollaborator {
|
||||
raw: string;
|
||||
matchedUser: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface SwotSession {
|
||||
id: string;
|
||||
title: string;
|
||||
collaborator: string;
|
||||
resolvedCollaborator: ResolvedCollaborator;
|
||||
updatedAt: Date;
|
||||
isOwner: boolean;
|
||||
role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||
user: { id: string; name: string | null; email: string };
|
||||
shares: Share[];
|
||||
_count: { items: number; actions: number };
|
||||
workshopType: 'swot';
|
||||
}
|
||||
|
||||
interface MotivatorSession {
|
||||
id: string;
|
||||
title: string;
|
||||
participant: string;
|
||||
resolvedParticipant: ResolvedCollaborator;
|
||||
updatedAt: Date;
|
||||
isOwner: boolean;
|
||||
role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||
user: { id: string; name: string | null; email: string };
|
||||
shares: Share[];
|
||||
_count: { cards: number };
|
||||
workshopType: 'motivators';
|
||||
}
|
||||
|
||||
type AnySession = SwotSession | MotivatorSession;
|
||||
|
||||
interface WorkshopTabsProps {
|
||||
swotSessions: SwotSession[];
|
||||
motivatorSessions: MotivatorSession[];
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
return resolved.raw;
|
||||
}
|
||||
|
||||
// Get grouping key - use matched user ID if available, otherwise normalized raw string
|
||||
function getGroupKey(session: AnySession): string {
|
||||
const resolved = getResolvedCollaborator(session);
|
||||
// If we have a matched user, use their ID as key (ensures same person = same group)
|
||||
if (resolved.matchedUser) {
|
||||
return `user:${resolved.matchedUser.id}`;
|
||||
}
|
||||
// Otherwise, normalize the raw string
|
||||
return `raw:${resolved.raw.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
// Group sessions by participant (using matched user ID when available)
|
||||
function groupByPerson(sessions: AnySession[]): Map<string, AnySession[]> {
|
||||
const grouped = new Map<string, AnySession[]>();
|
||||
|
||||
sessions.forEach((session) => {
|
||||
const key = getGroupKey(session);
|
||||
|
||||
const existing = grouped.get(key);
|
||||
if (existing) {
|
||||
existing.push(session);
|
||||
} else {
|
||||
grouped.set(key, [session]);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort sessions within each group by date
|
||||
grouped.forEach((sessions) => {
|
||||
sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
// Get tab from URL or default to 'all'
|
||||
const tabParam = searchParams.get('tab');
|
||||
const activeTab: WorkshopType =
|
||||
tabParam && VALID_TABS.includes(tabParam as WorkshopType) ? (tabParam as WorkshopType) : 'all';
|
||||
|
||||
const setActiveTab = (tab: WorkshopType) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (tab === 'all') {
|
||||
params.delete('tab');
|
||||
} else {
|
||||
params.set('tab', tab);
|
||||
}
|
||||
router.push(`/sessions${params.toString() ? `?${params.toString()}` : ''}`);
|
||||
};
|
||||
|
||||
// Combine and sort all sessions
|
||||
const allSessions: AnySession[] = [...swotSessions, ...motivatorSessions].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
function SectionHeader({ label, count }: { label: string; count: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="w-1 h-5 rounded-full bg-primary flex-shrink-0" />
|
||||
<h2 className="text-sm font-semibold text-foreground">{label}</h2>
|
||||
<span className="inline-flex items-center justify-center h-5 min-w-[20px] px-1.5 rounded-full bg-primary/10 text-primary text-[11px] font-semibold">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Filter based on active tab (for non-byPerson tabs)
|
||||
const filteredSessions =
|
||||
activeTab === 'all' || activeTab === 'byPerson'
|
||||
? allSessions
|
||||
: activeTab === 'swot'
|
||||
? swotSessions
|
||||
: motivatorSessions;
|
||||
// ─── SortIcon ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Separate by ownership
|
||||
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
|
||||
const sharedSessions = filteredSessions.filter((s) => !s.isOwner);
|
||||
function SortIcon({ active, dir }: { active: boolean; dir: 'asc' | 'desc' }) {
|
||||
if (!active) {
|
||||
return (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor" className="opacity-30 flex-shrink-0">
|
||||
<path d="M5 1.5L8 5H2L5 1.5Z" />
|
||||
<path d="M5 8.5L2 5H8L5 8.5Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (dir === 'asc') {
|
||||
return (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor" className="flex-shrink-0">
|
||||
<path d="M5 1.5L8 6H2L5 1.5Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor" className="flex-shrink-0">
|
||||
<path d="M5 8.5L2 4H8L5 8.5Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Group by person (all sessions - owned and shared)
|
||||
const sessionsByPerson = groupByPerson(allSessions);
|
||||
const sortedPersons = Array.from(sessionsByPerson.entries()).sort((a, b) =>
|
||||
a[0].localeCompare(b[0], 'fr')
|
||||
// ─── ViewToggle ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ViewToggle({ view, setView }: { view: CardView; setView: (v: CardView) => void }) {
|
||||
const btn = (v: CardView, label: string, icon: React.ReactNode) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
title={label}
|
||||
onClick={() => setView(v)}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
view === v ? 'bg-primary text-primary-foreground' : 'text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b border-border pb-4 flex-wrap">
|
||||
<TabButton
|
||||
active={activeTab === 'all'}
|
||||
onClick={() => setActiveTab('all')}
|
||||
icon="📋"
|
||||
label="Tous"
|
||||
count={allSessions.length}
|
||||
<div className="flex items-center gap-0.5 p-0.5 bg-card border border-border rounded-lg ml-auto flex-shrink-0 shadow-sm">
|
||||
{btn('grid', 'Grille',
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<rect x="0" y="0" width="4" height="4" rx="0.5" /><rect x="5" y="0" width="4" height="4" rx="0.5" /><rect x="10" y="0" width="4" height="4" rx="0.5" />
|
||||
<rect x="0" y="5" width="4" height="4" rx="0.5" /><rect x="5" y="5" width="4" height="4" rx="0.5" /><rect x="10" y="5" width="4" height="4" rx="0.5" />
|
||||
<rect x="0" y="10" width="4" height="4" rx="0.5" /><rect x="5" y="10" width="4" height="4" rx="0.5" /><rect x="10" y="10" width="4" height="4" rx="0.5" />
|
||||
</svg>
|
||||
)}
|
||||
{btn('list', 'Liste',
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<line x1="0" y1="2.5" x2="14" y2="2.5" /><line x1="0" y1="7" x2="14" y2="7" /><line x1="0" y1="11.5" x2="14" y2="11.5" />
|
||||
</svg>
|
||||
)}
|
||||
{btn('table', 'Tableau',
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<rect x="0" y="0" width="14" height="3.5" rx="0.5" />
|
||||
<rect x="0" y="5" width="6" height="2.5" rx="0.5" /><rect x="8" y="5" width="6" height="2.5" rx="0.5" />
|
||||
<rect x="0" y="9.5" width="6" height="2.5" rx="0.5" /><rect x="8" y="9.5" width="6" height="2.5" rx="0.5" />
|
||||
</svg>
|
||||
)}
|
||||
{btn('timeline', 'Chronologique',
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<line x1="3" y1="0" x2="3" y2="14" />
|
||||
<circle cx="3" cy="2.5" r="1.5" fill="currentColor" stroke="none" />
|
||||
<line x1="5" y1="2.5" x2="14" y2="2.5" />
|
||||
<circle cx="3" cy="7" r="1.5" fill="currentColor" stroke="none" />
|
||||
<line x1="5" y1="7" x2="14" y2="7" />
|
||||
<circle cx="3" cy="11.5" r="1.5" fill="currentColor" stroke="none" />
|
||||
<line x1="5" y1="11.5" x2="14" y2="11.5" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── TabButton ────────────────────────────────────────────────────────────────
|
||||
|
||||
function TabButton({ active, onClick, icon, label, count }: {
|
||||
active: boolean; onClick: () => void; icon: string; label: string; count: number;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" onClick={onClick}
|
||||
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-full font-medium text-sm transition-all duration-150 shadow-sm ${
|
||||
active
|
||||
? 'bg-primary text-primary-foreground shadow-md'
|
||||
: 'bg-card text-foreground/70 border border-border hover:text-foreground hover:bg-card-hover'
|
||||
}`}
|
||||
>
|
||||
<span>{icon}</span>
|
||||
<span>{label}</span>
|
||||
<span className={`text-[11px] font-semibold px-1.5 py-0.5 rounded-full ${active ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── TypeFilterDropdown ───────────────────────────────────────────────────────
|
||||
|
||||
function TypeFilterDropdown({
|
||||
activeTab, setActiveTab, open, onOpenChange, counts,
|
||||
}: {
|
||||
activeTab: WorkshopTabType; setActiveTab: (t: WorkshopTabType) => void;
|
||||
open: boolean; onOpenChange: (v: boolean) => void; counts: Record<string, number>;
|
||||
}) {
|
||||
const typeTabs = TYPE_TABS.filter((t) => t.value !== 'all' && t.value !== 'team');
|
||||
const current = TYPE_TABS.find((t) => t.value === activeTab) ?? TYPE_TABS[0];
|
||||
const isTypeSelected = activeTab !== 'all' && activeTab !== 'byPerson' && activeTab !== 'team';
|
||||
const totalCount = typeTabs.reduce((s, t) => s + (counts[t.value] ?? 0), 0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(containerRef, () => onOpenChange(false), open);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(!open)}
|
||||
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-full font-medium text-sm transition-all duration-150 shadow-sm ${
|
||||
isTypeSelected
|
||||
? 'bg-primary text-primary-foreground shadow-md'
|
||||
: 'bg-card text-foreground/70 border border-border hover:text-foreground hover:bg-card-hover'
|
||||
}`}
|
||||
>
|
||||
<span>{isTypeSelected ? current.icon : '🔖'}</span>
|
||||
<span>{isTypeSelected ? current.label : 'Type'}</span>
|
||||
<span className={`text-[11px] font-semibold px-1.5 py-0.5 rounded-full ${isTypeSelected ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}>
|
||||
{isTypeSelected ? (counts[activeTab] ?? 0) : totalCount}
|
||||
</span>
|
||||
<svg className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 z-20 mt-2 w-48 rounded-xl border border-border bg-card py-1.5 shadow-lg">
|
||||
<button type="button" onClick={() => { setActiveTab('all'); onOpenChange(false); }}
|
||||
className="flex w-full items-center justify-between gap-2 px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover border-b border-border transition-colors">
|
||||
<span className="flex items-center gap-2"><span>📋</span><span>Tous les types</span></span>
|
||||
<span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">{totalCount}</span>
|
||||
</button>
|
||||
{typeTabs.map((t) => (
|
||||
<button key={t.value} type="button" onClick={() => { setActiveTab(t.value); onOpenChange(false); }}
|
||||
className="flex w-full items-center justify-between gap-2 px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover transition-colors">
|
||||
<span className="flex items-center gap-2"><span>{t.icon}</span><span>{t.label}</span></span>
|
||||
<span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">{counts[t.value] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── SessionsGrid ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SessionsGrid({
|
||||
sessions, view, isTeamCollab = false,
|
||||
}: {
|
||||
sessions: AnySession[]; view: CardView; isTeamCollab?: boolean;
|
||||
}) {
|
||||
if (view === 'table') {
|
||||
return (
|
||||
<div className="rounded-xl border border-border overflow-hidden overflow-x-auto bg-card">
|
||||
<div className="grid text-[11px] font-semibold text-muted uppercase tracking-wider bg-card-hover/60 border-b border-border" style={{ gridTemplateColumns: TABLE_COLS }}>
|
||||
{SORT_COLUMNS.map((col) => (
|
||||
<div key={col.key} className="px-4 py-2.5">{col.label}</div>
|
||||
))}
|
||||
</div>
|
||||
{sessions.map((s) => (
|
||||
<SessionCard key={s.id} session={s} isTeamCollab={isTeamCollab} view="table" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={view === 'list' ? 'flex flex-col gap-2' : 'grid gap-4 md:grid-cols-2 lg:grid-cols-3'}>
|
||||
{sessions.map((s) => (
|
||||
<SessionCard key={s.id} session={s} isTeamCollab={isTeamCollab} view={view} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── SortableTableView ────────────────────────────────────────────────────────
|
||||
|
||||
function SortableTableView({
|
||||
sessions, sortCol, sortDir, onSort,
|
||||
}: {
|
||||
sessions: AnySession[];
|
||||
sortCol: SortCol;
|
||||
sortDir: 'asc' | 'desc';
|
||||
onSort: (col: SortCol) => void;
|
||||
}) {
|
||||
if (sessions.length === 0) {
|
||||
return <div className="text-center py-12 text-muted">Aucun atelier pour le moment</div>;
|
||||
}
|
||||
return (
|
||||
<div className="rounded-xl border border-border overflow-hidden overflow-x-auto bg-card">
|
||||
<div className="grid bg-card-hover/60 border-b border-border" style={{ gridTemplateColumns: TABLE_COLS }}>
|
||||
{SORT_COLUMNS.map((col) => (
|
||||
<button
|
||||
key={col.key}
|
||||
type="button"
|
||||
onClick={() => onSort(col.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2.5 text-left text-[11px] font-semibold uppercase tracking-wider transition-colors hover:text-foreground ${
|
||||
sortCol === col.key ? 'text-primary' : 'text-muted'
|
||||
}`}
|
||||
>
|
||||
{col.label}
|
||||
<SortIcon active={sortCol === col.key} dir={sortDir} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{sessions.map((s) => (
|
||||
<SessionCard
|
||||
key={s.id}
|
||||
session={s}
|
||||
view="table"
|
||||
isTeamCollab={(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab}
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'byPerson'}
|
||||
onClick={() => setActiveTab('byPerson')}
|
||||
icon="👥"
|
||||
label="Par personne"
|
||||
count={sessionsByPerson.size}
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'swot'}
|
||||
onClick={() => setActiveTab('swot')}
|
||||
icon="📊"
|
||||
label="SWOT"
|
||||
count={swotSessions.length}
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'motivators'}
|
||||
onClick={() => setActiveTab('motivators')}
|
||||
icon="🎯"
|
||||
label="Moving Motivators"
|
||||
count={motivatorSessions.length}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── WorkshopTabs ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function WorkshopTabs({
|
||||
swotSessions, motivatorSessions, yearReviewSessions,
|
||||
weeklyCheckInSessions, weatherSessions, gifMoodSessions,
|
||||
teamCollabSessions = [],
|
||||
}: WorkshopTabsProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false);
|
||||
const [cardView, setCardView] = useState<CardView>('grid');
|
||||
const [sortCol, setSortCol] = useState<SortCol>('date');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
const handleSort = (col: SortCol) => {
|
||||
if (sortCol === col) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
else { setSortCol(col); setSortDir('asc'); }
|
||||
};
|
||||
|
||||
const tabParam = searchParams.get('tab');
|
||||
const activeTab: WorkshopTabType =
|
||||
tabParam && VALID_TAB_PARAMS.includes(tabParam as WorkshopTabType)
|
||||
? (tabParam as WorkshopTabType)
|
||||
: 'all';
|
||||
|
||||
const setActiveTab = (tab: WorkshopTabType) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (tab === 'all') params.delete('tab');
|
||||
else params.set('tab', tab);
|
||||
router.push(`/sessions${params.toString() ? `?${params.toString()}` : ''}`);
|
||||
};
|
||||
|
||||
const allSessions: AnySession[] = [
|
||||
...swotSessions, ...motivatorSessions, ...yearReviewSessions,
|
||||
...weeklyCheckInSessions, ...weatherSessions, ...gifMoodSessions,
|
||||
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
|
||||
const filteredSessions: AnySession[] =
|
||||
activeTab === 'all' || activeTab === 'byPerson' ? allSessions
|
||||
: activeTab === 'team' ? teamCollabSessions
|
||||
: activeTab === 'swot' ? swotSessions
|
||||
: activeTab === 'motivators' ? motivatorSessions
|
||||
: activeTab === 'year-review' ? yearReviewSessions
|
||||
: activeTab === 'weekly-checkin' ? weeklyCheckInSessions
|
||||
: activeTab === 'gif-mood' ? gifMoodSessions
|
||||
: weatherSessions;
|
||||
|
||||
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
|
||||
const sharedSessions = filteredSessions.filter(
|
||||
(s) => !s.isOwner && !(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab
|
||||
);
|
||||
const teamCollabFiltered = activeTab === 'all' ? teamCollabSessions : [];
|
||||
|
||||
const sessionsByPerson = groupByPerson(allSessions);
|
||||
const sortedPersons = Array.from(sessionsByPerson.entries()).sort((a, b) => a[0].localeCompare(b[0], 'fr'));
|
||||
|
||||
// Timeline grouping
|
||||
const timelineSessions = [...(activeTab === 'all' ? [...filteredSessions, ...teamCollabSessions] : filteredSessions)]
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
const byMonth = new Map<string, AnySession[]>();
|
||||
timelineSessions.forEach((s) => {
|
||||
const key = getMonthGroup(s.updatedAt);
|
||||
if (!byMonth.has(key)) byMonth.set(key, []);
|
||||
byMonth.get(key)!.push(s);
|
||||
});
|
||||
|
||||
// Flat sorted sessions for the sortable table view
|
||||
const flatTableSessions =
|
||||
cardView === 'table' && activeTab !== 'byPerson'
|
||||
? sortSessions(
|
||||
activeTab === 'all' ? [...filteredSessions, ...teamCollabSessions] : filteredSessions,
|
||||
sortCol, sortDir,
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Tabs + vue toggle */}
|
||||
<div className="flex gap-1.5 items-center flex-wrap">
|
||||
<TabButton active={activeTab === 'all'} onClick={() => setActiveTab('all')} icon="📋" label="Tous" count={allSessions.length} />
|
||||
<TabButton active={activeTab === 'byPerson'} onClick={() => setActiveTab('byPerson')} icon="👥" label="Par personne" count={sessionsByPerson.size} />
|
||||
{teamCollabSessions.length > 0 && (
|
||||
<TabButton active={activeTab === 'team'} onClick={() => setActiveTab('team')} icon="🏢" label="Équipe" count={teamCollabSessions.length} />
|
||||
)}
|
||||
<div className="h-5 w-px bg-border mx-0.5 self-center" />
|
||||
<TypeFilterDropdown
|
||||
activeTab={activeTab} setActiveTab={setActiveTab}
|
||||
open={typeDropdownOpen} onOpenChange={setTypeDropdownOpen}
|
||||
counts={{
|
||||
swot: swotSessions.length, motivators: motivatorSessions.length,
|
||||
'year-review': yearReviewSessions.length, 'weekly-checkin': weeklyCheckInSessions.length,
|
||||
weather: weatherSessions.length, 'gif-mood': gifMoodSessions.length,
|
||||
team: teamCollabSessions.length,
|
||||
}}
|
||||
/>
|
||||
<ViewToggle view={cardView} setView={setCardView} />
|
||||
</div>
|
||||
|
||||
{/* Sessions */}
|
||||
{activeTab === 'byPerson' ? (
|
||||
// By Person View
|
||||
{/* ── Vue Tableau flat (colonnes triables) ──────────────────── */}
|
||||
{cardView === 'table' && activeTab !== 'byPerson' ? (
|
||||
<SortableTableView sessions={flatTableSessions} sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
|
||||
|
||||
) : cardView === 'timeline' && activeTab !== 'byPerson' ? (
|
||||
/* ── Vue Timeline ────────────────────────────────────────── */
|
||||
byMonth.size === 0 ? (
|
||||
<div className="text-center py-12 text-muted">Aucun atelier pour le moment</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{Array.from(byMonth.entries()).map(([period, sessions]) => (
|
||||
<section key={period}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs font-semibold text-muted uppercase tracking-widest px-2 capitalize">{period}</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{sessions.map((s) => (
|
||||
<SessionCard key={s.id} session={s} isTeamCollab={(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab} view="list" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
) : activeTab === 'byPerson' ? (
|
||||
/* ── Vue Par personne ───────────────────────────────────── */
|
||||
sortedPersons.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted">Aucun atelier pour le moment</div>
|
||||
) : (
|
||||
@@ -219,51 +390,58 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
|
||||
const resolved = getResolvedCollaborator(sessions[0]);
|
||||
return (
|
||||
<section key={personKey}>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<CollaboratorDisplay collaborator={resolved} size="md" />
|
||||
<Badge variant="primary">
|
||||
<span className="inline-flex items-center justify-center h-5 min-w-[20px] px-1.5 rounded-full bg-primary/10 text-primary text-[11px] font-semibold">
|
||||
{sessions.length} atelier{sessions.length > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{sessions.map((s) => (
|
||||
<SessionCard key={s.id} session={s} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<SessionsGrid sessions={sessions} view={cardView === 'timeline' ? 'list' : cardView} />
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
) : activeTab === 'team' ? (
|
||||
/* ── Vue Équipe ─────────────────────────────────────────── */
|
||||
teamCollabSessions.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted">Aucun atelier de vos collaborateurs (non partagés)</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<section>
|
||||
<SectionHeader label="Ateliers de l'équipe – non partagés" count={teamCollabSessions.length} />
|
||||
<p className="text-sm text-muted mb-5 -mt-2">
|
||||
En tant qu'admin d'équipe, vous voyez les ateliers de vos collaborateurs
|
||||
qui ne vous sont pas encore partagés.
|
||||
</p>
|
||||
<SessionsGrid sessions={teamCollabSessions} view={cardView} isTeamCollab />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted">Aucun atelier de ce type pour le moment</div>
|
||||
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* My Sessions */}
|
||||
/* ── Vue normale (tous / par type) ─────────────────────── */
|
||||
<div className="space-y-10">
|
||||
{ownedSessions.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
📁 Mes ateliers ({ownedSessions.length})
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{ownedSessions.map((s) => (
|
||||
<SessionCard key={s.id} session={s} />
|
||||
))}
|
||||
</div>
|
||||
<SectionHeader label="Mes ateliers" count={ownedSessions.length} />
|
||||
<SessionsGrid sessions={ownedSessions} view={cardView} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Shared Sessions */}
|
||||
{sharedSessions.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
🤝 Partagés avec moi ({sharedSessions.length})
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{sharedSessions.map((s) => (
|
||||
<SessionCard key={s.id} session={s} />
|
||||
))}
|
||||
</div>
|
||||
<SectionHeader label="Partagés avec moi" count={sharedSessions.length} />
|
||||
<SessionsGrid sessions={sharedSessions} view={cardView} />
|
||||
</section>
|
||||
)}
|
||||
{activeTab === 'all' && teamCollabFiltered.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader label="Équipe – non partagés" count={teamCollabFiltered.length} />
|
||||
<SessionsGrid sessions={teamCollabFiltered} view={cardView} isTeamCollab />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
@@ -271,317 +449,3 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
icon: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors
|
||||
${
|
||||
active
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted hover:bg-card-hover hover:text-foreground'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span>{icon}</span>
|
||||
<span>{label}</span>
|
||||
<Badge variant={active ? 'default' : 'primary'} className="ml-1">
|
||||
{count}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionCard({ session }: { session: AnySession }) {
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Edit form state
|
||||
const [editTitle, setEditTitle] = useState(session.title);
|
||||
const [editParticipant, setEditParticipant] = useState(
|
||||
session.workshopType === 'swot'
|
||||
? (session as SwotSession).collaborator
|
||||
: (session as MotivatorSession).participant
|
||||
);
|
||||
|
||||
const isSwot = session.workshopType === 'swot';
|
||||
const href = isSwot ? `/sessions/${session.id}` : `/motivators/${session.id}`;
|
||||
const icon = isSwot ? '📊' : '🎯';
|
||||
const participant = isSwot
|
||||
? (session as SwotSession).collaborator
|
||||
: (session as MotivatorSession).participant;
|
||||
const accentColor = isSwot ? '#06b6d4' : '#8b5cf6';
|
||||
|
||||
const handleDelete = () => {
|
||||
startTransition(async () => {
|
||||
const result = isSwot
|
||||
? await deleteSwotSession(session.id)
|
||||
: await deleteMotivatorSession(session.id);
|
||||
|
||||
if (result.success) {
|
||||
setShowDeleteModal(false);
|
||||
} else {
|
||||
console.error('Error deleting session:', result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
startTransition(async () => {
|
||||
const result = isSwot
|
||||
? await updateSwotSession(session.id, { title: editTitle, collaborator: editParticipant })
|
||||
: await updateMotivatorSession(session.id, {
|
||||
title: editTitle,
|
||||
participant: editParticipant,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setShowEditModal(false);
|
||||
} else {
|
||||
console.error('Error updating session:', result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const openEditModal = () => {
|
||||
// Reset form values when opening
|
||||
setEditTitle(session.title);
|
||||
setEditParticipant(participant);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative group">
|
||||
<Link href={href}>
|
||||
<Card hover className="h-full p-4 relative overflow-hidden">
|
||||
{/* Accent bar */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-1"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
/>
|
||||
|
||||
{/* Header: Icon + Title + Role badge */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">{icon}</span>
|
||||
<h3 className="font-semibold text-foreground line-clamp-1 flex-1">{session.title}</h3>
|
||||
{!session.isOwner && (
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
backgroundColor:
|
||||
session.role === 'EDITOR' ? 'rgba(6,182,212,0.1)' : 'rgba(234,179,8,0.1)',
|
||||
color: session.role === 'EDITOR' ? '#06b6d4' : '#eab308',
|
||||
}}
|
||||
>
|
||||
{session.role === 'EDITOR' ? '✏️' : '👁️'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Participant + Owner info */}
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<CollaboratorDisplay collaborator={getResolvedCollaborator(session)} size="sm" />
|
||||
{!session.isOwner && (
|
||||
<span className="text-xs text-muted">
|
||||
· par {session.user.name || session.user.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: Stats + Avatars + Date */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-2 text-muted">
|
||||
{isSwot ? (
|
||||
<>
|
||||
<span>{(session as SwotSession)._count.items} items</span>
|
||||
<span>·</span>
|
||||
<span>{(session as SwotSession)._count.actions} actions</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{(session as MotivatorSession)._count.cards}/10</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<span className="text-muted">
|
||||
{new Date(session.updatedAt).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Shared with */}
|
||||
{session.isOwner && session.shares.length > 0 && (
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
|
||||
<span className="text-[10px] text-muted uppercase tracking-wide">Partagé</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{session.shares.slice(0, 3).map((share) => (
|
||||
<div
|
||||
key={share.id}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-primary/10 text-[10px] text-primary"
|
||||
title={share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{share.user.name?.split(' ')[0] || share.user.email.split('@')[0]}
|
||||
</span>
|
||||
<span>{share.role === 'EDITOR' ? '✏️' : '👁️'}</span>
|
||||
</div>
|
||||
))}
|
||||
{session.shares.length > 3 && (
|
||||
<span className="text-[10px] text-muted">+{session.shares.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Action buttons - only for owner */}
|
||||
{session.isOwner && (
|
||||
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openEditModal();
|
||||
}}
|
||||
className="p-1.5 rounded-lg bg-primary/10 text-primary hover:bg-primary/20"
|
||||
title="Modifier"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
className="p-1.5 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive/20"
|
||||
title="Supprimer"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
|
||||
{/* Edit modal */}
|
||||
<Modal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
title="Modifier l'atelier"
|
||||
size="sm"
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleEdit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="edit-title" className="block text-sm font-medium text-foreground mb-1">
|
||||
Titre
|
||||
</label>
|
||||
<Input
|
||||
id="edit-title"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
placeholder="Titre de l'atelier"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="edit-participant"
|
||||
className="block text-sm font-medium text-foreground mb-1"
|
||||
>
|
||||
{isSwot ? 'Collaborateur' : 'Participant'}
|
||||
</label>
|
||||
<Input
|
||||
id="edit-participant"
|
||||
value={editParticipant}
|
||||
onChange={(e) => setEditParticipant(e.target.value)}
|
||||
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setShowEditModal(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || !editTitle.trim() || !editParticipant.trim()}
|
||||
>
|
||||
{isPending ? 'Enregistrement...' : 'Enregistrer'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<Modal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
title="Supprimer l'atelier"
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted">
|
||||
Êtes-vous sûr de vouloir supprimer l'atelier{' '}
|
||||
<strong className="text-foreground">"{session.title}"</strong> ?
|
||||
</p>
|
||||
<p className="text-sm text-destructive">
|
||||
Cette action est irréversible. Toutes les données seront perdues.
|
||||
</p>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" onClick={() => setShowDeleteModal(false)} disabled={isPending}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={isPending}>
|
||||
{isPending ? 'Suppression...' : 'Supprimer'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getSessionById } from '@/services/sessions';
|
||||
import { getUserTeams } from '@/services/teams';
|
||||
import { SwotBoard } from '@/components/swot/SwotBoard';
|
||||
import { SessionLiveWrapper } from '@/components/collaboration';
|
||||
import { EditableTitle } from '@/components/session';
|
||||
import { Badge, CollaboratorDisplay } from '@/components/ui';
|
||||
import { Badge, SessionPageHeader } from '@/components/ui';
|
||||
|
||||
interface SessionPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -19,57 +18,31 @@ export default async function SessionPage({ params }: SessionPageProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await getSessionById(id, authSession.user.id);
|
||||
const [session, userTeams] = await Promise.all([
|
||||
getSessionById(id, authSession.user.id),
|
||||
getUserTeams(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=swot" className="hover:text-foreground">
|
||||
SWOT
|
||||
</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>
|
||||
<EditableTitle
|
||||
sessionId={session.id}
|
||||
initialTitle={session.title}
|
||||
isOwner={session.isOwner}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<CollaboratorDisplay
|
||||
collaborator={session.resolvedCollaborator}
|
||||
size="lg"
|
||||
showEmail
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="primary">{session.items.length} items</Badge>
|
||||
<Badge variant="success">{session.actions.length} actions</Badge>
|
||||
<span className="text-sm text-muted">
|
||||
{new Date(session.date).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<main className="mx-auto max-w-7xl px-4">
|
||||
<SessionPageHeader
|
||||
workshopType="swot"
|
||||
sessionId={session.id}
|
||||
sessionTitle={session.title}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
ownerUser={session.user}
|
||||
date={session.date}
|
||||
collaborator={session.resolvedCollaborator}
|
||||
badges={<>
|
||||
<Badge variant="primary">{session.items.length} items</Badge>
|
||||
<Badge variant="success">{session.actions.length} actions</Badge>
|
||||
</>}
|
||||
/>
|
||||
|
||||
{/* Live Session Wrapper */}
|
||||
<SessionLiveWrapper
|
||||
@@ -79,6 +52,7 @@ export default async function SessionPage({ params }: SessionPageProps) {
|
||||
shares={session.shares}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
userTeams={userTeams}
|
||||
>
|
||||
<SwotBoard sessionId={session.id} items={session.items} actions={session.actions} />
|
||||
</SessionLiveWrapper>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CardContent,
|
||||
Button,
|
||||
Input,
|
||||
ParticipantInput,
|
||||
} from '@/components/ui';
|
||||
|
||||
export default function NewSessionPage() {
|
||||
@@ -55,7 +56,7 @@ export default function NewSessionPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-8">
|
||||
<main className="mx-auto max-w-2xl px-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -82,12 +83,7 @@ export default function NewSessionPage() {
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Nom du collaborateur"
|
||||
name="collaborator"
|
||||
placeholder="Ex: Jean Dupont"
|
||||
required
|
||||
/>
|
||||
<ParticipantInput name="collaborator" required />
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
|
||||
@@ -1,24 +1,47 @@
|
||||
import { Suspense } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getSessionsByUserId } from '@/services/sessions';
|
||||
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
|
||||
import { Card, Button } from '@/components/ui';
|
||||
import {
|
||||
getSessionsByUserId,
|
||||
getTeamCollaboratorSessionsForAdmin as getTeamSwotSessions,
|
||||
} from '@/services/sessions';
|
||||
import {
|
||||
getMotivatorSessionsByUserId,
|
||||
getTeamCollaboratorSessionsForAdmin as getTeamMotivatorSessions,
|
||||
} from '@/services/moving-motivators';
|
||||
import {
|
||||
getYearReviewSessionsByUserId,
|
||||
getTeamCollaboratorSessionsForAdmin as getTeamYearReviewSessions,
|
||||
} from '@/services/year-review';
|
||||
import {
|
||||
getWeeklyCheckInSessionsByUserId,
|
||||
getTeamCollaboratorSessionsForAdmin as getTeamWeeklyCheckInSessions,
|
||||
} from '@/services/weekly-checkin';
|
||||
import {
|
||||
getWeatherSessionsByUserId,
|
||||
getTeamCollaboratorSessionsForAdmin as getTeamWeatherSessions,
|
||||
} from '@/services/weather';
|
||||
import {
|
||||
getGifMoodSessionsByUserId,
|
||||
getTeamCollaboratorSessionsForAdmin as getTeamGifMoodSessions,
|
||||
} from '@/services/gif-mood';
|
||||
import { Card, PageHeader } from '@/components/ui';
|
||||
import { withWorkshopType } from '@/lib/workshops';
|
||||
import { WorkshopTabs } from './WorkshopTabs';
|
||||
import { NewWorkshopDropdown } from './NewWorkshopDropdown';
|
||||
|
||||
function WorkshopTabsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Tabs skeleton */}
|
||||
<div className="flex gap-2 border-b border-border pb-4">
|
||||
<div className="flex gap-2 pb-2">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-10 w-32 bg-card animate-pulse rounded-lg" />
|
||||
<div key={i} className="h-9 w-28 bg-card animate-pulse rounded-full" />
|
||||
))}
|
||||
</div>
|
||||
{/* Cards skeleton */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="h-40 bg-card animate-pulse rounded-xl" />
|
||||
<div key={i} className="h-44 bg-card animate-pulse rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,53 +55,75 @@ export default async function SessionsPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch both SWOT and Moving Motivators sessions
|
||||
const [swotSessions, motivatorSessions] = await Promise.all([
|
||||
// Fetch sessions (owned + shared) and team collab sessions (for team admins, non-shared)
|
||||
const [
|
||||
swotSessions,
|
||||
motivatorSessions,
|
||||
yearReviewSessions,
|
||||
weeklyCheckInSessions,
|
||||
weatherSessions,
|
||||
gifMoodSessions,
|
||||
teamSwotSessions,
|
||||
teamMotivatorSessions,
|
||||
teamYearReviewSessions,
|
||||
teamWeeklyCheckInSessions,
|
||||
teamWeatherSessions,
|
||||
teamGifMoodSessions,
|
||||
] = await Promise.all([
|
||||
getSessionsByUserId(session.user.id),
|
||||
getMotivatorSessionsByUserId(session.user.id),
|
||||
getYearReviewSessionsByUserId(session.user.id),
|
||||
getWeeklyCheckInSessionsByUserId(session.user.id),
|
||||
getWeatherSessionsByUserId(session.user.id),
|
||||
getGifMoodSessionsByUserId(session.user.id),
|
||||
getTeamSwotSessions(session.user.id),
|
||||
getTeamMotivatorSessions(session.user.id),
|
||||
getTeamYearReviewSessions(session.user.id),
|
||||
getTeamWeeklyCheckInSessions(session.user.id),
|
||||
getTeamWeatherSessions(session.user.id),
|
||||
getTeamGifMoodSessions(session.user.id),
|
||||
]);
|
||||
|
||||
// Add type to each session for unified display
|
||||
const allSwotSessions = swotSessions.map((s) => ({
|
||||
...s,
|
||||
workshopType: 'swot' as const,
|
||||
}));
|
||||
// Add workshopType to each session for unified display
|
||||
const allSwotSessions = withWorkshopType(swotSessions, 'swot');
|
||||
const allMotivatorSessions = withWorkshopType(motivatorSessions, 'motivators');
|
||||
const allYearReviewSessions = withWorkshopType(yearReviewSessions, 'year-review');
|
||||
const allWeeklyCheckInSessions = withWorkshopType(weeklyCheckInSessions, 'weekly-checkin');
|
||||
const allWeatherSessions = withWorkshopType(weatherSessions, 'weather');
|
||||
const allGifMoodSessions = withWorkshopType(gifMoodSessions, 'gif-mood');
|
||||
|
||||
const allMotivatorSessions = motivatorSessions.map((s) => ({
|
||||
...s,
|
||||
workshopType: 'motivators' as const,
|
||||
}));
|
||||
const teamSwotWithType = withWorkshopType(teamSwotSessions, 'swot');
|
||||
const teamMotivatorWithType = withWorkshopType(teamMotivatorSessions, 'motivators');
|
||||
const teamYearReviewWithType = withWorkshopType(teamYearReviewSessions, 'year-review');
|
||||
const teamWeeklyCheckInWithType = withWorkshopType(teamWeeklyCheckInSessions, 'weekly-checkin');
|
||||
const teamWeatherWithType = withWorkshopType(teamWeatherSessions, 'weather');
|
||||
const teamGifMoodWithType = withWorkshopType(teamGifMoodSessions, 'gif-mood');
|
||||
|
||||
// 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,
|
||||
...allWeeklyCheckInSessions,
|
||||
...allWeatherSessions,
|
||||
...allGifMoodSessions,
|
||||
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
|
||||
const hasNoSessions = allSessions.length === 0;
|
||||
const totalCount = allSessions.length;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Mes Ateliers</h1>
|
||||
<p className="mt-1 text-muted">Tous vos ateliers en un seul endroit</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/sessions/new">
|
||||
<Button variant="outline">
|
||||
<span>📊</span>
|
||||
Nouveau SWOT
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/motivators/new">
|
||||
<Button>
|
||||
<span>🎯</span>
|
||||
Nouveau Motivators
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<main className="mx-auto max-w-7xl px-4">
|
||||
<PageHeader
|
||||
emoji="🗂️"
|
||||
title="Mes Ateliers"
|
||||
subtitle={
|
||||
totalCount > 0
|
||||
? `${totalCount} atelier${totalCount > 1 ? 's' : ''} · Tous vos ateliers en un seul endroit`
|
||||
: 'Tous vos ateliers en un seul endroit'
|
||||
}
|
||||
actions={<NewWorkshopDropdown />}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
{hasNoSessions ? (
|
||||
@@ -88,27 +133,32 @@ 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, un Year Review pour faire le bilan de l'année, ou un
|
||||
Weekly Check-in pour le suivi hebdomadaire.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link href="/sessions/new">
|
||||
<Button variant="outline">
|
||||
<span>📊</span>
|
||||
Créer un SWOT
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/motivators/new">
|
||||
<Button>
|
||||
<span>🎯</span>
|
||||
Créer un Moving Motivators
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex justify-center">
|
||||
<NewWorkshopDropdown />
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Suspense fallback={<WorkshopTabsSkeleton />}>
|
||||
<WorkshopTabs swotSessions={allSwotSessions} motivatorSessions={allMotivatorSessions} />
|
||||
<WorkshopTabs
|
||||
swotSessions={allSwotSessions}
|
||||
motivatorSessions={allMotivatorSessions}
|
||||
yearReviewSessions={allYearReviewSessions}
|
||||
weeklyCheckInSessions={allWeeklyCheckInSessions}
|
||||
weatherSessions={allWeatherSessions}
|
||||
gifMoodSessions={allGifMoodSessions}
|
||||
teamCollabSessions={[
|
||||
...teamSwotWithType,
|
||||
...teamMotivatorWithType,
|
||||
...teamYearReviewWithType,
|
||||
...teamWeeklyCheckInWithType,
|
||||
...teamWeatherWithType,
|
||||
...teamGifMoodWithType,
|
||||
]}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</main>
|
||||
|
||||
93
src/app/sessions/workshop-session-helpers.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type {
|
||||
AnySession, SortCol, ResolvedCollaborator,
|
||||
SwotSession, MotivatorSession, YearReviewSession,
|
||||
WeeklyCheckInSession, WeatherSession, GifMoodSession,
|
||||
} from './workshop-session-types';
|
||||
|
||||
export function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
|
||||
if (session.workshopType === 'swot') return (session as SwotSession).resolvedCollaborator;
|
||||
if (session.workshopType === 'year-review') return (session as YearReviewSession).resolvedParticipant;
|
||||
if (session.workshopType === 'weekly-checkin') return (session as WeeklyCheckInSession).resolvedParticipant;
|
||||
if (session.workshopType === 'weather') {
|
||||
const s = session as WeatherSession;
|
||||
return { raw: s.user.name || s.user.email, matchedUser: { id: s.user.id, email: s.user.email, name: s.user.name } };
|
||||
}
|
||||
if (session.workshopType === 'gif-mood') {
|
||||
const s = session as GifMoodSession;
|
||||
return { raw: s.user.name || s.user.email, matchedUser: { id: s.user.id, email: s.user.email, name: s.user.name } };
|
||||
}
|
||||
return (session as MotivatorSession).resolvedParticipant;
|
||||
}
|
||||
|
||||
export function getGroupKey(session: AnySession): string {
|
||||
const r = getResolvedCollaborator(session);
|
||||
return r.matchedUser ? `user:${r.matchedUser.id}` : `raw:${r.raw.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function groupByPerson(sessions: AnySession[]): Map<string, AnySession[]> {
|
||||
const grouped = new Map<string, AnySession[]>();
|
||||
sessions.forEach((s) => {
|
||||
const key = getGroupKey(s);
|
||||
const existing = grouped.get(key);
|
||||
if (existing) existing.push(s);
|
||||
else grouped.set(key, [s]);
|
||||
});
|
||||
grouped.forEach((arr) => arr.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()));
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
return new Date(date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
export function getMonthGroup(date: Date | string): string {
|
||||
return new Date(date).toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function getStatsText(session: AnySession): string {
|
||||
const isSwot = session.workshopType === 'swot';
|
||||
const isYearReview = session.workshopType === 'year-review';
|
||||
const isWeeklyCheckIn = session.workshopType === 'weekly-checkin';
|
||||
const isWeather = session.workshopType === 'weather';
|
||||
const isGifMood = session.workshopType === 'gif-mood';
|
||||
|
||||
if (isSwot) return `${(session as SwotSession)._count.items} items · ${(session as SwotSession)._count.actions} actions`;
|
||||
if (isYearReview) return `${(session as YearReviewSession)._count.items} items · ${(session as YearReviewSession).year}`;
|
||||
if (isWeeklyCheckIn) return `${(session as WeeklyCheckInSession)._count.items} items · ${formatDate((session as WeeklyCheckInSession).date)}`;
|
||||
if (isWeather) return `${(session as WeatherSession)._count.entries} membres · ${formatDate((session as WeatherSession).date)}`;
|
||||
if (isGifMood) return `${(session as GifMoodSession)._count.items} GIFs · ${formatDate((session as GifMoodSession).date)}`;
|
||||
return `${(session as MotivatorSession)._count.cards}/10 motivateurs`;
|
||||
}
|
||||
|
||||
function getStatsSortValue(session: AnySession): number {
|
||||
if (session.workshopType === 'swot') return (session as SwotSession)._count.items;
|
||||
if (session.workshopType === 'year-review') return (session as YearReviewSession)._count.items;
|
||||
if (session.workshopType === 'weekly-checkin') return (session as WeeklyCheckInSession)._count.items;
|
||||
if (session.workshopType === 'weather') return (session as WeatherSession)._count.entries;
|
||||
if (session.workshopType === 'gif-mood') return (session as GifMoodSession)._count.items;
|
||||
return (session as MotivatorSession)._count.cards;
|
||||
}
|
||||
|
||||
function getParticipantSortName(session: AnySession): string {
|
||||
const r = getResolvedCollaborator(session);
|
||||
return (r.matchedUser?.name || r.matchedUser?.email?.split('@')[0] || r.raw).toLowerCase();
|
||||
}
|
||||
|
||||
function getCreatorName(session: AnySession): string {
|
||||
return (session.user.name || session.user.email).toLowerCase();
|
||||
}
|
||||
|
||||
export function sortSessions(sessions: AnySession[], col: SortCol, dir: 'asc' | 'desc'): AnySession[] {
|
||||
return [...sessions].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (col) {
|
||||
case 'type': cmp = a.workshopType.localeCompare(b.workshopType); break;
|
||||
case 'titre': cmp = a.title.localeCompare(b.title, 'fr'); break;
|
||||
case 'createur': cmp = getCreatorName(a).localeCompare(getCreatorName(b), 'fr'); break;
|
||||
case 'participant': cmp = getParticipantSortName(a).localeCompare(getParticipantSortName(b), 'fr'); break;
|
||||
case 'stats': cmp = getStatsSortValue(a) - getStatsSortValue(b); break;
|
||||
case 'date': cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(); break;
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
94
src/app/sessions/workshop-session-types.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { WORKSHOPS } from '@/lib/workshops';
|
||||
import type { Share } from '@/lib/share-utils';
|
||||
|
||||
export type CardView = 'grid' | 'list' | 'table' | 'timeline';
|
||||
export type SortCol = 'type' | 'titre' | 'createur' | 'participant' | 'stats' | 'date';
|
||||
|
||||
// Colonnes tableau : type | titre | créateur | participant | stats | date
|
||||
export const TABLE_COLS = '160px 1fr 160px 160px 160px 76px';
|
||||
|
||||
export const SORT_COLUMNS: { key: SortCol; label: string }[] = [
|
||||
{ key: 'type', label: 'Type' },
|
||||
{ key: 'titre', label: 'Titre' },
|
||||
{ key: 'createur', label: 'Créateur' },
|
||||
{ key: 'participant', label: 'Participant' },
|
||||
{ key: 'stats', label: 'Stats' },
|
||||
{ key: 'date', label: 'Date' },
|
||||
];
|
||||
|
||||
export const TYPE_TABS = [
|
||||
{ value: 'all' as const, icon: '📋', label: 'Tous' },
|
||||
{ value: 'team' as const, icon: '🏢', label: 'Équipe' },
|
||||
...WORKSHOPS.map((w) => ({ value: w.id, icon: w.icon, label: w.labelShort })),
|
||||
];
|
||||
|
||||
export interface ResolvedCollaborator {
|
||||
raw: string;
|
||||
matchedUser: { id: string; email: string; name: string | null } | null;
|
||||
}
|
||||
|
||||
export interface SwotSession {
|
||||
id: string; title: string; collaborator: string;
|
||||
resolvedCollaborator: ResolvedCollaborator; updatedAt: Date;
|
||||
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||
user: { id: string; name: string | null; email: string };
|
||||
shares: Share[]; _count: { items: number; actions: number };
|
||||
workshopType: 'swot'; isTeamCollab?: true; canEdit?: boolean;
|
||||
}
|
||||
|
||||
export interface MotivatorSession {
|
||||
id: string; title: string; participant: string;
|
||||
resolvedParticipant: ResolvedCollaborator; updatedAt: Date;
|
||||
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||
user: { id: string; name: string | null; email: string };
|
||||
shares: Share[]; _count: { cards: number };
|
||||
workshopType: 'motivators'; isTeamCollab?: true; canEdit?: boolean;
|
||||
}
|
||||
|
||||
export 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'; isTeamCollab?: true; canEdit?: boolean;
|
||||
}
|
||||
|
||||
export interface WeeklyCheckInSession {
|
||||
id: string; title: string; participant: string;
|
||||
resolvedParticipant: ResolvedCollaborator; date: Date; updatedAt: Date;
|
||||
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||
user: { id: string; name: string | null; email: string };
|
||||
shares: Share[]; _count: { items: number };
|
||||
workshopType: 'weekly-checkin'; isTeamCollab?: true; canEdit?: boolean;
|
||||
}
|
||||
|
||||
export interface WeatherSession {
|
||||
id: string; title: string; date: Date; updatedAt: Date;
|
||||
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||
user: { id: string; name: string | null; email: string };
|
||||
shares: Share[]; _count: { entries: number };
|
||||
workshopType: 'weather'; isTeamCollab?: true; canEdit?: boolean;
|
||||
}
|
||||
|
||||
export interface GifMoodSession {
|
||||
id: string; title: string; date: Date; updatedAt: Date;
|
||||
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||
user: { id: string; name: string | null; email: string };
|
||||
shares: Share[]; _count: { items: number };
|
||||
workshopType: 'gif-mood'; isTeamCollab?: true; canEdit?: boolean;
|
||||
}
|
||||
|
||||
export type AnySession =
|
||||
| SwotSession | MotivatorSession | YearReviewSession
|
||||
| WeeklyCheckInSession | WeatherSession | GifMoodSession;
|
||||
|
||||
export interface WorkshopTabsProps {
|
||||
swotSessions: SwotSession[];
|
||||
motivatorSessions: MotivatorSession[];
|
||||
yearReviewSessions: YearReviewSession[];
|
||||
weeklyCheckInSessions: WeeklyCheckInSession[];
|
||||
weatherSessions: WeatherSession[];
|
||||
gifMoodSessions: GifMoodSession[];
|
||||
teamCollabSessions?: (AnySession & { isTeamCollab?: true })[];
|
||||
}
|
||||
168
src/app/teams/[id]/okrs/[okrId]/edit/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { OKRForm } from '@/components/okrs';
|
||||
import { Card } from '@/components/ui';
|
||||
import type { CreateOKRInput, CreateKeyResultInput, TeamMember, OKR, KeyResult } from '@/lib/types';
|
||||
|
||||
type OKRWithTeamMember = OKR & {
|
||||
teamMember: {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
};
|
||||
userId: string;
|
||||
team: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export default function EditOKRPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const teamId = params.id as string;
|
||||
const okrId = params.okrId as string;
|
||||
const [okr, setOkr] = useState<OKRWithTeamMember | null>(null);
|
||||
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch OKR and team members in parallel
|
||||
Promise.all([
|
||||
fetch(`/api/okrs/${okrId}`).then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error('OKR not found');
|
||||
}
|
||||
return res.json();
|
||||
}),
|
||||
fetch(`/api/teams/${teamId}`).then((res) => res.json()),
|
||||
])
|
||||
.then(([okrData, teamData]) => {
|
||||
setOkr(okrData);
|
||||
setTeamMembers(teamData.members || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching data:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [okrId, teamId]);
|
||||
|
||||
type KeyResultUpdate = {
|
||||
id: string;
|
||||
title?: string;
|
||||
targetValue?: number;
|
||||
unit?: string;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
const handleSubmit = async (
|
||||
data: CreateOKRInput & {
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
keyResultsUpdates?: {
|
||||
create?: CreateKeyResultInput[];
|
||||
update?: KeyResultUpdate[];
|
||||
delete?: string[];
|
||||
};
|
||||
}
|
||||
) => {
|
||||
// Convert to UpdateOKRInput format
|
||||
const updateData = {
|
||||
objective: data.objective,
|
||||
description: data.description || undefined,
|
||||
period: data.period,
|
||||
startDate: typeof data.startDate === 'string' ? new Date(data.startDate) : data.startDate,
|
||||
endDate: typeof data.endDate === 'string' ? new Date(data.endDate) : data.endDate,
|
||||
};
|
||||
|
||||
const payload: {
|
||||
objective: string;
|
||||
description?: string;
|
||||
period: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
keyResultsUpdates?: {
|
||||
create?: CreateKeyResultInput[];
|
||||
update?: KeyResultUpdate[];
|
||||
delete?: string[];
|
||||
};
|
||||
} = {
|
||||
...updateData,
|
||||
startDate: updateData.startDate.toISOString(),
|
||||
endDate: updateData.endDate.toISOString(),
|
||||
};
|
||||
|
||||
// Add Key Results updates if in edit mode
|
||||
if (data.keyResultsUpdates) {
|
||||
payload.keyResultsUpdates = data.keyResultsUpdates;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/okrs/${okrId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Erreur lors de la mise à jour de l'OKR');
|
||||
}
|
||||
|
||||
router.push(`/teams/${teamId}/okrs/${okrId}`);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4">
|
||||
<div className="text-center">Chargement...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!okr) {
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4">
|
||||
<div className="text-center">OKR non trouvé</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare initial data for the form
|
||||
const initialData: Partial<CreateOKRInput> & { keyResults?: KeyResult[] } = {
|
||||
teamMemberId: okr.teamMemberId,
|
||||
objective: okr.objective,
|
||||
description: okr.description || undefined,
|
||||
period: okr.period,
|
||||
startDate: okr.startDate,
|
||||
endDate: okr.endDate,
|
||||
keyResults: okr.keyResults || [],
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4">
|
||||
<div className="mb-6">
|
||||
<Link href={`/teams/${teamId}/okrs/${okrId}`} className="text-muted hover:text-foreground">
|
||||
← Retour à l'OKR
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">Modifier l'OKR</h1>
|
||||
<OKRForm
|
||||
teamMembers={teamMembers}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => router.push(`/teams/${teamId}/okrs/${okrId}`)}
|
||||
initialData={initialData}
|
||||
/>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
276
src/app/teams/[id]/okrs/[okrId]/page.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { KeyResultItem } from '@/components/okrs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
|
||||
import { Button } from '@/components/ui';
|
||||
import { Badge } from '@/components/ui';
|
||||
import { getGravatarUrl } from '@/lib/gravatar';
|
||||
import type { OKR, OKRStatus } from '@/lib/types';
|
||||
import { OKR_STATUS_LABELS } from '@/lib/types';
|
||||
|
||||
// Helper function for OKR status colors
|
||||
function getOKRStatusColor(status: OKRStatus): { bg: string; color: string } {
|
||||
switch (status) {
|
||||
case 'NOT_STARTED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #6b7280 15%, transparent)', // gray-500
|
||||
color: '#6b7280',
|
||||
};
|
||||
case 'IN_PROGRESS':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #3b82f6 15%, transparent)', // blue-500
|
||||
color: '#3b82f6',
|
||||
};
|
||||
case 'COMPLETED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #10b981 15%, transparent)', // green-500
|
||||
color: '#10b981',
|
||||
};
|
||||
case 'CANCELLED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #ef4444 15%, transparent)', // red-500
|
||||
color: '#ef4444',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #6b7280 15%, transparent)',
|
||||
color: '#6b7280',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type OKRWithTeamMember = OKR & {
|
||||
teamMember: {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
};
|
||||
userId: string;
|
||||
team: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
permissions?: {
|
||||
isAdmin: boolean;
|
||||
isConcernedMember: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export default function OKRDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const teamId = params.id as string;
|
||||
const okrId = params.okrId as string;
|
||||
const [okr, setOkr] = useState<OKRWithTeamMember | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch OKR
|
||||
fetch(`/api/okrs/${okrId}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error('OKR not found');
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setOkr(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching OKR:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [okrId]);
|
||||
|
||||
const handleKeyResultUpdate = () => {
|
||||
// Refresh OKR data
|
||||
fetch(`/api/okrs/${okrId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setOkr(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error refreshing OKR:', error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer cet OKR ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/okrs/${okrId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Erreur lors de la suppression');
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/teams/${teamId}`);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4">
|
||||
<div className="text-center">Chargement...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!okr) {
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4">
|
||||
<div className="text-center">OKR non trouvé</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const progress = okr.progress || 0;
|
||||
const progressColor =
|
||||
progress >= 75 ? 'var(--success)' : progress >= 25 ? 'var(--accent)' : 'var(--destructive)';
|
||||
const canEdit = okr.permissions?.canEdit ?? false;
|
||||
const canDelete = okr.permissions?.canDelete ?? false;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4">
|
||||
<div className="mb-6">
|
||||
<Link href={`/teams/${teamId}`} className="text-muted hover:text-foreground">
|
||||
← Retour à l'équipe
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-2xl flex items-center gap-2">
|
||||
<span className="text-2xl">🎯</span>
|
||||
{okr.objective}
|
||||
</CardTitle>
|
||||
{okr.description && <p className="mt-2 text-muted">{okr.description}</p>}
|
||||
{okr.teamMember && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getGravatarUrl(okr.teamMember.user.email, 96)}
|
||||
alt={okr.teamMember.user.name || okr.teamMember.user.email}
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<span className="text-sm text-muted">
|
||||
{okr.teamMember.user.name || okr.teamMember.user.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
|
||||
color: 'var(--purple)',
|
||||
}}
|
||||
>
|
||||
{okr.period}
|
||||
</Badge>
|
||||
<Badge style={getOKRStatusColor(okr.status)}>{OKR_STATUS_LABELS[okr.status]}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 flex items-center justify-between text-sm">
|
||||
<span className="text-muted">Progression globale</span>
|
||||
<span className="font-medium" style={{ color: progressColor }}>
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 w-full overflow-hidden rounded-full bg-card-column">
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
backgroundColor: progressColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="flex gap-4 text-sm text-muted">
|
||||
<div>
|
||||
<strong>Début:</strong> {new Date(okr.startDate).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Fin:</strong> {new Date(okr.endDate).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{(canEdit || canDelete) && (
|
||||
<div className="mt-4 flex gap-2">
|
||||
{canEdit && (
|
||||
<Button
|
||||
onClick={() => router.push(`/teams/${teamId}/okrs/${okrId}/edit`)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Éditer
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
style={{
|
||||
color: 'var(--destructive)',
|
||||
borderColor: 'var(--destructive)',
|
||||
}}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Key Results */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-foreground mb-4">
|
||||
Key Results ({okr.keyResults?.length || 0})
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{okr.keyResults && okr.keyResults.length > 0 ? (
|
||||
okr.keyResults.map((kr) => (
|
||||
<KeyResultItem
|
||||
key={kr.id}
|
||||
keyResult={kr}
|
||||
okrId={okrId}
|
||||
canEdit={canEdit}
|
||||
onUpdate={handleKeyResultUpdate}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Card className="p-8 text-center text-muted">Aucun Key Result défini</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
81
src/app/teams/[id]/okrs/new/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { OKRForm } from '@/components/okrs';
|
||||
import { Card } from '@/components/ui';
|
||||
import type { CreateOKRInput, TeamMember } from '@/lib/types';
|
||||
|
||||
export default function NewOKRPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const teamId = params.id as string;
|
||||
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch team members
|
||||
fetch(`/api/teams/${teamId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setTeamMembers(data.members || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching team:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [teamId]);
|
||||
|
||||
const handleSubmit = async (data: CreateOKRInput) => {
|
||||
// Ensure dates are properly serialized
|
||||
const payload = {
|
||||
...data,
|
||||
startDate: typeof data.startDate === 'string' ? data.startDate : data.startDate.toISOString(),
|
||||
endDate: typeof data.endDate === 'string' ? data.endDate : data.endDate.toISOString(),
|
||||
};
|
||||
|
||||
const response = await fetch('/api/okrs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Erreur lors de la création de l'OKR');
|
||||
}
|
||||
|
||||
router.push(`/teams/${teamId}`);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4">
|
||||
<div className="text-center">Chargement...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4">
|
||||
<div className="mb-6">
|
||||
<Link href={`/teams/${teamId}`} className="text-muted hover:text-foreground">
|
||||
← Retour à l'équipe
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">Créer un OKR</h1>
|
||||
<OKRForm
|
||||
teamMembers={teamMembers}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => router.push(`/teams/${teamId}`)}
|
||||
/>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
78
src/app/teams/[id]/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { getTeam, isTeamAdmin } from '@/services/teams';
|
||||
import { getTeamOKRs } from '@/services/okrs';
|
||||
import { TeamDetailClient } from '@/components/teams/TeamDetailClient';
|
||||
import { DeleteTeamButton } from '@/components/teams/DeleteTeamButton';
|
||||
import { OKRsList } from '@/components/okrs';
|
||||
import { Button, Card, PageHeader } from '@/components/ui';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { TeamMember } from '@/lib/types';
|
||||
|
||||
interface TeamDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function TeamDetailPage({ params }: TeamDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const team = await getTeam(id);
|
||||
|
||||
if (!team) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Check if user is a member
|
||||
const isMember = team.members.some((m) => m.userId === session.user?.id);
|
||||
if (!isMember) {
|
||||
redirect('/teams');
|
||||
}
|
||||
|
||||
const isAdmin = await isTeamAdmin(id, session.user.id);
|
||||
const okrsData = await getTeamOKRs(id);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4">
|
||||
<div className="mb-2">
|
||||
<Link href="/teams" className="text-sm text-muted hover:text-foreground">
|
||||
← Retour aux équipes
|
||||
</Link>
|
||||
</div>
|
||||
<PageHeader
|
||||
emoji="👥"
|
||||
title={team.name}
|
||||
subtitle={team.description ?? undefined}
|
||||
actions={
|
||||
isAdmin ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/teams/${id}/okrs/new`}>
|
||||
<Button className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent">
|
||||
Définir un OKR
|
||||
</Button>
|
||||
</Link>
|
||||
<DeleteTeamButton teamId={id} teamName={team.name} />
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Members Section */}
|
||||
<Card className="mb-8 p-6">
|
||||
<TeamDetailClient
|
||||
members={team.members as unknown as TeamMember[]}
|
||||
teamId={id}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* OKRs Section */}
|
||||
<OKRsList okrsData={okrsData} teamId={id} isAdmin={isAdmin} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
91
src/app/teams/new/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Input } from '@/components/ui';
|
||||
import { Textarea } from '@/components/ui';
|
||||
import { Button } from '@/components/ui';
|
||||
import { Card } from '@/components/ui';
|
||||
|
||||
export default function NewTeamPage() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
alert("Le nom de l'équipe est requis");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const response = await fetch('/api/teams', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim(), description: description.trim() || null }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(error.error || "Erreur lors de la création de l'équipe");
|
||||
return;
|
||||
}
|
||||
|
||||
const team = await response.json();
|
||||
router.push(`/teams/${team.id}`);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error('Error creating team:', error);
|
||||
alert("Erreur lors de la création de l'équipe");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4">
|
||||
<div className="mb-6">
|
||||
<Link href="/teams" className="text-muted hover:text-foreground">
|
||||
← Retour aux équipes
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">Créer une équipe</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Nom de l'équipe *"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Ex: Équipe Produit"
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Description de l'équipe..."
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" onClick={() => router.back()} variant="outline">
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"
|
||||
>
|
||||
{submitting ? 'Création...' : "Créer l'équipe"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
55
src/app/teams/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { TeamCard } from '@/components/teams';
|
||||
import { Button, PageHeader } from '@/components/ui';
|
||||
import { getUserTeams } from '@/services/teams';
|
||||
|
||||
export default async function TeamsPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const teams = await getUserTeams(session.user.id);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4">
|
||||
<PageHeader
|
||||
emoji="👥"
|
||||
title="Équipes"
|
||||
subtitle={`${teams.length} équipe${teams.length !== 1 ? 's' : ''} · Collaborez et définissez vos OKRs`}
|
||||
actions={
|
||||
<Link href="/teams/new">
|
||||
<Button className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent">
|
||||
Créer une équipe
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Teams Grid */}
|
||||
{teams.length > 0 ? (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{teams.map((team: (typeof teams)[number]) => (
|
||||
<TeamCard key={team.id} team={team as Parameters<typeof TeamCard>[0]['team']} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-border bg-card py-16">
|
||||
<div className="text-4xl">👥</div>
|
||||
<div className="mt-4 text-lg font-medium text-foreground">Aucune équipe</div>
|
||||
<div className="mt-1 text-sm text-muted">
|
||||
Créez votre première équipe pour commencer à définir des OKRs
|
||||
</div>
|
||||
<Link href="/teams/new" className="mt-6">
|
||||
<Button className="!bg-[var(--purple)] !text-white hover:!bg-[var(--purple)]/90">
|
||||
Créer une équipe
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getAllUsersWithStats } from '@/services/auth';
|
||||
import { getGravatarUrl } from '@/lib/gravatar';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
@@ -33,15 +34,12 @@ export default async function UsersPage() {
|
||||
const avgSessionsPerUser = users.length > 0 ? totalSessions / users.length : 0;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-foreground">Utilisateurs</h1>
|
||||
<p className="mt-1 text-muted">
|
||||
{users.length} utilisateur{users.length > 1 ? 's' : ''} inscrit
|
||||
{users.length > 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<main className="mx-auto max-w-6xl px-4">
|
||||
<PageHeader
|
||||
emoji="🧑💻"
|
||||
title="Utilisateurs"
|
||||
subtitle={`${users.length} utilisateur${users.length > 1 ? 's' : ''} inscrit${users.length > 1 ? 's' : ''} · Vue d'ensemble de la communauté`}
|
||||
/>
|
||||
|
||||
{/* Global Stats */}
|
||||
<div className="mb-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
|
||||
90
src/app/weather/[id]/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { auth } from '@/lib/auth';
|
||||
import {
|
||||
getWeatherSessionById,
|
||||
getPreviousWeatherEntriesForUsers,
|
||||
getWeatherSessionsHistory,
|
||||
} from '@/services/weather';
|
||||
import { getUserTeams } from '@/services/teams';
|
||||
import {
|
||||
WeatherBoard,
|
||||
WeatherLiveWrapper,
|
||||
WeatherInfoPanel,
|
||||
WeatherAverageBar,
|
||||
WeatherTrendChart,
|
||||
} from '@/components/weather';
|
||||
import { Badge, SessionPageHeader } from '@/components/ui';
|
||||
|
||||
interface WeatherSessionPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function WeatherSessionPage({ params }: WeatherSessionPageProps) {
|
||||
const { id } = await params;
|
||||
const authSession = await auth();
|
||||
|
||||
if (!authSession?.user?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await getWeatherSessionById(id, authSession.user.id);
|
||||
|
||||
if (!session) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const allUserIds = [session.user.id, ...session.shares.map((s: { userId: string }) => s.userId)];
|
||||
|
||||
const [previousEntries, userTeams, history] = await Promise.all([
|
||||
getPreviousWeatherEntriesForUsers(session.id, session.date, allUserIds),
|
||||
getUserTeams(authSession.user.id),
|
||||
getWeatherSessionsHistory(authSession.user.id),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4">
|
||||
<SessionPageHeader
|
||||
workshopType="weather"
|
||||
sessionId={session.id}
|
||||
sessionTitle={session.title}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
ownerUser={session.user}
|
||||
date={session.date}
|
||||
badges={<Badge variant="primary">{session.entries.length} membres</Badge>}
|
||||
/>
|
||||
|
||||
{/* Info sur les catégories */}
|
||||
<WeatherInfoPanel />
|
||||
|
||||
{/* Évolution dans le temps */}
|
||||
<WeatherTrendChart data={history} currentSessionId={session.id} />
|
||||
|
||||
{/* Live Wrapper + Board */}
|
||||
<WeatherLiveWrapper
|
||||
sessionId={session.id}
|
||||
sessionTitle={session.title}
|
||||
currentUserId={authSession.user.id}
|
||||
shares={session.shares}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
userTeams={userTeams}
|
||||
>
|
||||
<WeatherAverageBar entries={session.entries} />
|
||||
<WeatherBoard
|
||||
sessionId={session.id}
|
||||
currentUserId={authSession.user.id}
|
||||
entries={session.entries}
|
||||
shares={session.shares}
|
||||
owner={{
|
||||
id: session.user.id,
|
||||
name: session.user.name ?? null,
|
||||
email: session.user.email ?? '',
|
||||
}}
|
||||
canEdit={session.canEdit}
|
||||
previousEntries={Object.fromEntries(previousEntries)}
|
||||
/>
|
||||
</WeatherLiveWrapper>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
153
src/app/weather/new/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Button,
|
||||
Input,
|
||||
} from '@/components/ui';
|
||||
import { createWeatherSession } from '@/actions/weather';
|
||||
import { getWeekYearLabel } from '@/lib/date-utils';
|
||||
|
||||
export default function NewWeatherPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [title, setTitle] = useState(() =>
|
||||
getWeekYearLabel(new Date(new Date().toISOString().split('T')[0]))
|
||||
);
|
||||
const [isTitleManuallyEdited, setIsTitleManuallyEdited] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const date = selectedDate ? new Date(selectedDate) : undefined;
|
||||
|
||||
if (!title) {
|
||||
setError('Veuillez remplir le titre');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await createWeatherSession({ title, date });
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Une erreur est survenue');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/weather/${result.data?.id}`);
|
||||
}
|
||||
|
||||
function handleDateChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const newDate = e.target.value;
|
||||
setSelectedDate(newDate);
|
||||
// Only update title if user hasn't manually modified it
|
||||
if (!isTitleManuallyEdited) {
|
||||
setTitle(getWeekYearLabel(new Date(newDate)));
|
||||
}
|
||||
}
|
||||
|
||||
function handleTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
setTitle(e.target.value);
|
||||
setIsTitleManuallyEdited(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span>🌤️</span>
|
||||
Nouvelle Météo
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Créez une météo personnelle pour faire le point sur 4 axes clés et partagez-la avec
|
||||
votre équipe
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Titre de la météo"
|
||||
name="title"
|
||||
placeholder="Ex: Météo S05 - 2026"
|
||||
value={title}
|
||||
onChange={handleTitleChange}
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label htmlFor="date" className="block text-sm font-medium text-foreground mb-1">
|
||||
Date de la météo
|
||||
</label>
|
||||
<input
|
||||
id="date"
|
||||
name="date"
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={handleDateChange}
|
||||
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>Performance</strong> : Comment évaluez-vous votre performance personnelle
|
||||
?
|
||||
</li>
|
||||
<li>
|
||||
<strong>Moral</strong> : Quel est votre moral actuel ?
|
||||
</li>
|
||||
<li>
|
||||
<strong>Flux</strong> : Comment se passe votre flux de travail personnel ?
|
||||
</li>
|
||||
<li>
|
||||
<strong>Création de valeur</strong> : Comment évaluez-vous votre création de
|
||||
valeur ?
|
||||
</li>
|
||||
</ol>
|
||||
<p className="text-sm text-muted mt-2">
|
||||
💡 <strong>Astuce</strong> : Partagez votre météo avec votre équipe pour qu'ils
|
||||
puissent voir votre état. Chaque membre peut créer sa propre météo et la partager !
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
disabled={loading}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" loading={loading} className="flex-1">
|
||||
Créer la météo
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
93
src/app/weekly-checkin/[id]/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getWeeklyCheckInSessionById } from '@/services/weekly-checkin';
|
||||
import { getUserTeams } from '@/services/teams';
|
||||
import type { ResolvedCollaborator } from '@/services/auth';
|
||||
import { getUserOKRsForPeriod } from '@/services/okrs';
|
||||
import { getCurrentQuarterPeriod } from '@/lib/okr-utils';
|
||||
import { WeeklyCheckInBoard, WeeklyCheckInLiveWrapper } from '@/components/weekly-checkin';
|
||||
import { CurrentQuarterOKRs } from '@/components/weekly-checkin/CurrentQuarterOKRs';
|
||||
import { Badge, SessionPageHeader } from '@/components/ui';
|
||||
|
||||
interface WeeklyCheckInSessionPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckInSessionPageProps) {
|
||||
const { id } = await params;
|
||||
const authSession = await auth();
|
||||
|
||||
if (!authSession?.user?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [session, userTeams] = await Promise.all([
|
||||
getWeeklyCheckInSessionById(id, authSession.user.id),
|
||||
getUserTeams(authSession.user.id),
|
||||
]);
|
||||
|
||||
if (!session) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Get current quarter OKRs for the participant (NOT the creator)
|
||||
// We use session.resolvedParticipant.matchedUser.id which is the participant's user ID
|
||||
const currentQuarterPeriod = getCurrentQuarterPeriod(session.date);
|
||||
let currentQuarterOKRs: Awaited<ReturnType<typeof getUserOKRsForPeriod>> = [];
|
||||
|
||||
// Only fetch OKRs if the participant is a recognized user (has matchedUser)
|
||||
const resolvedParticipant = session.resolvedParticipant as ResolvedCollaborator;
|
||||
if (resolvedParticipant.matchedUser) {
|
||||
// Use participant's ID, not session.userId (which is the creator's ID)
|
||||
const participantUserId = resolvedParticipant.matchedUser.id;
|
||||
currentQuarterOKRs = await getUserOKRsForPeriod(participantUserId, currentQuarterPeriod);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4">
|
||||
<SessionPageHeader
|
||||
workshopType="weekly-checkin"
|
||||
sessionId={session.id}
|
||||
sessionTitle={session.title}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
ownerUser={session.user}
|
||||
date={session.date}
|
||||
collaborator={resolvedParticipant}
|
||||
badges={<Badge variant="primary">{session.items.length} items</Badge>}
|
||||
/>
|
||||
|
||||
{/* Current Quarter OKRs - editable by participant or team admin */}
|
||||
{currentQuarterOKRs.length > 0 && (
|
||||
<CurrentQuarterOKRs
|
||||
okrs={currentQuarterOKRs}
|
||||
period={currentQuarterPeriod}
|
||||
canEdit={
|
||||
(!!resolvedParticipant.matchedUser &&
|
||||
authSession.user.id === resolvedParticipant.matchedUser.id) ||
|
||||
(() => {
|
||||
const participantTeamIds = new Set(
|
||||
currentQuarterOKRs.map((okr) => okr.team?.id).filter(Boolean) as string[]
|
||||
);
|
||||
const adminTeamIds = userTeams.filter((t) => t.userRole === 'ADMIN').map((t) => t.id);
|
||||
return adminTeamIds.some((tid) => participantTeamIds.has(tid));
|
||||
})()
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Live Wrapper + Board */}
|
||||
<WeeklyCheckInLiveWrapper
|
||||
sessionId={session.id}
|
||||
sessionTitle={session.title}
|
||||
currentUserId={authSession.user.id}
|
||||
shares={session.shares}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
userTeams={userTeams}
|
||||
>
|
||||
<WeeklyCheckInBoard sessionId={session.id} items={session.items} />
|
||||
</WeeklyCheckInLiveWrapper>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
160
src/app/weekly-checkin/new/page.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Button,
|
||||
Input,
|
||||
ParticipantInput,
|
||||
} from '@/components/ui';
|
||||
import { createWeeklyCheckInSession } from '@/actions/weekly-checkin';
|
||||
import { getWeekYearLabel } from '@/lib/date-utils';
|
||||
|
||||
export default function NewWeeklyCheckInPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [title, setTitle] = useState(() =>
|
||||
getWeekYearLabel(new Date(new Date().toISOString().split('T')[0]))
|
||||
);
|
||||
const [isTitleManuallyEdited, setIsTitleManuallyEdited] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const participant = formData.get('participant') as string;
|
||||
const date = selectedDate ? new Date(selectedDate) : undefined;
|
||||
|
||||
if (!title || !participant) {
|
||||
setError('Veuillez remplir tous les champs');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await createWeeklyCheckInSession({ title, participant, date });
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Une erreur est survenue');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/weekly-checkin/${result.data?.id}`);
|
||||
}
|
||||
|
||||
function handleDateChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const newDate = e.target.value;
|
||||
setSelectedDate(newDate);
|
||||
// Only update title if user hasn't manually modified it
|
||||
if (!isTitleManuallyEdited) {
|
||||
setTitle(getWeekYearLabel(new Date(newDate)));
|
||||
}
|
||||
}
|
||||
|
||||
function handleTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
setTitle(e.target.value);
|
||||
setIsTitleManuallyEdited(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span>📝</span>
|
||||
Nouveau Check-in Hebdomadaire
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Créez un check-in hebdomadaire pour faire le point sur la semaine avec votre
|
||||
collaborateur
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Titre du check-in"
|
||||
name="title"
|
||||
placeholder="Ex: Check-in semaine du 15 janvier"
|
||||
value={title}
|
||||
onChange={handleTitleChange}
|
||||
required
|
||||
/>
|
||||
|
||||
<ParticipantInput name="participant" required />
|
||||
|
||||
<div>
|
||||
<label htmlFor="date" className="block text-sm font-medium text-foreground mb-1">
|
||||
Date du check-in
|
||||
</label>
|
||||
<input
|
||||
id="date"
|
||||
name="date"
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={handleDateChange}
|
||||
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>Ce qui s'est bien passé</strong> : Notez les réussites et points
|
||||
positifs de la semaine
|
||||
</li>
|
||||
<li>
|
||||
<strong>Ce qui s'est mal passé</strong> : Identifiez les difficultés et
|
||||
points d'amélioration
|
||||
</li>
|
||||
<li>
|
||||
<strong>Enjeux du moment</strong> : Décrivez sur quoi vous vous concentrez
|
||||
actuellement
|
||||
</li>
|
||||
<li>
|
||||
<strong>Prochains enjeux</strong> : Définissez ce sur quoi vous allez vous
|
||||
concentrer prochainement
|
||||
</li>
|
||||
</ol>
|
||||
<p className="text-sm text-muted mt-2">
|
||||
💡 <strong>Astuce</strong> : Ajoutez une émotion à chaque item pour mieux exprimer
|
||||
votre ressenti (fierté, joie, frustration, etc.)
|
||||
</p>
|
||||
</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 check-in
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
61
src/app/year-review/[id]/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getYearReviewSessionById } from '@/services/year-review';
|
||||
import { getUserTeams } from '@/services/teams';
|
||||
import type { ResolvedCollaborator } from '@/services/auth';
|
||||
import { YearReviewBoard, YearReviewLiveWrapper } from '@/components/year-review';
|
||||
import { Badge, SessionPageHeader } 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, userTeams] = await Promise.all([
|
||||
getYearReviewSessionById(id, authSession.user.id),
|
||||
getUserTeams(authSession.user.id),
|
||||
]);
|
||||
|
||||
if (!session) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4">
|
||||
<SessionPageHeader
|
||||
workshopType="year-review"
|
||||
sessionId={session.id}
|
||||
sessionTitle={session.title}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
ownerUser={session.user}
|
||||
date={session.updatedAt}
|
||||
collaborator={session.resolvedParticipant as ResolvedCollaborator}
|
||||
badges={<>
|
||||
<Badge variant="primary">{session.items.length} items</Badge>
|
||||
<Badge variant="default">Année {session.year}</Badge>
|
||||
</>}
|
||||
/>
|
||||
|
||||
{/* Live Wrapper + Board */}
|
||||
<YearReviewLiveWrapper
|
||||
sessionId={session.id}
|
||||
sessionTitle={session.title}
|
||||
currentUserId={authSession.user.id}
|
||||
shares={session.shares}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
userTeams={userTeams}
|
||||
>
|
||||
<YearReviewBoard sessionId={session.id} items={session.items} />
|
||||
</YearReviewLiveWrapper>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
137
src/app/year-review/new/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Button,
|
||||
Input,
|
||||
ParticipantInput,
|
||||
} 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">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span>📅</span>
|
||||
Nouveau Bilan Annuel
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Créez un bilan de l'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
|
||||
/>
|
||||
|
||||
<ParticipantInput name="participant" 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'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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import { ThemeProvider } from '@/contexts/ThemeContext';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface ProvidersProps {
|
||||
@@ -13,10 +12,7 @@ export function Providers({ children }: ProvidersProps) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ThemeProvider>
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
{children}
|
||||
</div>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
|
||||
106
src/components/collaboration/BaseSessionLiveWrapper.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useLive, type LiveEvent } from '@/hooks/useLive';
|
||||
import { CollaborationToolbar } from './CollaborationToolbar';
|
||||
import { ShareModal } from './ShareModal';
|
||||
import type { ShareRole } from '@prisma/client';
|
||||
import type { TeamWithMembers, Share } from '@/lib/share-utils';
|
||||
|
||||
export type LiveApiPath = 'sessions' | 'motivators' | 'weather' | 'year-review' | 'weekly-checkin' | 'gif-mood';
|
||||
|
||||
interface ShareModalConfig {
|
||||
title: string;
|
||||
sessionSubtitle: string;
|
||||
helpText: React.ReactNode;
|
||||
}
|
||||
|
||||
interface BaseSessionLiveWrapperConfig {
|
||||
apiPath: LiveApiPath;
|
||||
shareModal: ShareModalConfig;
|
||||
onShareWithEmail: (
|
||||
email: string,
|
||||
role: ShareRole
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
onRemoveShare: (userId: string) => Promise<unknown>;
|
||||
onShareWithTeam?: (
|
||||
teamId: string,
|
||||
role: ShareRole
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
interface BaseSessionLiveWrapperProps {
|
||||
sessionId: string;
|
||||
sessionTitle: string;
|
||||
currentUserId: string;
|
||||
shares: Share[];
|
||||
isOwner: boolean;
|
||||
canEdit: boolean;
|
||||
userTeams?: TeamWithMembers[];
|
||||
children: React.ReactNode;
|
||||
config: BaseSessionLiveWrapperConfig;
|
||||
}
|
||||
|
||||
export function BaseSessionLiveWrapper({
|
||||
sessionId,
|
||||
sessionTitle,
|
||||
currentUserId,
|
||||
shares,
|
||||
isOwner,
|
||||
canEdit,
|
||||
userTeams = [],
|
||||
children,
|
||||
config,
|
||||
}: BaseSessionLiveWrapperProps) {
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
|
||||
|
||||
const handleEvent = useCallback((event: LiveEvent) => {
|
||||
// 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 } = useLive({
|
||||
sessionId,
|
||||
apiPath: config.apiPath,
|
||||
currentUserId,
|
||||
onEvent: handleEvent,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<CollaborationToolbar
|
||||
isConnected={isConnected}
|
||||
error={error}
|
||||
lastEventUser={lastEventUser}
|
||||
canEdit={canEdit}
|
||||
shares={shares}
|
||||
onShareClick={() => setShareModalOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
|
||||
|
||||
{/* Share Modal */}
|
||||
<ShareModal
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
title={config.shareModal.title}
|
||||
sessionSubtitle={config.shareModal.sessionSubtitle}
|
||||
sessionTitle={sessionTitle}
|
||||
shares={shares}
|
||||
isOwner={isOwner}
|
||||
userTeams={userTeams}
|
||||
currentUserId={currentUserId}
|
||||
onShareWithEmail={config.onShareWithEmail}
|
||||
onShareWithTeam={config.onShareWithTeam}
|
||||
onRemoveShare={config.onRemoveShare}
|
||||
helpText={config.shareModal.helpText}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
51
src/components/collaboration/CollaborationToolbar.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { LiveIndicator } from './LiveIndicator';
|
||||
import { ShareButton } from './ShareButton';
|
||||
import { CollaboratorAvatars } from './CollaboratorAvatars';
|
||||
import type { Share } from '@/lib/share-utils';
|
||||
|
||||
interface CollaborationToolbarProps {
|
||||
isConnected: boolean;
|
||||
error: string | null;
|
||||
lastEventUser: string | null;
|
||||
canEdit: boolean;
|
||||
shares: Share[];
|
||||
onShareClick: () => void;
|
||||
}
|
||||
|
||||
export function CollaborationToolbar({
|
||||
isConnected,
|
||||
error,
|
||||
lastEventUser,
|
||||
canEdit,
|
||||
shares,
|
||||
onShareClick,
|
||||
}: CollaborationToolbarProps) {
|
||||
return (
|
||||
<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">
|
||||
<CollaboratorAvatars shares={shares} />
|
||||
<ShareButton onClick={onShareClick} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/collaboration/CollaboratorAvatars.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import type { Share } from '@/lib/share-utils';
|
||||
|
||||
interface CollaboratorAvatarsProps {
|
||||
shares: Share[];
|
||||
maxVisible?: number;
|
||||
}
|
||||
|
||||
export function CollaboratorAvatars({ shares, maxVisible = 3 }: CollaboratorAvatarsProps) {
|
||||
if (shares.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visibleShares = shares.slice(0, maxVisible);
|
||||
const remainingCount = shares.length - maxVisible;
|
||||
|
||||
return (
|
||||
<div className="flex -space-x-2">
|
||||
{visibleShares.map((share) => (
|
||||
<Avatar
|
||||
key={share.id}
|
||||
email={share.user.email}
|
||||
name={share.user.name}
|
||||
size={32}
|
||||
className="border-2 border-card"
|
||||
/>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<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">
|
||||
+{remainingCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useSessionLive, type LiveEvent } from '@/hooks/useSessionLive';
|
||||
import { LiveIndicator } from './LiveIndicator';
|
||||
import { ShareModal } from './ShareModal';
|
||||
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;
|
||||
}
|
||||
import { BaseSessionLiveWrapper } from './BaseSessionLiveWrapper';
|
||||
import { shareSessionAction, removeShareAction } from '@/actions/share';
|
||||
import type { TeamWithMembers, Share } from '@/lib/share-utils';
|
||||
|
||||
interface SessionLiveWrapperProps {
|
||||
sessionId: string;
|
||||
@@ -28,6 +11,7 @@ interface SessionLiveWrapperProps {
|
||||
shares: Share[];
|
||||
isOwner: boolean;
|
||||
canEdit: boolean;
|
||||
userTeams?: TeamWithMembers[];
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -38,95 +22,36 @@ export function SessionLiveWrapper({
|
||||
shares,
|
||||
isOwner,
|
||||
canEdit,
|
||||
userTeams = [],
|
||||
children,
|
||||
}: SessionLiveWrapperProps) {
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
|
||||
|
||||
const handleEvent = useCallback((event: LiveEvent) => {
|
||||
// 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 } = useSessionLive({
|
||||
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 */}
|
||||
<ShareModal
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
sessionId={sessionId}
|
||||
sessionTitle={sessionTitle}
|
||||
shares={shares}
|
||||
isOwner={isOwner}
|
||||
/>
|
||||
</>
|
||||
<BaseSessionLiveWrapper
|
||||
sessionId={sessionId}
|
||||
sessionTitle={sessionTitle}
|
||||
currentUserId={currentUserId}
|
||||
shares={shares}
|
||||
isOwner={isOwner}
|
||||
canEdit={canEdit}
|
||||
userTeams={userTeams}
|
||||
config={{
|
||||
apiPath: 'sessions',
|
||||
shareModal: {
|
||||
title: 'Partager la session',
|
||||
sessionSubtitle: 'Session',
|
||||
helpText: (
|
||||
<>
|
||||
<strong>Éditeur</strong> : peut modifier les items et actions
|
||||
<br />
|
||||
<strong>Lecteur</strong> : peut uniquement consulter
|
||||
</>
|
||||
),
|
||||
},
|
||||
onShareWithEmail: (email, role) => shareSessionAction(sessionId, email, role),
|
||||
onRemoveShare: (userId) => removeShareAction(sessionId, userId),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BaseSessionLiveWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
23
src/components/collaboration/ShareButton.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface ShareButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function ShareButton({ onClick }: ShareButtonProps) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" onClick={onClick}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import Link from 'next/link';
|
||||
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 { shareSessionAction, removeShareAction } from '@/actions/share';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { getTeamMembersForShare, type TeamWithMembers, type Share } from '@/lib/share-utils';
|
||||
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;
|
||||
}
|
||||
type ShareTab = 'teamMember' | 'team' | 'email';
|
||||
|
||||
interface ShareModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
sessionId: string;
|
||||
title: string;
|
||||
sessionSubtitle?: string;
|
||||
sessionTitle: string;
|
||||
shares: Share[];
|
||||
isOwner: boolean;
|
||||
userTeams?: TeamWithMembers[];
|
||||
currentUserId?: string;
|
||||
onShareWithEmail: (
|
||||
email: string,
|
||||
role: ShareRole
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
onShareWithTeam?: (
|
||||
teamId: string,
|
||||
role: ShareRole
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
onRemoveShare: (userId: string) => Promise<unknown>;
|
||||
helpText?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'EDITOR', label: 'Éditeur' },
|
||||
{ value: 'VIEWER', label: 'Lecteur' },
|
||||
] as const;
|
||||
|
||||
export function ShareModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
sessionId,
|
||||
title,
|
||||
sessionSubtitle,
|
||||
sessionTitle,
|
||||
shares,
|
||||
isOwner,
|
||||
userTeams = [],
|
||||
currentUserId = '',
|
||||
onShareWithEmail,
|
||||
onShareWithTeam,
|
||||
onRemoveShare,
|
||||
helpText,
|
||||
}: ShareModalProps) {
|
||||
const teamMembers = getTeamMembersForShare(userTeams, currentUserId);
|
||||
const hasTeamShare = !!onShareWithTeam;
|
||||
const [shareType, setShareType] = useState<ShareTab>('teamMember');
|
||||
const [email, setEmail] = useState('');
|
||||
const [teamId, setTeamId] = useState('');
|
||||
const [selectedMemberId, setSelectedMemberId] = useState('');
|
||||
const [role, setRole] = useState<ShareRole>('EDITOR');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const resetForm = () => {
|
||||
setEmail('');
|
||||
setTeamId('');
|
||||
setSelectedMemberId('');
|
||||
};
|
||||
|
||||
async function handleShare(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await shareSessionAction(sessionId, email, role);
|
||||
let result: { success: boolean; error?: string };
|
||||
if (shareType === 'team' && onShareWithTeam) {
|
||||
result = await onShareWithTeam(teamId, role);
|
||||
} else {
|
||||
const targetEmail =
|
||||
shareType === 'teamMember'
|
||||
? (teamMembers.find((m) => m.id === selectedMemberId)?.email ?? '')
|
||||
: email;
|
||||
result = await onShareWithEmail(targetEmail, role);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
setEmail('');
|
||||
resetForm();
|
||||
} else {
|
||||
setError(result.error || 'Erreur lors du partage');
|
||||
}
|
||||
@@ -60,53 +97,157 @@ export function ShareModal({
|
||||
|
||||
async function handleRemove(userId: string) {
|
||||
startTransition(async () => {
|
||||
await removeShareAction(sessionId, userId);
|
||||
await onRemoveShare(userId);
|
||||
});
|
||||
}
|
||||
|
||||
const tabs: { value: ShareTab; label: string; icon: string }[] = [
|
||||
{ value: 'teamMember', label: 'Membre', icon: '👥' },
|
||||
...(hasTeamShare ? [{ value: 'team' as ShareTab, label: 'Équipe', icon: '🏢' }] : []),
|
||||
{ value: 'email', label: 'Email', icon: '👤' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Partager la session">
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title}>
|
||||
<div className="space-y-6">
|
||||
{/* Session info */}
|
||||
<div>
|
||||
<p className="text-sm text-muted">Session</p>
|
||||
{sessionSubtitle && <p className="text-sm text-muted">{sessionSubtitle}</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 className="flex gap-2 border-b border-border pb-3 flex-wrap">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShareType(tab.value);
|
||||
resetForm();
|
||||
}}
|
||||
className={`flex-1 min-w-0 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||
shareType === tab.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-card-hover text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{shareType === 'email' && (
|
||||
<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)}
|
||||
options={[...ROLE_OPTIONS]}
|
||||
wrapperClassName="w-auto shrink-0 min-w-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shareType === 'teamMember' && (
|
||||
<div className="space-y-2">
|
||||
{teamMembers.length === 0 ? (
|
||||
<p className="text-sm text-muted">
|
||||
Vous n'êtes membre d'aucune équipe ou vos équipes n'ont pas
|
||||
d'autres membres. Créez une équipe depuis la page{' '}
|
||||
<Link href="/teams" className="text-primary hover:underline">
|
||||
Équipes
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={selectedMemberId}
|
||||
onChange={(e) => setSelectedMemberId(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'Sélectionner un membre', disabled: true },
|
||||
...teamMembers.map((m) => ({
|
||||
value: m.id,
|
||||
label: m.name ? `${m.name} (${m.email})` : m.email,
|
||||
})),
|
||||
]}
|
||||
wrapperClassName="flex-1 min-w-0"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as ShareRole)}
|
||||
options={[...ROLE_OPTIONS]}
|
||||
wrapperClassName="w-auto shrink-0 min-w-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shareType === 'team' && hasTeamShare && (
|
||||
<div className="space-y-2">
|
||||
{userTeams.length === 0 ? (
|
||||
<p className="text-sm text-muted">
|
||||
Vous n'êtes membre d'aucune équipe. Créez une équipe depuis la page{' '}
|
||||
<Link href="/teams" className="text-primary hover:underline">
|
||||
Équipes
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={teamId}
|
||||
onChange={(e) => setTeamId(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'Sélectionner une équipe', disabled: true },
|
||||
...userTeams.map((team) => ({
|
||||
value: team.id,
|
||||
label: `${team.name}${team.userRole === 'ADMIN' ? ' (Admin)' : ''}`,
|
||||
})),
|
||||
]}
|
||||
wrapperClassName="flex-1 min-w-0"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as ShareRole)}
|
||||
options={[...ROLE_OPTIONS]}
|
||||
wrapperClassName="w-auto shrink-0 min-w-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<Button type="submit" disabled={isPending || !email} className="w-full">
|
||||
{isPending ? 'Partage...' : 'Partager'}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isPending ||
|
||||
(shareType === 'email' && !email) ||
|
||||
(shareType === 'teamMember' && !selectedMemberId) ||
|
||||
(shareType === 'team' && !teamId)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending ? 'Partage...' : shareType === 'team' ? "Partager à l'équipe" : '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>
|
||||
) : (
|
||||
@@ -125,7 +266,6 @@ export function ShareModal({
|
||||
{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'}
|
||||
@@ -158,14 +298,7 @@ export function ShareModal({
|
||||
)}
|
||||
</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 actions
|
||||
<br />
|
||||
<strong>Lecteur</strong> : peut uniquement consulter
|
||||
</p>
|
||||
</div>
|
||||
{helpText && <div className="rounded-lg bg-primary/5 p-3">{helpText}</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
export { LiveIndicator } from './LiveIndicator';
|
||||
export { ShareModal } from './ShareModal';
|
||||
export { SessionLiveWrapper } from './SessionLiveWrapper';
|
||||
export { BaseSessionLiveWrapper } from './BaseSessionLiveWrapper';
|
||||
export { ShareButton } from './ShareButton';
|
||||
export { CollaboratorAvatars } from './CollaboratorAvatars';
|
||||
export { CollaborationToolbar } from './CollaborationToolbar';
|
||||
export type { LiveApiPath } from './BaseSessionLiveWrapper';
|
||||
|
||||
126
src/components/gif-mood/GifMoodAddForm.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Button, Input } from '@/components/ui';
|
||||
import { addGifMoodItem } from '@/actions/gif-mood';
|
||||
import { GIF_MOOD_MAX_ITEMS } from '@/lib/types';
|
||||
|
||||
interface GifMoodAddFormProps {
|
||||
sessionId: string;
|
||||
currentCount: number;
|
||||
}
|
||||
|
||||
export function GifMoodAddForm({ sessionId, currentCount }: GifMoodAddFormProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [gifUrl, setGifUrl] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const remaining = GIF_MOOD_MAX_ITEMS - currentCount;
|
||||
|
||||
function handleUrlBlur() {
|
||||
const trimmed = gifUrl.trim();
|
||||
if (!trimmed) { setPreviewUrl(''); return; }
|
||||
try { new URL(trimmed); setPreviewUrl(trimmed); setError(null); }
|
||||
catch { setPreviewUrl(''); }
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const trimmed = gifUrl.trim();
|
||||
if (!trimmed) { setError("L'URL est requise"); return; }
|
||||
try { new URL(trimmed); } catch { setError('URL invalide'); return; }
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await addGifMoodItem(sessionId, {
|
||||
gifUrl: trimmed,
|
||||
note: note.trim() || undefined,
|
||||
});
|
||||
if (result.success) {
|
||||
setGifUrl(''); setNote(''); setPreviewUrl(''); setOpen(false);
|
||||
} else {
|
||||
setError(result.error || "Erreur lors de l'ajout");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Collapsed state — placeholder card
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="group flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-border/50 hover:border-primary/40 hover:bg-primary/5 min-h-[120px] transition-all duration-200 text-muted hover:text-primary w-full"
|
||||
>
|
||||
<span className="text-2xl opacity-40 group-hover:opacity-70 transition-opacity">+</span>
|
||||
<span className="text-xs font-medium">{remaining} slot{remaining !== 1 ? 's' : ''} restant{remaining !== 1 ? 's' : ''}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded form
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-2xl border border-border bg-card shadow-sm p-4 space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-foreground">Ajouter un GIF</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(false); setError(null); setPreviewUrl(''); }}
|
||||
className="text-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="URL du GIF"
|
||||
value={gifUrl}
|
||||
onChange={(e) => setGifUrl(e.target.value)}
|
||||
onBlur={handleUrlBlur}
|
||||
placeholder="https://media.giphy.com/…"
|
||||
disabled={isPending}
|
||||
/>
|
||||
|
||||
{previewUrl && (
|
||||
<div className="rounded-xl overflow-hidden border border-border/50">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Aperçu"
|
||||
className="w-full object-contain max-h-40"
|
||||
onError={() => setPreviewUrl('')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-muted mb-1">
|
||||
Note <span className="font-normal opacity-60">(optionnelle)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Ce que ce GIF exprime…"
|
||||
rows={2}
|
||||
disabled={isPending}
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" loading={isPending} className="w-full" size="sm">
|
||||
Ajouter
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
273
src/components/gif-mood/GifMoodBoard.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState, useTransition } from 'react';
|
||||
import { setGifMoodUserRating } from '@/actions/gif-mood';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { GifMoodCard } from './GifMoodCard';
|
||||
import { GifMoodAddForm } from './GifMoodAddForm';
|
||||
import { GIF_MOOD_MAX_ITEMS } from '@/lib/types';
|
||||
|
||||
interface GifMoodItem {
|
||||
id: string;
|
||||
gifUrl: string;
|
||||
note: string | null;
|
||||
order: number;
|
||||
userId: string;
|
||||
user: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Share {
|
||||
id: string;
|
||||
userId: string;
|
||||
user: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GifMoodBoardProps {
|
||||
sessionId: string;
|
||||
currentUserId: string;
|
||||
items: GifMoodItem[];
|
||||
shares: Share[];
|
||||
owner: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
};
|
||||
ratings: { userId: string; rating: number }[];
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
function WeekRating({
|
||||
sessionId,
|
||||
isCurrentUser,
|
||||
canEdit,
|
||||
initialRating,
|
||||
}: {
|
||||
sessionId: string;
|
||||
isCurrentUser: boolean;
|
||||
canEdit: boolean;
|
||||
initialRating: number | null;
|
||||
}) {
|
||||
const [prevInitialRating, setPrevInitialRating] = useState(initialRating);
|
||||
const [rating, setRating] = useState<number | null>(initialRating);
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
if (prevInitialRating !== initialRating) {
|
||||
setPrevInitialRating(initialRating);
|
||||
setRating(initialRating);
|
||||
}
|
||||
|
||||
const interactive = isCurrentUser && canEdit;
|
||||
const display = hovered ?? rating;
|
||||
|
||||
function handleClick(n: number) {
|
||||
if (!interactive) return;
|
||||
setRating(n);
|
||||
startTransition(async () => {
|
||||
await setGifMoodUserRating(sessionId, n);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-0.5"
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((n) => {
|
||||
const filled = display !== null && n <= display;
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => handleClick(n)}
|
||||
onMouseEnter={() => interactive && setHovered(n)}
|
||||
disabled={!interactive || isPending}
|
||||
className={`transition-all duration-100 ${interactive ? 'cursor-pointer hover:scale-125' : 'cursor-default'}`}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
className={`transition-colors duration-100 ${
|
||||
filled ? 'text-amber-400' : 'text-border'
|
||||
}`}
|
||||
>
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Subtle accent colors for each user section
|
||||
const SECTION_COLORS = ['#ec4899', '#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
|
||||
|
||||
const GRID_COLS: Record<number, string> = {
|
||||
4: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
|
||||
5: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5',
|
||||
6: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6',
|
||||
};
|
||||
|
||||
function GridIcon({ cols }: { cols: number }) {
|
||||
return (
|
||||
<svg width="20" height="14" viewBox="0 0 20 14" fill="none" aria-hidden>
|
||||
{Array.from({ length: cols }).map((_, i) => {
|
||||
const w = (20 - (cols - 1) * 2) / cols;
|
||||
const x = i * (w + 2);
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect x={x} y={0} width={w} height={6} rx={1} fill="currentColor" opacity={0.7} />
|
||||
<rect x={x} y={8} width={w} height={6} rx={1} fill="currentColor" opacity={0.4} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GifMoodBoard({
|
||||
sessionId,
|
||||
currentUserId,
|
||||
items,
|
||||
shares,
|
||||
owner,
|
||||
ratings,
|
||||
canEdit,
|
||||
}: GifMoodBoardProps) {
|
||||
const [cols, setCols] = useState(5);
|
||||
|
||||
const allUsers = useMemo(() => {
|
||||
const map = new Map<string, { id: string; name: string | null; email: string }>();
|
||||
map.set(owner.id, owner);
|
||||
shares.forEach((s) => map.set(s.userId, s.user));
|
||||
return Array.from(map.values());
|
||||
}, [owner, shares]);
|
||||
|
||||
const itemsByUser = useMemo(() => {
|
||||
const map = new Map<string, GifMoodItem[]>();
|
||||
items.forEach((item) => {
|
||||
const existing = map.get(item.userId) ?? [];
|
||||
existing.push(item);
|
||||
map.set(item.userId, existing);
|
||||
});
|
||||
return map;
|
||||
}, [items]);
|
||||
|
||||
const sortedUsers = useMemo(() => {
|
||||
return [...allUsers].sort((a, b) => {
|
||||
if (a.id === currentUserId) return -1;
|
||||
if (b.id === currentUserId) return 1;
|
||||
if (a.id === owner.id) return -1;
|
||||
if (b.id === owner.id) return 1;
|
||||
return (a.name || a.email).localeCompare(b.name || b.email, 'fr');
|
||||
});
|
||||
}, [allUsers, currentUserId, owner.id]);
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* Column size control */}
|
||||
<div className="flex justify-end">
|
||||
<div className="inline-flex items-center gap-1 rounded-xl border border-border bg-card p-1">
|
||||
{[4, 5, 6].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setCols(n)}
|
||||
className={`flex items-center justify-center rounded-lg px-2.5 py-1.5 transition-all ${
|
||||
cols === n
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted hover:text-foreground hover:bg-card-hover'
|
||||
}`}
|
||||
title={`${n} colonnes`}
|
||||
>
|
||||
<GridIcon cols={n} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{sortedUsers.map((user, index) => {
|
||||
const userItems = itemsByUser.get(user.id) ?? [];
|
||||
const isCurrentUser = user.id === currentUserId;
|
||||
const canAdd = canEdit && isCurrentUser && userItems.length < GIF_MOOD_MAX_ITEMS;
|
||||
const accentColor = SECTION_COLORS[index % SECTION_COLORS.length];
|
||||
const userRating = ratings.find((r) => r.userId === user.id)?.rating ?? null;
|
||||
|
||||
return (
|
||||
<section key={user.id}>
|
||||
{/* Section header */}
|
||||
<div className="flex items-center gap-4 mb-5">
|
||||
{/* Colored accent bar */}
|
||||
<div className="w-1 h-8 rounded-full shrink-0" style={{ backgroundColor: accentColor }} />
|
||||
|
||||
<Avatar email={user.email} name={user.name} size={36} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-foreground truncate">
|
||||
{user.name || user.email}
|
||||
</span>
|
||||
{isCurrentUser && (
|
||||
<span className="text-xs text-muted bg-card-hover px-2 py-0.5 rounded-full border border-border">
|
||||
vous
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
<p className="text-xs text-muted">
|
||||
{userItems.length} / {GIF_MOOD_MAX_ITEMS} GIF{userItems.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<WeekRating
|
||||
sessionId={sessionId}
|
||||
isCurrentUser={isCurrentUser}
|
||||
canEdit={canEdit}
|
||||
initialRating={userRating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className={`grid ${GRID_COLS[cols]} gap-4 items-start`}>
|
||||
{userItems.map((item) => (
|
||||
<GifMoodCard
|
||||
key={item.id}
|
||||
sessionId={sessionId}
|
||||
item={item}
|
||||
currentUserId={currentUserId}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Add form slot */}
|
||||
{canAdd && (
|
||||
<GifMoodAddForm sessionId={sessionId} currentCount={userItems.length} />
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!canAdd && userItems.length === 0 && (
|
||||
<div className="col-span-full flex items-center justify-center rounded-2xl border border-dashed border-border/60 py-10">
|
||||
<p className="text-sm text-muted/60">Aucun GIF pour le moment</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/components/gif-mood/GifMoodCard.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useState, useTransition } from 'react';
|
||||
import { updateGifMoodItem, deleteGifMoodItem } from '@/actions/gif-mood';
|
||||
import { IconClose } from '@/components/ui';
|
||||
|
||||
interface GifMoodCardProps {
|
||||
sessionId: string;
|
||||
item: {
|
||||
id: string;
|
||||
gifUrl: string;
|
||||
note: string | null;
|
||||
userId: string;
|
||||
};
|
||||
currentUserId: string;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export const GifMoodCard = memo(function GifMoodCard({
|
||||
sessionId,
|
||||
item,
|
||||
currentUserId,
|
||||
canEdit,
|
||||
}: GifMoodCardProps) {
|
||||
const [note, setNote] = useState(item.note || '');
|
||||
const [itemVersion, setItemVersion] = useState(item);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
if (itemVersion !== item) {
|
||||
setItemVersion(item);
|
||||
setNote(item.note || '');
|
||||
}
|
||||
|
||||
const isOwner = item.userId === currentUserId;
|
||||
const canEditThis = canEdit && isOwner;
|
||||
|
||||
function handleNoteBlur() {
|
||||
if (!canEditThis) return;
|
||||
startTransition(async () => {
|
||||
await updateGifMoodItem(sessionId, item.id, { note: note.trim() || undefined });
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!canEditThis) return;
|
||||
startTransition(async () => {
|
||||
await deleteGifMoodItem(sessionId, item.id);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative rounded-2xl overflow-hidden bg-card shadow-sm hover:shadow-md transition-all duration-200 ${
|
||||
isPending ? 'opacity-50 scale-95' : ''
|
||||
}`}
|
||||
>
|
||||
{/* GIF */}
|
||||
{imgError ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 min-h-[120px] bg-card-hover">
|
||||
<span className="text-3xl opacity-40">🖼️</span>
|
||||
<p className="text-xs text-muted">Image non disponible</p>
|
||||
</div>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={item.gifUrl}
|
||||
alt="GIF"
|
||||
className="w-full block"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Gradient overlay on hover (for delete affordance) */}
|
||||
{canEditThis && (
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/20 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
||||
)}
|
||||
|
||||
{/* Delete button — visible on hover */}
|
||||
{canEditThis && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isPending}
|
||||
className="absolute top-2 right-2 p-1.5 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 hover:bg-black/70 transition-all backdrop-blur-sm"
|
||||
title="Supprimer ce GIF"
|
||||
>
|
||||
<IconClose className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Note */}
|
||||
{canEditThis ? (
|
||||
<div className="px-3 pt-2 pb-3 bg-card">
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onBlur={handleNoteBlur}
|
||||
placeholder="Ajouter une note…"
|
||||
rows={1}
|
||||
className="w-full text-foreground/70 bg-transparent resize-none outline-none placeholder:text-muted/40 leading-relaxed text-center"
|
||||
style={{ fontFamily: 'var(--font-caveat)', fontSize: '1.2rem' }}
|
||||
/>
|
||||
</div>
|
||||
) : note ? (
|
||||
<div className="px-3 py-2.5 bg-card">
|
||||
<p className="text-foreground/70 leading-relaxed text-center" style={{ fontFamily: 'var(--font-caveat)', fontSize: '1.2rem' }}>{note}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
62
src/components/gif-mood/GifMoodLiveWrapper.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { BaseSessionLiveWrapper } from '@/components/collaboration/BaseSessionLiveWrapper';
|
||||
import {
|
||||
shareGifMoodSession,
|
||||
shareGifMoodSessionToTeam,
|
||||
removeGifMoodShare,
|
||||
} from '@/actions/gif-mood';
|
||||
import type { TeamWithMembers, Share } from '@/lib/share-utils';
|
||||
|
||||
interface GifMoodLiveWrapperProps {
|
||||
sessionId: string;
|
||||
sessionTitle: string;
|
||||
currentUserId: string;
|
||||
shares: Share[];
|
||||
isOwner: boolean;
|
||||
canEdit: boolean;
|
||||
userTeams?: TeamWithMembers[];
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function GifMoodLiveWrapper({
|
||||
sessionId,
|
||||
sessionTitle,
|
||||
currentUserId,
|
||||
shares,
|
||||
isOwner,
|
||||
canEdit,
|
||||
userTeams = [],
|
||||
children,
|
||||
}: GifMoodLiveWrapperProps) {
|
||||
return (
|
||||
<BaseSessionLiveWrapper
|
||||
sessionId={sessionId}
|
||||
sessionTitle={sessionTitle}
|
||||
currentUserId={currentUserId}
|
||||
shares={shares}
|
||||
isOwner={isOwner}
|
||||
canEdit={canEdit}
|
||||
userTeams={userTeams}
|
||||
config={{
|
||||
apiPath: 'gif-mood',
|
||||
shareModal: {
|
||||
title: 'Partager le GIF Mood Board',
|
||||
sessionSubtitle: 'GIF Mood Board',
|
||||
helpText: (
|
||||
<>
|
||||
<strong>Éditeur</strong> : peut ajouter ses GIFs et voir ceux des autres
|
||||
<br />
|
||||
<strong>Lecteur</strong> : peut uniquement consulter
|
||||
</>
|
||||
),
|
||||
},
|
||||
onShareWithEmail: (email, role) => shareGifMoodSession(sessionId, email, role),
|
||||
onShareWithTeam: (teamId, role) => shareGifMoodSessionToTeam(sessionId, teamId, role),
|
||||
onRemoveShare: (userId) => removeGifMoodShare(sessionId, userId),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BaseSessionLiveWrapper>
|
||||
);
|
||||
}
|
||||
4
src/components/gif-mood/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { GifMoodBoard } from './GifMoodBoard';
|
||||
export { GifMoodCard } from './GifMoodCard';
|
||||
export { GifMoodAddForm } from './GifMoodAddForm';
|
||||
export { GifMoodLiveWrapper } from './GifMoodLiveWrapper';
|
||||
@@ -1,170 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useSession, signOut } from 'next-auth/react';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { useState } from 'react';
|
||||
import { Avatar } from '@/components/ui';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { RocketIcon } from '@/components/ui';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import { UserMenu } from './UserMenu';
|
||||
import { WorkshopsDropdown } from './WorkshopsDropdown';
|
||||
import { NavLinks } from './NavLinks';
|
||||
|
||||
export function Header() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { data: session, status } = useSession();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [workshopsOpen, setWorkshopsOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActiveLink = (path: string) => pathname.startsWith(path);
|
||||
export async function Header() {
|
||||
const session = await auth();
|
||||
const isAuthenticated = !!session?.user;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-card/80 backdrop-blur-sm">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<span className="text-2xl">🚀</span>
|
||||
<RocketIcon className="h-7 w-7 shrink-0 text-primary" />
|
||||
<span className="text-xl font-bold text-foreground">Workshop Manager</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-4">
|
||||
{status === 'authenticated' && session?.user && (
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
{/* All Workshops Link */}
|
||||
<Link
|
||||
href="/sessions"
|
||||
className={`text-sm font-medium transition-colors ${
|
||||
isActiveLink('/sessions') && !isActiveLink('/sessions/')
|
||||
? 'text-primary'
|
||||
: 'text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Mes Ateliers
|
||||
</Link>
|
||||
|
||||
{/* Workshops Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setWorkshopsOpen(!workshopsOpen)}
|
||||
onBlur={() => setTimeout(() => setWorkshopsOpen(false), 150)}
|
||||
className={`flex items-center gap-1 text-sm font-medium transition-colors ${
|
||||
isActiveLink('/sessions/') || isActiveLink('/motivators')
|
||||
? 'text-primary'
|
||||
: 'text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Ateliers
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${workshopsOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{workshopsOpen && (
|
||||
<div className="absolute left-0 z-20 mt-2 w-56 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
<Link
|
||||
href="/sessions/new"
|
||||
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover"
|
||||
onClick={() => setWorkshopsOpen(false)}
|
||||
>
|
||||
<span className="text-lg">📊</span>
|
||||
<div>
|
||||
<div className="font-medium">Analyse SWOT</div>
|
||||
<div className="text-xs text-muted">Forces, faiblesses, opportunités</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/motivators/new"
|
||||
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover"
|
||||
onClick={() => setWorkshopsOpen(false)}
|
||||
>
|
||||
<span className="text-lg">🎯</span>
|
||||
<div>
|
||||
<div className="font-medium">Moving Motivators</div>
|
||||
<div className="text-xs text-muted">Motivations intrinsèques</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<NavLinks />
|
||||
<WorkshopsDropdown />
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-card text-lg transition-colors hover:bg-card-hover"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === 'light' ? '🌙' : '☀️'}
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
|
||||
{status === 'loading' ? (
|
||||
<div className="h-9 w-20 animate-pulse rounded-lg bg-card-hover" />
|
||||
) : status === 'authenticated' && session?.user ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="flex h-9 items-center gap-2 rounded-lg border border-border bg-card pl-1.5 pr-3 transition-colors hover:bg-card-hover"
|
||||
>
|
||||
<Avatar email={session.user.email!} name={session.user.name} size={24} />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{session.user.name || session.user.email?.split('@')[0]}
|
||||
</span>
|
||||
<svg
|
||||
className={`h-4 w-4 text-muted transition-transform ${menuOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute right-0 z-20 mt-2 w-48 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<p className="text-xs text-muted">Connecté en tant que</p>
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{session.user.email}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/profile"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
|
||||
>
|
||||
👤 Mon Profil
|
||||
</Link>
|
||||
<Link
|
||||
href="/users"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
|
||||
>
|
||||
👥 Utilisateurs
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: '/' })}
|
||||
className="w-full px-4 py-2 text-left text-sm text-destructive hover:bg-card-hover"
|
||||
>
|
||||
Se déconnecter
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isAuthenticated ? (
|
||||
<UserMenu
|
||||
userName={session.user?.name}
|
||||
userEmail={session.user?.email ?? ''}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
href="/login"
|
||||
|
||||