Compare commits
61 Commits
master
...
c828ab1a48
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
next-env.d.ts
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
|
|
||||||
|
# data
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
89
CLAUDE.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# 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 ----
|
# ---- Base ----
|
||||||
FROM node:22-alpine AS base
|
FROM node:22-alpine AS base
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
RUN mkdir -p $PNPM_HOME
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# ---- Dependencies ----
|
# ---- Dependencies ----
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
COPY package.json pnpm-lock.yaml ./
|
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 ----
|
# ---- Build ----
|
||||||
FROM base AS builder
|
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)
|
# Install prisma CLI for migrations + better-sqlite3 (compile native module)
|
||||||
ENV DATABASE_URL="file:/app/data/prod.db"
|
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
|
pnpm prisma generate
|
||||||
|
|
||||||
# Copy entrypoint script
|
# Copy entrypoint script
|
||||||
|
|||||||
74
PERF_OPTIMIZATIONS.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# 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
|
## ✨ Fonctionnalités
|
||||||
|
|
||||||
### 📊 Analyse SWOT
|
### 📊 Analyse SWOT
|
||||||
|
|
||||||
Cartographiez les forces, faiblesses, opportunités et menaces de vos collaborateurs.
|
Cartographiez les forces, faiblesses, opportunités et menaces de vos collaborateurs.
|
||||||
|
|
||||||
- Matrice interactive avec drag & drop
|
- Matrice interactive avec drag & drop
|
||||||
- Actions croisées et plan de développement
|
- Actions croisées et plan de développement
|
||||||
- Collaboration en temps réel
|
- Collaboration en temps réel
|
||||||
|
|
||||||
### 🎯 Moving Motivators
|
### 🎯 Moving Motivators
|
||||||
|
|
||||||
Explorez les 10 motivations intrinsèques (Management 3.0).
|
Explorez les 10 motivations intrinsèques (Management 3.0).
|
||||||
|
|
||||||
- Classement par importance
|
- Classement par importance
|
||||||
- Évaluation de l'influence positive/négative
|
- Évaluation de l'influence positive/négative
|
||||||
- Récapitulatif personnalisé
|
- Récapitulatif personnalisé
|
||||||
|
|
||||||
### 🤝 Collaboration
|
### 🤝 Collaboration
|
||||||
|
|
||||||
- Partage de sessions (Éditeur / Lecteur)
|
- Partage de sessions (Éditeur / Lecteur)
|
||||||
- Synchronisation temps réel (SSE)
|
- Synchronisation temps réel (SSE)
|
||||||
- Historique sauvegardé
|
- 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
|
## Stack Technique
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
workshop-manager-app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- '3011:3000'
|
- '3009:3000'
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DATABASE_URL=file:/app/data/dev.db
|
- DATABASE_URL=file:/app/data/dev.db
|
||||||
- AUTH_SECRET=${AUTH_SECRET:-your-secret-key-change-in-production}
|
- AUTH_SECRET=${AUTH_SECRET:-your-secret-key-change-in-production}
|
||||||
- AUTH_TRUST_HOST=true
|
- AUTH_TRUST_HOST=true
|
||||||
- AUTH_URL=${AUTH_URL:-http://localhost:3011}
|
- AUTH_URL=${AUTH_URL:-https://workshop-manager.julienfroidefond.com}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ${DATA_VOLUME_PATH:-./data}:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- 'com.centurylinklabs.watchtower.enable=false'
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
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;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"@prisma/client": "^7.1.0",
|
"@prisma/client": "^7.1.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.4.6",
|
"better-sqlite3": "^12.4.6",
|
||||||
"next": "16.0.7",
|
"next": "16.0.10",
|
||||||
"next-auth": "5.0.0-beta.30",
|
"next-auth": "5.0.0-beta.30",
|
||||||
"prisma": "^7.1.0",
|
"prisma": "^7.1.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
|
|||||||
95
pnpm-lock.yaml
generated
@@ -33,11 +33,11 @@ importers:
|
|||||||
specifier: ^12.4.6
|
specifier: ^12.4.6
|
||||||
version: 12.4.6
|
version: 12.4.6
|
||||||
next:
|
next:
|
||||||
specifier: 16.0.7
|
specifier: 16.0.10
|
||||||
version: 16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 16.0.10(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
next-auth:
|
next-auth:
|
||||||
specifier: 5.0.0-beta.30
|
specifier: 5.0.0-beta.30
|
||||||
version: 5.0.0-beta.30(next@16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
|
version: 5.0.0-beta.30(next@16.0.10(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
|
||||||
prisma:
|
prisma:
|
||||||
specifier: ^7.1.0
|
specifier: ^7.1.0
|
||||||
version: 7.1.0(@types/react@19.2.7)(better-sqlite3@12.4.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
|
version: 7.1.0(@types/react@19.2.7)(better-sqlite3@12.4.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
|
||||||
@@ -459,56 +459,56 @@ packages:
|
|||||||
'@napi-rs/wasm-runtime@0.2.12':
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||||
|
|
||||||
'@next/env@16.0.7':
|
'@next/env@16.0.10':
|
||||||
resolution: {integrity: sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==}
|
resolution: {integrity: sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@16.0.5':
|
'@next/eslint-plugin-next@16.0.5':
|
||||||
resolution: {integrity: sha512-m1zPz6hsBvQt1CMRz7rTga8OXpRE9rVW4JHCSjW+tswTxiEU+6ev+GTlgm7ZzcCiMEVQAHTNhpEGFzDtVha9qg==}
|
resolution: {integrity: sha512-m1zPz6hsBvQt1CMRz7rTga8OXpRE9rVW4JHCSjW+tswTxiEU+6ev+GTlgm7ZzcCiMEVQAHTNhpEGFzDtVha9qg==}
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@16.0.7':
|
'@next/swc-darwin-arm64@16.0.10':
|
||||||
resolution: {integrity: sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==}
|
resolution: {integrity: sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-darwin-x64@16.0.7':
|
'@next/swc-darwin-x64@16.0.10':
|
||||||
resolution: {integrity: sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==}
|
resolution: {integrity: sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@16.0.7':
|
'@next/swc-linux-arm64-gnu@16.0.10':
|
||||||
resolution: {integrity: sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==}
|
resolution: {integrity: sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@16.0.7':
|
'@next/swc-linux-arm64-musl@16.0.10':
|
||||||
resolution: {integrity: sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==}
|
resolution: {integrity: sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@16.0.7':
|
'@next/swc-linux-x64-gnu@16.0.10':
|
||||||
resolution: {integrity: sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==}
|
resolution: {integrity: sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@16.0.7':
|
'@next/swc-linux-x64-musl@16.0.10':
|
||||||
resolution: {integrity: sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==}
|
resolution: {integrity: sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@16.0.7':
|
'@next/swc-win32-arm64-msvc@16.0.10':
|
||||||
resolution: {integrity: sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==}
|
resolution: {integrity: sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@16.0.7':
|
'@next/swc-win32-x64-msvc@16.0.10':
|
||||||
resolution: {integrity: sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==}
|
resolution: {integrity: sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@@ -1022,6 +1022,9 @@ packages:
|
|||||||
caniuse-lite@1.0.30001757:
|
caniuse-lite@1.0.30001757:
|
||||||
resolution: {integrity: sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==}
|
resolution: {integrity: sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==}
|
||||||
|
|
||||||
|
caniuse-lite@1.0.30001760:
|
||||||
|
resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1908,8 +1911,8 @@ packages:
|
|||||||
nodemailer:
|
nodemailer:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
next@16.0.7:
|
next@16.0.10:
|
||||||
resolution: {integrity: sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==}
|
resolution: {integrity: sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2898,34 +2901,34 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.10.1
|
'@tybys/wasm-util': 0.10.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/env@16.0.7': {}
|
'@next/env@16.0.10': {}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@16.0.5':
|
'@next/eslint-plugin-next@16.0.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-glob: 3.3.1
|
fast-glob: 3.3.1
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@16.0.7':
|
'@next/swc-darwin-arm64@16.0.10':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-darwin-x64@16.0.7':
|
'@next/swc-darwin-x64@16.0.10':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@16.0.7':
|
'@next/swc-linux-arm64-gnu@16.0.10':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@16.0.7':
|
'@next/swc-linux-arm64-musl@16.0.10':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@16.0.7':
|
'@next/swc-linux-x64-gnu@16.0.10':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@16.0.7':
|
'@next/swc-linux-x64-musl@16.0.10':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@16.0.7':
|
'@next/swc-win32-arm64-msvc@16.0.10':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@16.0.7':
|
'@next/swc-win32-x64-msvc@16.0.10':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
@@ -3474,6 +3477,8 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001757: {}
|
caniuse-lite@1.0.30001757: {}
|
||||||
|
|
||||||
|
caniuse-lite@1.0.30001760: {}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
@@ -4438,30 +4443,30 @@ snapshots:
|
|||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
next-auth@5.0.0-beta.30(next@16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0):
|
next-auth@5.0.0-beta.30(next@16.0.10(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@auth/core': 0.41.0
|
'@auth/core': 0.41.0
|
||||||
next: 16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
next: 16.0.10(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
|
|
||||||
next@16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
next@16.0.10(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 16.0.7
|
'@next/env': 16.0.10
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.15
|
||||||
caniuse-lite: 1.0.30001757
|
caniuse-lite: 1.0.30001760
|
||||||
postcss: 8.4.31
|
postcss: 8.4.31
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
react-dom: 19.2.0(react@19.2.0)
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.0)
|
styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.0)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@next/swc-darwin-arm64': 16.0.7
|
'@next/swc-darwin-arm64': 16.0.10
|
||||||
'@next/swc-darwin-x64': 16.0.7
|
'@next/swc-darwin-x64': 16.0.10
|
||||||
'@next/swc-linux-arm64-gnu': 16.0.7
|
'@next/swc-linux-arm64-gnu': 16.0.10
|
||||||
'@next/swc-linux-arm64-musl': 16.0.7
|
'@next/swc-linux-arm64-musl': 16.0.10
|
||||||
'@next/swc-linux-x64-gnu': 16.0.7
|
'@next/swc-linux-x64-gnu': 16.0.10
|
||||||
'@next/swc-linux-x64-musl': 16.0.7
|
'@next/swc-linux-x64-musl': 16.0.10
|
||||||
'@next/swc-win32-arm64-msvc': 16.0.7
|
'@next/swc-win32-arm64-msvc': 16.0.10
|
||||||
'@next/swc-win32-x64-msvc': 16.0.7
|
'@next/swc-win32-x64-msvc': 16.0.10
|
||||||
sharp: 0.34.5
|
sharp: 0.34.5
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -21,6 +21,22 @@ model User {
|
|||||||
motivatorSessions MovingMotivatorsSession[]
|
motivatorSessions MovingMotivatorsSession[]
|
||||||
sharedMotivatorSessions MMSessionShare[]
|
sharedMotivatorSessions MMSessionShare[]
|
||||||
motivatorSessionEvents MMSessionEvent[]
|
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[]
|
||||||
|
// Teams & OKRs relations
|
||||||
|
createdTeams Team[]
|
||||||
|
teamMembers TeamMember[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
@@ -200,3 +216,312 @@ model MMSessionEvent {
|
|||||||
|
|
||||||
@@index([sessionId, createdAt])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 |
@@ -16,6 +16,16 @@ export async function createMotivatorSession(data: { title: string; participant:
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const motivatorSession = await motivatorsService.createMotivatorSession(session.user.id, data);
|
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');
|
revalidatePath('/motivators');
|
||||||
return { success: true, data: motivatorSession };
|
return { success: true, data: motivatorSession };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export async function createSwotItem(
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return { success: false, error: 'Non autorisé' };
|
return { success: false, error: 'Non autorisé' };
|
||||||
}
|
}
|
||||||
|
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
|
||||||
|
return { success: false, error: 'Non autorisé' };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = await sessionsService.createSwotItem(sessionId, data);
|
const item = await sessionsService.createSwotItem(sessionId, data);
|
||||||
@@ -45,6 +48,9 @@ export async function updateSwotItem(
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return { success: false, error: 'Non autorisé' };
|
return { success: false, error: 'Non autorisé' };
|
||||||
}
|
}
|
||||||
|
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
|
||||||
|
return { success: false, error: 'Non autorisé' };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = await sessionsService.updateSwotItem(itemId, data);
|
const item = await sessionsService.updateSwotItem(itemId, data);
|
||||||
@@ -68,6 +74,9 @@ export async function deleteSwotItem(itemId: string, sessionId: string) {
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return { success: false, error: 'Non autorisé' };
|
return { success: false, error: 'Non autorisé' };
|
||||||
}
|
}
|
||||||
|
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
|
||||||
|
return { success: false, error: 'Non autorisé' };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sessionsService.deleteSwotItem(itemId);
|
await sessionsService.deleteSwotItem(itemId);
|
||||||
@@ -90,6 +99,9 @@ export async function duplicateSwotItem(itemId: string, sessionId: string) {
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return { success: false, error: 'Non autorisé' };
|
return { success: false, error: 'Non autorisé' };
|
||||||
}
|
}
|
||||||
|
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
|
||||||
|
return { success: false, error: 'Non autorisé' };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = await sessionsService.duplicateSwotItem(itemId);
|
const item = await sessionsService.duplicateSwotItem(itemId);
|
||||||
@@ -120,6 +132,9 @@ export async function moveSwotItem(
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return { success: false, error: 'Non autorisé' };
|
return { success: false, error: 'Non autorisé' };
|
||||||
}
|
}
|
||||||
|
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
|
||||||
|
return { success: false, error: 'Non autorisé' };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = await sessionsService.moveSwotItem(itemId, newCategory, newOrder);
|
const item = await sessionsService.moveSwotItem(itemId, newCategory, newOrder);
|
||||||
@@ -156,6 +171,9 @@ export async function createAction(
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return { success: false, error: 'Non autorisé' };
|
return { success: false, error: 'Non autorisé' };
|
||||||
}
|
}
|
||||||
|
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
|
||||||
|
return { success: false, error: 'Non autorisé' };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const action = await sessionsService.createAction(sessionId, data);
|
const action = await sessionsService.createAction(sessionId, data);
|
||||||
@@ -183,12 +201,16 @@ export async function updateAction(
|
|||||||
description?: string;
|
description?: string;
|
||||||
priority?: number;
|
priority?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
linkedItemIds?: string[];
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return { success: false, error: 'Non autorisé' };
|
return { success: false, error: 'Non autorisé' };
|
||||||
}
|
}
|
||||||
|
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
|
||||||
|
return { success: false, error: 'Non autorisé' };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const action = await sessionsService.updateAction(actionId, data);
|
const action = await sessionsService.updateAction(actionId, data);
|
||||||
@@ -212,6 +234,9 @@ export async function deleteAction(actionId: string, sessionId: string) {
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return { success: false, error: 'Non autorisé' };
|
return { success: false, error: 'Non autorisé' };
|
||||||
}
|
}
|
||||||
|
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
|
||||||
|
return { success: false, error: 'Non autorisé' };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sessionsService.deleteAction(actionId);
|
await sessionsService.deleteAction(actionId);
|
||||||
|
|||||||
276
src/actions/weather.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
'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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
339
src/actions/year-review.ts
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
'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 { signIn } from 'next-auth/react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { RocketIcon } from '@/components/ui';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -44,8 +45,8 @@ export default function LoginPage() {
|
|||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<Link href="/" className="inline-flex items-center gap-2">
|
<Link href="/" className="inline-flex items-center gap-2">
|
||||||
<span className="text-3xl">📊</span>
|
<RocketIcon className="h-8 w-8 shrink-0 text-primary" />
|
||||||
<span className="text-2xl font-bold text-foreground">SWOT Manager</span>
|
<span className="text-2xl font-bold text-foreground">Workshop Manager</span>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="mt-2 text-muted">Connectez-vous à votre compte</p>
|
<p className="mt-2 text-muted">Connectez-vous à votre compte</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { RocketIcon } from '@/components/ui';
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -73,8 +74,8 @@ export default function RegisterPage() {
|
|||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<Link href="/" className="inline-flex items-center gap-2">
|
<Link href="/" className="inline-flex items-center gap-2">
|
||||||
<span className="text-3xl">📊</span>
|
<RocketIcon className="h-8 w-8 shrink-0 text-primary" />
|
||||||
<span className="text-2xl font-bold text-foreground">SWOT Manager</span>
|
<span className="text-2xl font-bold text-foreground">Workshop Manager</span>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="mt-2 text-muted">Créez votre compte</p>
|
<p className="mt-2 text-muted">Créez votre compte</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
|||||||
// Connection might be closed
|
// Connection might be closed
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
}
|
}
|
||||||
}, 1000); // Poll every second
|
}, 2000); // Poll every 2 seconds
|
||||||
|
|
||||||
// Cleanup on abort
|
// Cleanup on abort
|
||||||
request.signal.addEventListener('abort', () => {
|
request.signal.addEventListener('abort', () => {
|
||||||
|
|||||||
60
src/app/api/okrs/[id]/key-results/[krId]/route.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
src/app/api/okrs/[id]/route.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
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
|
// Connection might be closed
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
}
|
}
|
||||||
}, 1000); // Poll every second
|
}, 2000); // Poll every 2 seconds
|
||||||
|
|
||||||
// Cleanup on abort
|
// Cleanup on abort
|
||||||
request.signal.addEventListener('abort', () => {
|
request.signal.addEventListener('abort', () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { prisma } from '@/services/database';
|
import { prisma } from '@/services/database';
|
||||||
|
import { shareSession } from '@/services/sessions';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
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 });
|
return NextResponse.json(newSession, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating session:', 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
91
src/app/api/teams/[id]/route.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/app/api/teams/route.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
33
src/app/api/users/route.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
135
src/app/api/weather/[id]/subscribe/route.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/app/api/year-review/[id]/subscribe/route.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import {
|
||||||
|
canAccessYearReviewSession,
|
||||||
|
getYearReviewSessionEvents,
|
||||||
|
} from '@/services/year-review';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
// Store active connections per session
|
||||||
|
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
|
||||||
|
|
||||||
|
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id: sessionId } = await params;
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access
|
||||||
|
const hasAccess = await canAccessYearReviewSession(sessionId, session.user.id);
|
||||||
|
if (!hasAccess) {
|
||||||
|
return new Response('Forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
let lastEventTime = new Date();
|
||||||
|
let controller: ReadableStreamDefaultController;
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(ctrl) {
|
||||||
|
controller = ctrl;
|
||||||
|
|
||||||
|
// Register connection
|
||||||
|
if (!connections.has(sessionId)) {
|
||||||
|
connections.set(sessionId, new Set());
|
||||||
|
}
|
||||||
|
connections.get(sessionId)!.add(controller);
|
||||||
|
|
||||||
|
// Send initial ping
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
// Remove connection on close
|
||||||
|
connections.get(sessionId)?.delete(controller);
|
||||||
|
if (connections.get(sessionId)?.size === 0) {
|
||||||
|
connections.delete(sessionId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poll for new events (simple approach, works with any DB)
|
||||||
|
const pollInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const events = await getYearReviewSessionEvents(sessionId, lastEventTime);
|
||||||
|
if (events.length > 0) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
for (const event of events) {
|
||||||
|
// Don't send events to the user who created them
|
||||||
|
if (event.userId !== userId) {
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
type: event.type,
|
||||||
|
payload: JSON.parse(event.payload),
|
||||||
|
userId: event.userId,
|
||||||
|
user: event.user,
|
||||||
|
timestamp: event.createdAt,
|
||||||
|
})}\n\n`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lastEventTime = event.createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Connection might be closed
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
}
|
||||||
|
}, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
SWOT Manager - CSS Variables Theme System
|
Workshop Manager - CSS Variables Theme System
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
/* Cards & Surfaces */
|
/* Cards & Surfaces */
|
||||||
--card: #ffffff;
|
--card: #ffffff;
|
||||||
--card-hover: #f1f5f9;
|
--card-hover: #e2e8f0;
|
||||||
--card-border: #e2e8f0;
|
--card-border: #e2e8f0;
|
||||||
|
|
||||||
/* Primary - Cyan/Teal */
|
/* Primary - Cyan/Teal */
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
/* Accent Colors */
|
/* Accent Colors */
|
||||||
--accent: #8b5cf6;
|
--accent: #8b5cf6;
|
||||||
--accent-hover: #7c3aed;
|
--accent-hover: #7c3aed;
|
||||||
|
--purple: #8b5cf6;
|
||||||
|
|
||||||
/* Status */
|
/* Status */
|
||||||
--success: #059669;
|
--success: #059669;
|
||||||
@@ -75,7 +76,7 @@
|
|||||||
|
|
||||||
/* Cards & Surfaces */
|
/* Cards & Surfaces */
|
||||||
--card: #1e293b;
|
--card: #1e293b;
|
||||||
--card-hover: #283548;
|
--card-hover: #334155;
|
||||||
--card-border: #2d3d53;
|
--card-border: #2d3d53;
|
||||||
|
|
||||||
/* Primary - Cyan/Teal (softened) */
|
/* Primary - Cyan/Teal (softened) */
|
||||||
@@ -103,6 +104,7 @@
|
|||||||
/* Accent Colors */
|
/* Accent Colors */
|
||||||
--accent: #a78bfa;
|
--accent: #a78bfa;
|
||||||
--accent-hover: #c4b5fd;
|
--accent-hover: #c4b5fd;
|
||||||
|
--purple: #a78bfa;
|
||||||
|
|
||||||
/* Status (softened) */
|
/* Status (softened) */
|
||||||
--success: #4ade80;
|
--success: #4ade80;
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ const geistMono = Geist_Mono({
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Workshop Manager',
|
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({
|
export default function RootLayout({
|
||||||
@@ -25,6 +29,13 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="fr" suppressHydrationWarning>
|
<html lang="fr" suppressHydrationWarning>
|
||||||
|
<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} antialiased`}>
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
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,13 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
|
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
|
||||||
import { getMotivatorSessionById } from '@/services/moving-motivators';
|
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 { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators';
|
||||||
import { Badge, CollaboratorDisplay } from '@/components/ui';
|
import { Badge, CollaboratorDisplay } from '@/components/ui';
|
||||||
import { EditableMotivatorTitle } from './EditableTitle';
|
import { EditableMotivatorTitle } from '@/components/ui';
|
||||||
|
|
||||||
interface MotivatorSessionPageProps {
|
interface MotivatorSessionPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -18,7 +21,10 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
|
|||||||
return null;
|
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) {
|
if (!session) {
|
||||||
notFound();
|
notFound();
|
||||||
@@ -29,8 +35,8 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted mb-2">
|
<div className="flex items-center gap-2 text-sm text-muted mb-2">
|
||||||
<Link href="/sessions?tab=motivators" className="hover:text-foreground">
|
<Link href={getSessionsTabUrl('motivators')} className="hover:text-foreground">
|
||||||
Moving Motivators
|
{getWorkshop('motivators').label}
|
||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-foreground">{session.title}</span>
|
<span className="text-foreground">{session.title}</span>
|
||||||
@@ -46,10 +52,14 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
|
|||||||
<EditableMotivatorTitle
|
<EditableMotivatorTitle
|
||||||
sessionId={session.id}
|
sessionId={session.id}
|
||||||
initialTitle={session.title}
|
initialTitle={session.title}
|
||||||
isOwner={session.isOwner}
|
canEdit={session.canEdit}
|
||||||
/>
|
/>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<CollaboratorDisplay collaborator={session.resolvedParticipant} size="lg" showEmail />
|
<CollaboratorDisplay
|
||||||
|
collaborator={session.resolvedParticipant as ResolvedCollaborator}
|
||||||
|
size="lg"
|
||||||
|
showEmail
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -75,6 +85,7 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
|
|||||||
shares={session.shares}
|
shares={session.shares}
|
||||||
isOwner={session.isOwner}
|
isOwner={session.isOwner}
|
||||||
canEdit={session.canEdit}
|
canEdit={session.canEdit}
|
||||||
|
userTeams={userTeams}
|
||||||
>
|
>
|
||||||
<MotivatorBoard sessionId={session.id} cards={session.cards} canEdit={session.canEdit} />
|
<MotivatorBoard sessionId={session.id} cards={session.cards} canEdit={session.canEdit} />
|
||||||
</MotivatorLiveWrapper>
|
</MotivatorLiveWrapper>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
|
ParticipantInput,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { createMotivatorSession } from '@/actions/moving-motivators';
|
import { createMotivatorSession } from '@/actions/moving-motivators';
|
||||||
|
|
||||||
@@ -72,12 +73,7 @@ export default function NewMotivatorSessionPage() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<ParticipantInput name="participant" required />
|
||||||
label="Nom du participant"
|
|
||||||
name="participant"
|
|
||||||
placeholder="Ex: Jean Dupont"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-border bg-card-hover p-4">
|
<div className="rounded-lg border border-border bg-card-hover p-4">
|
||||||
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
|
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
|
||||||
|
|||||||
65
src/app/objectives/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { getUserOKRs } from '@/services/okrs';
|
||||||
|
import { Card } 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 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground flex items-center gap-2">
|
||||||
|
<span className="text-3xl">🎯</span>
|
||||||
|
Mes Objectifs
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-muted">
|
||||||
|
Suivez la progression de vos OKRs à travers toutes vos équipes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
476
src/app/page.tsx
@@ -1,4 +1,5 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { WORKSHOPS, getSessionsTabUrl } from '@/lib/workshops';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
@@ -20,38 +21,20 @@ export default function Home() {
|
|||||||
<h2 className="mb-8 text-center text-2xl font-bold text-foreground">
|
<h2 className="mb-8 text-center text-2xl font-bold text-foreground">
|
||||||
Choisissez votre atelier
|
Choisissez votre atelier
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid gap-8 md:grid-cols-2 max-w-4xl mx-auto">
|
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3 max-w-6xl mx-auto">
|
||||||
{/* SWOT Workshop Card */}
|
{WORKSHOPS.map((w) => (
|
||||||
<WorkshopCard
|
<WorkshopCard
|
||||||
href="/sessions?tab=swot"
|
key={w.id}
|
||||||
icon="📊"
|
href={getSessionsTabUrl(w.id)}
|
||||||
title="Analyse SWOT"
|
icon={w.icon}
|
||||||
tagline="Analysez. Planifiez. Progressez."
|
title={w.cardLabel}
|
||||||
description="Cartographiez les forces et faiblesses de vos collaborateurs. Identifiez opportunités et menaces pour définir des actions concrètes."
|
tagline={w.home.tagline}
|
||||||
features={[
|
description={w.home.description}
|
||||||
'Matrice interactive Forces/Faiblesses/Opportunités/Menaces',
|
features={w.home.features}
|
||||||
'Actions croisées et plan de développement',
|
accentColor={w.accentColor}
|
||||||
'Collaboration en temps réel',
|
newHref={w.newPath}
|
||||||
]}
|
/>
|
||||||
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -250,6 +233,379 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
|
|
||||||
|
{/* 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 */}
|
{/* Benefits Section */}
|
||||||
<section className="rounded-2xl border border-border bg-card p-8">
|
<section className="rounded-2xl border border-border bg-card p-8">
|
||||||
<h2 className="mb-8 text-center text-2xl font-bold text-foreground">
|
<h2 className="mb-8 text-center text-2xl font-bold text-foreground">
|
||||||
@@ -303,7 +659,7 @@ function WorkshopCard({
|
|||||||
newHref: string;
|
newHref: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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 */}
|
{/* Accent gradient */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-x-0 top-0 h-1 opacity-80"
|
className="absolute inset-x-0 top-0 h-1 opacity-80"
|
||||||
@@ -420,3 +776,57 @@ function MotivatorPill({ icon, name, color }: { icon: string; name: string; colo
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
53
src/app/sessions/NewWorkshopDropdown.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'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="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
Nouvel atelier
|
||||||
|
<svg
|
||||||
|
className={`h-4 w-4 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-56 rounded-lg border border-border bg-card py-1 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"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<span className="text-lg">{w.icon}</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{w.label}</div>
|
||||||
|
<div className="text-xs text-muted">{w.description}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useTransition } from 'react';
|
import { useState, useTransition, useRef } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
@@ -14,22 +14,26 @@ import {
|
|||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
|
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
|
||||||
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
|
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 {
|
||||||
|
type WorkshopTabType,
|
||||||
|
type WorkshopTypeId,
|
||||||
|
WORKSHOPS,
|
||||||
|
VALID_TAB_PARAMS,
|
||||||
|
getWorkshop,
|
||||||
|
getSessionPath,
|
||||||
|
} from '@/lib/workshops';
|
||||||
|
import { useClickOutside } from '@/hooks/useClickOutside';
|
||||||
|
|
||||||
type WorkshopType = 'all' | 'swot' | 'motivators' | 'byPerson';
|
import type { Share } from '@/lib/share-utils';
|
||||||
|
|
||||||
const VALID_TABS: WorkshopType[] = ['all', 'swot', 'motivators', 'byPerson'];
|
const TYPE_TABS = [
|
||||||
|
{ value: 'all' as const, icon: '📋', label: 'Tous' },
|
||||||
interface ShareUser {
|
{ value: 'team' as const, icon: '🏢', label: 'Équipe' },
|
||||||
id: string;
|
...WORKSHOPS.map((w) => ({ value: w.id, icon: w.icon, label: w.labelShort })),
|
||||||
name: string | null;
|
];
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Share {
|
|
||||||
id: string;
|
|
||||||
role: 'VIEWER' | 'EDITOR';
|
|
||||||
user: ShareUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResolvedCollaborator {
|
interface ResolvedCollaborator {
|
||||||
raw: string;
|
raw: string;
|
||||||
@@ -52,6 +56,8 @@ interface SwotSession {
|
|||||||
shares: Share[];
|
shares: Share[];
|
||||||
_count: { items: number; actions: number };
|
_count: { items: number; actions: number };
|
||||||
workshopType: 'swot';
|
workshopType: 'swot';
|
||||||
|
isTeamCollab?: true;
|
||||||
|
canEdit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MotivatorSession {
|
interface MotivatorSession {
|
||||||
@@ -66,36 +72,92 @@ interface MotivatorSession {
|
|||||||
shares: Share[];
|
shares: Share[];
|
||||||
_count: { cards: number };
|
_count: { cards: number };
|
||||||
workshopType: 'motivators';
|
workshopType: 'motivators';
|
||||||
|
isTeamCollab?: true;
|
||||||
|
canEdit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnySession = SwotSession | MotivatorSession;
|
interface YearReviewSession {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
participant: string;
|
||||||
|
resolvedParticipant: ResolvedCollaborator;
|
||||||
|
year: number;
|
||||||
|
updatedAt: Date;
|
||||||
|
isOwner: boolean;
|
||||||
|
role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||||
|
user: { id: string; name: string | null; email: string };
|
||||||
|
shares: Share[];
|
||||||
|
_count: { items: number };
|
||||||
|
workshopType: 'year-review';
|
||||||
|
isTeamCollab?: true;
|
||||||
|
canEdit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnySession = SwotSession | MotivatorSession | YearReviewSession | WeeklyCheckInSession | WeatherSession;
|
||||||
|
|
||||||
interface WorkshopTabsProps {
|
interface WorkshopTabsProps {
|
||||||
swotSessions: SwotSession[];
|
swotSessions: SwotSession[];
|
||||||
motivatorSessions: MotivatorSession[];
|
motivatorSessions: MotivatorSession[];
|
||||||
}
|
yearReviewSessions: YearReviewSession[];
|
||||||
|
weeklyCheckInSessions: WeeklyCheckInSession[];
|
||||||
// Helper to get participant name from any session
|
weatherSessions: WeatherSession[];
|
||||||
function getParticipant(session: AnySession): string {
|
teamCollabSessions?: (AnySession & { isTeamCollab?: true })[];
|
||||||
return session.workshopType === 'swot'
|
|
||||||
? (session as SwotSession).collaborator
|
|
||||||
: (session as MotivatorSession).participant;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to get resolved collaborator from any session
|
// Helper to get resolved collaborator from any session
|
||||||
function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
|
function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
|
||||||
return session.workshopType === 'swot'
|
if (session.workshopType === 'swot') {
|
||||||
? (session as SwotSession).resolvedCollaborator
|
return (session as SwotSession).resolvedCollaborator;
|
||||||
: (session as MotivatorSession).resolvedParticipant;
|
} else if (session.workshopType === 'year-review') {
|
||||||
}
|
return (session as YearReviewSession).resolvedParticipant;
|
||||||
|
} else if (session.workshopType === 'weekly-checkin') {
|
||||||
// Get display name for grouping - prefer matched user name
|
return (session as WeeklyCheckInSession).resolvedParticipant;
|
||||||
function getDisplayName(session: AnySession): string {
|
} else if (session.workshopType === 'weather') {
|
||||||
const resolved = getResolvedCollaborator(session);
|
// For weather sessions, use the owner as the "participant" since it's a personal weather
|
||||||
if (resolved.matchedUser?.name) {
|
const weatherSession = session as WeatherSession;
|
||||||
return resolved.matchedUser.name;
|
return {
|
||||||
|
raw: weatherSession.user.name || weatherSession.user.email,
|
||||||
|
matchedUser: {
|
||||||
|
id: weatherSession.user.id,
|
||||||
|
email: weatherSession.user.email,
|
||||||
|
name: weatherSession.user.name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return (session as MotivatorSession).resolvedParticipant;
|
||||||
}
|
}
|
||||||
return resolved.raw;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get grouping key - use matched user ID if available, otherwise normalized raw string
|
// Get grouping key - use matched user ID if available, otherwise normalized raw string
|
||||||
@@ -132,16 +194,26 @@ function groupByPerson(sessions: AnySession[]): Map<string, AnySession[]> {
|
|||||||
return grouped;
|
return grouped;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsProps) {
|
export function WorkshopTabs({
|
||||||
|
swotSessions,
|
||||||
|
motivatorSessions,
|
||||||
|
yearReviewSessions,
|
||||||
|
weeklyCheckInSessions,
|
||||||
|
weatherSessions,
|
||||||
|
teamCollabSessions = [],
|
||||||
|
}: WorkshopTabsProps) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false);
|
||||||
|
|
||||||
// Get tab from URL or default to 'all'
|
// Get tab from URL or default to 'all'
|
||||||
const tabParam = searchParams.get('tab');
|
const tabParam = searchParams.get('tab');
|
||||||
const activeTab: WorkshopType =
|
const activeTab: WorkshopTabType =
|
||||||
tabParam && VALID_TABS.includes(tabParam as WorkshopType) ? (tabParam as WorkshopType) : 'all';
|
tabParam && VALID_TAB_PARAMS.includes(tabParam as WorkshopTabType)
|
||||||
|
? (tabParam as WorkshopTabType)
|
||||||
|
: 'all';
|
||||||
|
|
||||||
const setActiveTab = (tab: WorkshopType) => {
|
const setActiveTab = (tab: WorkshopTabType) => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
if (tab === 'all') {
|
if (tab === 'all') {
|
||||||
params.delete('tab');
|
params.delete('tab');
|
||||||
@@ -151,22 +223,36 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
|
|||||||
router.push(`/sessions${params.toString() ? `?${params.toString()}` : ''}`);
|
router.push(`/sessions${params.toString() ? `?${params.toString()}` : ''}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Combine and sort all sessions
|
// Combine and sort all sessions (exclude team collab from main list - they're shown separately)
|
||||||
const allSessions: AnySession[] = [...swotSessions, ...motivatorSessions].sort(
|
const allSessions: AnySession[] = [
|
||||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
...swotSessions,
|
||||||
);
|
...motivatorSessions,
|
||||||
|
...yearReviewSessions,
|
||||||
|
...weeklyCheckInSessions,
|
||||||
|
...weatherSessions,
|
||||||
|
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||||
|
|
||||||
// Filter based on active tab (for non-byPerson tabs)
|
// Filter based on active tab (for non-byPerson tabs)
|
||||||
const filteredSessions =
|
const filteredSessions =
|
||||||
activeTab === 'all' || activeTab === 'byPerson'
|
activeTab === 'all' || activeTab === 'byPerson'
|
||||||
? allSessions
|
? allSessions
|
||||||
: activeTab === 'swot'
|
: activeTab === 'team'
|
||||||
|
? teamCollabSessions
|
||||||
|
: activeTab === 'swot'
|
||||||
? swotSessions
|
? swotSessions
|
||||||
: motivatorSessions;
|
: activeTab === 'motivators'
|
||||||
|
? motivatorSessions
|
||||||
|
: activeTab === 'year-review'
|
||||||
|
? yearReviewSessions
|
||||||
|
: activeTab === 'weekly-checkin'
|
||||||
|
? weeklyCheckInSessions
|
||||||
|
: weatherSessions;
|
||||||
|
|
||||||
// Separate by ownership
|
// Separate by ownership (for non-team tab: owned, shared, teamCollab)
|
||||||
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
|
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
|
||||||
const sharedSessions = filteredSessions.filter((s) => !s.isOwner);
|
const sharedSessions = filteredSessions.filter((s) => !s.isOwner && !(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab);
|
||||||
|
const teamCollabFiltered =
|
||||||
|
activeTab === 'all' ? teamCollabSessions : activeTab === 'team' ? teamCollabSessions : [];
|
||||||
|
|
||||||
// Group by person (all sessions - owned and shared)
|
// Group by person (all sessions - owned and shared)
|
||||||
const sessionsByPerson = groupByPerson(allSessions);
|
const sessionsByPerson = groupByPerson(allSessions);
|
||||||
@@ -177,7 +263,7 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-2 border-b border-border pb-4 flex-wrap">
|
<div className="flex gap-2 border-b border-border pb-4 flex-wrap items-center">
|
||||||
<TabButton
|
<TabButton
|
||||||
active={activeTab === 'all'}
|
active={activeTab === 'all'}
|
||||||
onClick={() => setActiveTab('all')}
|
onClick={() => setActiveTab('all')}
|
||||||
@@ -192,19 +278,28 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
|
|||||||
label="Par personne"
|
label="Par personne"
|
||||||
count={sessionsByPerson.size}
|
count={sessionsByPerson.size}
|
||||||
/>
|
/>
|
||||||
<TabButton
|
{teamCollabSessions.length > 0 && (
|
||||||
active={activeTab === 'swot'}
|
<TabButton
|
||||||
onClick={() => setActiveTab('swot')}
|
active={activeTab === 'team'}
|
||||||
icon="📊"
|
onClick={() => setActiveTab('team')}
|
||||||
label="SWOT"
|
icon="🏢"
|
||||||
count={swotSessions.length}
|
label="Équipe"
|
||||||
/>
|
count={teamCollabSessions.length}
|
||||||
<TabButton
|
/>
|
||||||
active={activeTab === 'motivators'}
|
)}
|
||||||
onClick={() => setActiveTab('motivators')}
|
<TypeFilterDropdown
|
||||||
icon="🎯"
|
activeTab={activeTab}
|
||||||
label="Moving Motivators"
|
setActiveTab={setActiveTab}
|
||||||
count={motivatorSessions.length}
|
open={typeDropdownOpen}
|
||||||
|
onOpenChange={setTypeDropdownOpen}
|
||||||
|
counts={{
|
||||||
|
swot: swotSessions.length,
|
||||||
|
motivators: motivatorSessions.length,
|
||||||
|
'year-review': yearReviewSessions.length,
|
||||||
|
'weekly-checkin': weeklyCheckInSessions.length,
|
||||||
|
weather: weatherSessions.length,
|
||||||
|
team: teamCollabSessions.length,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -235,6 +330,28 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
) : activeTab === 'team' ? (
|
||||||
|
teamCollabSessions.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted">
|
||||||
|
Aucun atelier de vos collaborateurs d'équipe (non partagés avec vous)
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-muted mb-4">
|
||||||
|
🏢 Ateliers de l'équipe – non partagés ({teamCollabSessions.length})
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted mb-4">
|
||||||
|
En tant qu'admin d'équipe, vous voyez les ateliers de vos collaborateurs qui ne vous sont pas encore partagés.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{teamCollabSessions.map((s) => (
|
||||||
|
<SessionCard key={s.id} session={s} isTeamCollab />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : filteredSessions.length === 0 ? (
|
) : filteredSessions.length === 0 ? (
|
||||||
<div className="text-center py-12 text-muted">Aucun atelier de ce type pour le moment</div>
|
<div className="text-center py-12 text-muted">Aucun atelier de ce type pour le moment</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -266,6 +383,107 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Team collab sessions (non-shared) - grayed out, admin view only */}
|
||||||
|
{activeTab === 'all' && teamCollabFiltered.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-muted mb-4">
|
||||||
|
🏢 Équipe – non partagés ({teamCollabFiltered.length})
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{teamCollabFiltered.map((s) => (
|
||||||
|
<SessionCard key={s.id} session={s} isTeamCollab />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
const current = TYPE_TABS.find((t) => t.value === activeTab) ?? TYPE_TABS[0];
|
||||||
|
const isTypeSelected = activeTab !== 'all' && activeTab !== 'byPerson';
|
||||||
|
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-2 px-3 py-2 rounded-lg font-medium text-sm transition-colors
|
||||||
|
${isTypeSelected ? 'bg-primary text-primary-foreground' : 'text-muted hover:bg-card-hover hover:text-foreground'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span>{current.icon}</span>
|
||||||
|
<span>{current.label}</span>
|
||||||
|
<Badge variant={isTypeSelected ? 'default' : 'primary'} className="ml-1 text-xs">
|
||||||
|
{isTypeSelected ? counts[activeTab] ?? 0 : totalCount}
|
||||||
|
</Badge>
|
||||||
|
<svg
|
||||||
|
className={`h-4 w-4 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-44 rounded-lg border border-border bg-card py-1 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"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>📋</span>
|
||||||
|
<span>Tous</span>
|
||||||
|
</span>
|
||||||
|
<Badge variant="primary" className="text-xs">
|
||||||
|
{totalCount}
|
||||||
|
</Badge>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>{t.icon}</span>
|
||||||
|
<span>{t.label}</span>
|
||||||
|
</span>
|
||||||
|
<Badge variant="primary" className="text-xs">
|
||||||
|
{counts[t.value] ?? 0}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -287,9 +505,10 @@ function TabButton({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`
|
className={`
|
||||||
flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors
|
flex items-center gap-2 px-3 py-2 rounded-lg font-medium text-sm transition-colors
|
||||||
${
|
${
|
||||||
active
|
active
|
||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-primary text-primary-foreground'
|
||||||
@@ -299,14 +518,14 @@ function TabButton({
|
|||||||
>
|
>
|
||||||
<span>{icon}</span>
|
<span>{icon}</span>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<Badge variant={active ? 'default' : 'primary'} className="ml-1">
|
<Badge variant={active ? 'default' : 'primary'} className="ml-1 text-xs">
|
||||||
{count}
|
{count}
|
||||||
</Badge>
|
</Badge>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionCard({ session }: { session: AnySession }) {
|
function SessionCard({ session, isTeamCollab = false }: { session: AnySession; isTeamCollab?: boolean }) {
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
@@ -316,22 +535,41 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
const [editParticipant, setEditParticipant] = useState(
|
const [editParticipant, setEditParticipant] = useState(
|
||||||
session.workshopType === 'swot'
|
session.workshopType === 'swot'
|
||||||
? (session as SwotSession).collaborator
|
? (session as SwotSession).collaborator
|
||||||
: (session as MotivatorSession).participant
|
: session.workshopType === 'year-review'
|
||||||
|
? (session as YearReviewSession).participant
|
||||||
|
: session.workshopType === 'weather'
|
||||||
|
? ''
|
||||||
|
: (session as MotivatorSession).participant
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const workshop = getWorkshop(session.workshopType as WorkshopTypeId);
|
||||||
const isSwot = session.workshopType === 'swot';
|
const isSwot = session.workshopType === 'swot';
|
||||||
const href = isSwot ? `/sessions/${session.id}` : `/motivators/${session.id}`;
|
const isYearReview = session.workshopType === 'year-review';
|
||||||
const icon = isSwot ? '📊' : '🎯';
|
const isWeeklyCheckIn = session.workshopType === 'weekly-checkin';
|
||||||
|
const isWeather = session.workshopType === 'weather';
|
||||||
|
const href = getSessionPath(session.workshopType as WorkshopTypeId, session.id);
|
||||||
const participant = isSwot
|
const participant = isSwot
|
||||||
? (session as SwotSession).collaborator
|
? (session as SwotSession).collaborator
|
||||||
: (session as MotivatorSession).participant;
|
: isYearReview
|
||||||
const accentColor = isSwot ? '#06b6d4' : '#8b5cf6';
|
? (session as YearReviewSession).participant
|
||||||
|
: isWeeklyCheckIn
|
||||||
|
? (session as WeeklyCheckInSession).participant
|
||||||
|
: isWeather
|
||||||
|
? (session as WeatherSession).user.name || (session as WeatherSession).user.email
|
||||||
|
: (session as MotivatorSession).participant;
|
||||||
|
const accentColor = workshop.accentColor;
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = isSwot
|
const result = isSwot
|
||||||
? await deleteSwotSession(session.id)
|
? await deleteSwotSession(session.id)
|
||||||
: await deleteMotivatorSession(session.id);
|
: isYearReview
|
||||||
|
? await deleteYearReviewSession(session.id)
|
||||||
|
: isWeeklyCheckIn
|
||||||
|
? await deleteWeeklyCheckInSession(session.id)
|
||||||
|
: isWeather
|
||||||
|
? await deleteWeatherSession(session.id)
|
||||||
|
: await deleteMotivatorSession(session.id);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowDeleteModal(false);
|
setShowDeleteModal(false);
|
||||||
@@ -345,10 +583,22 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = isSwot
|
const result = isSwot
|
||||||
? await updateSwotSession(session.id, { title: editTitle, collaborator: editParticipant })
|
? await updateSwotSession(session.id, { title: editTitle, collaborator: editParticipant })
|
||||||
: await updateMotivatorSession(session.id, {
|
: isYearReview
|
||||||
title: editTitle,
|
? await updateYearReviewSession(session.id, {
|
||||||
participant: editParticipant,
|
title: editTitle,
|
||||||
});
|
participant: editParticipant,
|
||||||
|
})
|
||||||
|
: isWeeklyCheckIn
|
||||||
|
? await updateWeeklyCheckInSession(session.id, {
|
||||||
|
title: editTitle,
|
||||||
|
participant: editParticipant,
|
||||||
|
})
|
||||||
|
: isWeather
|
||||||
|
? await updateWeatherSession(session.id, { title: editTitle })
|
||||||
|
: await updateMotivatorSession(session.id, {
|
||||||
|
title: editTitle,
|
||||||
|
participant: editParticipant,
|
||||||
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
@@ -365,11 +615,10 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const editParticipantLabel = workshop.participantLabel;
|
||||||
<>
|
|
||||||
<div className="relative group">
|
const cardContent = (
|
||||||
<Link href={href}>
|
<Card hover={!isTeamCollab} className={`h-full p-4 relative overflow-hidden ${isTeamCollab ? 'opacity-60' : ''}`}>
|
||||||
<Card hover className="h-full p-4 relative overflow-hidden">
|
|
||||||
{/* Accent bar */}
|
{/* Accent bar */}
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 left-0 right-0 h-1"
|
className="absolute top-0 left-0 right-0 h-1"
|
||||||
@@ -378,7 +627,7 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
|
|
||||||
{/* Header: Icon + Title + Role badge */}
|
{/* Header: Icon + Title + Role badge */}
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="text-xl">{icon}</span>
|
<span className="text-xl">{workshop.icon}</span>
|
||||||
<h3 className="font-semibold text-foreground line-clamp-1 flex-1">{session.title}</h3>
|
<h3 className="font-semibold text-foreground line-clamp-1 flex-1">{session.title}</h3>
|
||||||
{!session.isOwner && (
|
{!session.isOwner && (
|
||||||
<span
|
<span
|
||||||
@@ -414,6 +663,34 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{(session as SwotSession)._count.actions} actions</span>
|
<span>{(session as SwotSession)._count.actions} actions</span>
|
||||||
</>
|
</>
|
||||||
|
) : isYearReview ? (
|
||||||
|
<>
|
||||||
|
<span>{(session as YearReviewSession)._count.items} items</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>Année {(session as YearReviewSession).year}</span>
|
||||||
|
</>
|
||||||
|
) : isWeeklyCheckIn ? (
|
||||||
|
<>
|
||||||
|
<span>{(session as WeeklyCheckInSession)._count.items} items</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>
|
||||||
|
{new Date((session as WeeklyCheckInSession).date).toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : isWeather ? (
|
||||||
|
<>
|
||||||
|
<span>{(session as WeatherSession)._count.entries} membres</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>
|
||||||
|
{new Date((session as WeatherSession).date).toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span>{(session as MotivatorSession)._count.cards}/10</span>
|
<span>{(session as MotivatorSession)._count.cards}/10</span>
|
||||||
)}
|
)}
|
||||||
@@ -452,10 +729,21 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="relative group">
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={isTeamCollab ? 'cursor-pointer' : ''}
|
||||||
|
title={isTeamCollab ? "Atelier de l'équipe – éditable en tant qu'admin" : undefined}
|
||||||
|
>
|
||||||
|
{cardContent}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Action buttons - only for owner */}
|
{/* Edit: owner, EDITOR, or team admin | Delete: owner or team admin only (not EDITOR) */}
|
||||||
{session.isOwner && (
|
{(session.isOwner || session.role === 'EDITOR' || session.isTeamCollab) && (
|
||||||
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -475,6 +763,7 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
{(session.isOwner || session.isTeamCollab) && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -493,6 +782,7 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -528,15 +818,17 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
htmlFor="edit-participant"
|
htmlFor="edit-participant"
|
||||||
className="block text-sm font-medium text-foreground mb-1"
|
className="block text-sm font-medium text-foreground mb-1"
|
||||||
>
|
>
|
||||||
{isSwot ? 'Collaborateur' : 'Participant'}
|
{editParticipantLabel}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
{!isWeather && (
|
||||||
id="edit-participant"
|
<Input
|
||||||
value={editParticipant}
|
id="edit-participant"
|
||||||
onChange={(e) => setEditParticipant(e.target.value)}
|
value={editParticipant}
|
||||||
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'}
|
onChange={(e) => setEditParticipant(e.target.value)}
|
||||||
required
|
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'}
|
||||||
/>
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button
|
<Button
|
||||||
@@ -549,7 +841,7 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending || !editTitle.trim() || !editParticipant.trim()}
|
disabled={isPending || !editTitle.trim() || (!isWeather && !editParticipant.trim())}
|
||||||
>
|
>
|
||||||
{isPending ? 'Enregistrement...' : 'Enregistrer'}
|
{isPending ? 'Enregistrement...' : 'Enregistrer'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
|
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
|
||||||
import { getSessionById } from '@/services/sessions';
|
import { getSessionById } from '@/services/sessions';
|
||||||
|
import { getUserTeams } from '@/services/teams';
|
||||||
import { SwotBoard } from '@/components/swot/SwotBoard';
|
import { SwotBoard } from '@/components/swot/SwotBoard';
|
||||||
import { SessionLiveWrapper } from '@/components/collaboration';
|
import { SessionLiveWrapper } from '@/components/collaboration';
|
||||||
import { EditableTitle } from '@/components/session';
|
import { EditableSessionTitle } from '@/components/ui';
|
||||||
import { Badge, CollaboratorDisplay } from '@/components/ui';
|
import { Badge, CollaboratorDisplay } from '@/components/ui';
|
||||||
|
|
||||||
interface SessionPageProps {
|
interface SessionPageProps {
|
||||||
@@ -19,7 +21,10 @@ export default async function SessionPage({ params }: SessionPageProps) {
|
|||||||
return null;
|
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) {
|
if (!session) {
|
||||||
notFound();
|
notFound();
|
||||||
@@ -30,8 +35,8 @@ export default async function SessionPage({ params }: SessionPageProps) {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted mb-2">
|
<div className="flex items-center gap-2 text-sm text-muted mb-2">
|
||||||
<Link href="/sessions?tab=swot" className="hover:text-foreground">
|
<Link href={getSessionsTabUrl('swot')} className="hover:text-foreground">
|
||||||
SWOT
|
{getWorkshop('swot').labelShort}
|
||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-foreground">{session.title}</span>
|
<span className="text-foreground">{session.title}</span>
|
||||||
@@ -44,10 +49,10 @@ export default async function SessionPage({ params }: SessionPageProps) {
|
|||||||
|
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<EditableTitle
|
<EditableSessionTitle
|
||||||
sessionId={session.id}
|
sessionId={session.id}
|
||||||
initialTitle={session.title}
|
initialTitle={session.title}
|
||||||
isOwner={session.isOwner}
|
canEdit={session.canEdit}
|
||||||
/>
|
/>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<CollaboratorDisplay
|
<CollaboratorDisplay
|
||||||
@@ -79,6 +84,7 @@ export default async function SessionPage({ params }: SessionPageProps) {
|
|||||||
shares={session.shares}
|
shares={session.shares}
|
||||||
isOwner={session.isOwner}
|
isOwner={session.isOwner}
|
||||||
canEdit={session.canEdit}
|
canEdit={session.canEdit}
|
||||||
|
userTeams={userTeams}
|
||||||
>
|
>
|
||||||
<SwotBoard sessionId={session.id} items={session.items} actions={session.actions} />
|
<SwotBoard sessionId={session.id} items={session.items} actions={session.actions} />
|
||||||
</SessionLiveWrapper>
|
</SessionLiveWrapper>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
|
ParticipantInput,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
|
|
||||||
export default function NewSessionPage() {
|
export default function NewSessionPage() {
|
||||||
@@ -82,12 +83,7 @@ export default function NewSessionPage() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<ParticipantInput name="collaborator" required />
|
||||||
label="Nom du collaborateur"
|
|
||||||
name="collaborator"
|
|
||||||
placeholder="Ex: Jean Dupont"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
<div className="flex gap-3 pt-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import Link from 'next/link';
|
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { getSessionsByUserId } from '@/services/sessions';
|
import {
|
||||||
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
|
getSessionsByUserId,
|
||||||
import { Card, Button } from '@/components/ui';
|
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 { Card } from '@/components/ui';
|
||||||
|
import { withWorkshopType } from '@/lib/workshops';
|
||||||
import { WorkshopTabs } from './WorkshopTabs';
|
import { WorkshopTabs } from './WorkshopTabs';
|
||||||
|
import { NewWorkshopDropdown } from './NewWorkshopDropdown';
|
||||||
|
|
||||||
function WorkshopTabsSkeleton() {
|
function WorkshopTabsSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -32,27 +51,52 @@ export default async function SessionsPage() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch both SWOT and Moving Motivators sessions
|
// Fetch sessions (owned + shared) and team collab sessions (for team admins, non-shared)
|
||||||
const [swotSessions, motivatorSessions] = await Promise.all([
|
const [
|
||||||
|
swotSessions,
|
||||||
|
motivatorSessions,
|
||||||
|
yearReviewSessions,
|
||||||
|
weeklyCheckInSessions,
|
||||||
|
weatherSessions,
|
||||||
|
teamSwotSessions,
|
||||||
|
teamMotivatorSessions,
|
||||||
|
teamYearReviewSessions,
|
||||||
|
teamWeeklyCheckInSessions,
|
||||||
|
teamWeatherSessions,
|
||||||
|
] = await Promise.all([
|
||||||
getSessionsByUserId(session.user.id),
|
getSessionsByUserId(session.user.id),
|
||||||
getMotivatorSessionsByUserId(session.user.id),
|
getMotivatorSessionsByUserId(session.user.id),
|
||||||
|
getYearReviewSessionsByUserId(session.user.id),
|
||||||
|
getWeeklyCheckInSessionsByUserId(session.user.id),
|
||||||
|
getWeatherSessionsByUserId(session.user.id),
|
||||||
|
getTeamSwotSessions(session.user.id),
|
||||||
|
getTeamMotivatorSessions(session.user.id),
|
||||||
|
getTeamYearReviewSessions(session.user.id),
|
||||||
|
getTeamWeeklyCheckInSessions(session.user.id),
|
||||||
|
getTeamWeatherSessions(session.user.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Add type to each session for unified display
|
// Add workshopType to each session for unified display
|
||||||
const allSwotSessions = swotSessions.map((s) => ({
|
const allSwotSessions = withWorkshopType(swotSessions, 'swot');
|
||||||
...s,
|
const allMotivatorSessions = withWorkshopType(motivatorSessions, 'motivators');
|
||||||
workshopType: 'swot' as const,
|
const allYearReviewSessions = withWorkshopType(yearReviewSessions, 'year-review');
|
||||||
}));
|
const allWeeklyCheckInSessions = withWorkshopType(weeklyCheckInSessions, 'weekly-checkin');
|
||||||
|
const allWeatherSessions = withWorkshopType(weatherSessions, 'weather');
|
||||||
|
|
||||||
const allMotivatorSessions = motivatorSessions.map((s) => ({
|
const teamSwotWithType = withWorkshopType(teamSwotSessions, 'swot');
|
||||||
...s,
|
const teamMotivatorWithType = withWorkshopType(teamMotivatorSessions, 'motivators');
|
||||||
workshopType: 'motivators' as const,
|
const teamYearReviewWithType = withWorkshopType(teamYearReviewSessions, 'year-review');
|
||||||
}));
|
const teamWeeklyCheckInWithType = withWorkshopType(teamWeeklyCheckInSessions, 'weekly-checkin');
|
||||||
|
const teamWeatherWithType = withWorkshopType(teamWeatherSessions, 'weather');
|
||||||
|
|
||||||
// Combine and sort by updatedAt
|
// Combine and sort by updatedAt
|
||||||
const allSessions = [...allSwotSessions, ...allMotivatorSessions].sort(
|
const allSessions = [
|
||||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
...allSwotSessions,
|
||||||
);
|
...allMotivatorSessions,
|
||||||
|
...allYearReviewSessions,
|
||||||
|
...allWeeklyCheckInSessions,
|
||||||
|
...allWeatherSessions,
|
||||||
|
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||||
|
|
||||||
const hasNoSessions = allSessions.length === 0;
|
const hasNoSessions = allSessions.length === 0;
|
||||||
|
|
||||||
@@ -64,20 +108,7 @@ export default async function SessionsPage() {
|
|||||||
<h1 className="text-3xl font-bold text-foreground">Mes Ateliers</h1>
|
<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>
|
<p className="mt-1 text-muted">Tous vos ateliers en un seul endroit</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<NewWorkshopDropdown />
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -88,27 +119,30 @@ export default async function SessionsPage() {
|
|||||||
Commencez votre premier atelier
|
Commencez votre premier atelier
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted mb-6 max-w-md mx-auto">
|
<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
|
Créez un atelier SWOT pour analyser les forces et faiblesses, un Moving Motivators pour
|
||||||
pour découvrir les motivations de vos collaborateurs.
|
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>
|
</p>
|
||||||
<div className="flex gap-3 justify-center">
|
<div className="flex justify-center">
|
||||||
<Link href="/sessions/new">
|
<NewWorkshopDropdown />
|
||||||
<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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Suspense fallback={<WorkshopTabsSkeleton />}>
|
<Suspense fallback={<WorkshopTabsSkeleton />}>
|
||||||
<WorkshopTabs swotSessions={allSwotSessions} motivatorSessions={allMotivatorSessions} />
|
<WorkshopTabs
|
||||||
|
swotSessions={allSwotSessions}
|
||||||
|
motivatorSessions={allMotivatorSessions}
|
||||||
|
yearReviewSessions={allYearReviewSessions}
|
||||||
|
weeklyCheckInSessions={allWeeklyCheckInSessions}
|
||||||
|
weatherSessions={allWeatherSessions}
|
||||||
|
teamCollabSessions={[
|
||||||
|
...teamSwotWithType,
|
||||||
|
...teamMotivatorWithType,
|
||||||
|
...teamYearReviewWithType,
|
||||||
|
...teamWeeklyCheckInWithType,
|
||||||
|
...teamWeatherWithType,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
167
src/app/teams/[id]/okrs/[okrId]/edit/page.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
'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 py-8">
|
||||||
|
<div className="text-center">Chargement...</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!okr) {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||||
|
<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 py-8">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
281
src/app/teams/[id]/okrs/[okrId]/page.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
'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 py-8">
|
||||||
|
<div className="text-center">Chargement...</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!okr) {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||||
|
<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 py-8">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
82
src/app/teams/[id]/okrs/new/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
'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 py-8">
|
||||||
|
<div className="text-center">Chargement...</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
85
src/app/teams/[id]/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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 } from '@/components/ui';
|
||||||
|
import { Card } 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 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<Link href="/teams" className="text-muted hover:text-foreground">
|
||||||
|
← Retour aux équipes
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground flex items-center gap-2">
|
||||||
|
<span className="text-3xl">👥</span>
|
||||||
|
{team.name}
|
||||||
|
</h1>
|
||||||
|
{team.description && <p className="mt-2 text-muted">{team.description}</p>}
|
||||||
|
</div>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
92
src/app/teams/new/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
'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 py-8">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
59
src/app/teams/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { TeamCard } from '@/components/teams';
|
||||||
|
import { Button } 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 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">Équipes</h1>
|
||||||
|
<p className="mt-1 text-muted">
|
||||||
|
{teams.length} équipe{teams.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Link href="/teams/new">
|
||||||
|
<Button className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent">
|
||||||
|
Créer une équipe
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/app/weather/[id]/page.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
|
||||||
|
import { getWeatherSessionById, getPreviousWeatherEntriesForUsers } from '@/services/weather';
|
||||||
|
import { getUserTeams } from '@/services/teams';
|
||||||
|
import { WeatherBoard, WeatherLiveWrapper, WeatherInfoPanel, WeatherAverageBar } from '@/components/weather';
|
||||||
|
import { Badge } from '@/components/ui';
|
||||||
|
import { EditableWeatherTitle } from '@/components/ui/EditableWeatherTitle';
|
||||||
|
|
||||||
|
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] = await Promise.all([
|
||||||
|
getPreviousWeatherEntriesForUsers(session.id, session.date, allUserIds),
|
||||||
|
getUserTeams(authSession.user.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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={getSessionsTabUrl('weather')} className="hover:text-foreground">
|
||||||
|
{getWorkshop('weather').labelShort}
|
||||||
|
</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>
|
||||||
|
<EditableWeatherTitle
|
||||||
|
sessionId={session.id}
|
||||||
|
initialTitle={session.title}
|
||||||
|
canEdit={session.canEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="primary">{session.entries.length} membres</Badge>
|
||||||
|
<span className="text-sm text-muted">
|
||||||
|
{new Date(session.date).toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info sur les catégories */}
|
||||||
|
<WeatherInfoPanel />
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
src/app/weather/new/page.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
'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 py-8">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/app/weekly-checkin/[id]/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
|
||||||
|
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, CollaboratorDisplay } from '@/components/ui';
|
||||||
|
import { EditableWeeklyCheckInTitle } 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 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted mb-2">
|
||||||
|
<Link href={getSessionsTabUrl('weekly-checkin')} className="hover:text-foreground">
|
||||||
|
{getWorkshop('weekly-checkin').label}
|
||||||
|
</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>
|
||||||
|
<EditableWeeklyCheckInTitle
|
||||||
|
sessionId={session.id}
|
||||||
|
initialTitle={session.title}
|
||||||
|
canEdit={session.canEdit}
|
||||||
|
/>
|
||||||
|
<div className="mt-2">
|
||||||
|
<CollaboratorDisplay collaborator={resolvedParticipant} size="lg" showEmail />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="primary">{session.items.length} items</Badge>
|
||||||
|
<span className="text-sm text-muted">
|
||||||
|
{new Date(session.date).toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/app/weekly-checkin/new/page.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
'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 py-8">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/app/year-review/[id]/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
|
||||||
|
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, CollaboratorDisplay } from '@/components/ui';
|
||||||
|
import { EditableYearReviewTitle } from '@/components/ui';
|
||||||
|
|
||||||
|
interface YearReviewSessionPageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function YearReviewSessionPage({ params }: YearReviewSessionPageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const authSession = await auth();
|
||||||
|
|
||||||
|
if (!authSession?.user?.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [session, 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 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted mb-2">
|
||||||
|
<Link href={getSessionsTabUrl('year-review')} className="hover:text-foreground">
|
||||||
|
{getWorkshop('year-review').label}
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground">{session.title}</span>
|
||||||
|
{!session.isOwner && (
|
||||||
|
<Badge variant="accent" className="ml-2">
|
||||||
|
Partagé par {session.user.name || session.user.email}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<EditableYearReviewTitle
|
||||||
|
sessionId={session.id}
|
||||||
|
initialTitle={session.title}
|
||||||
|
canEdit={session.canEdit}
|
||||||
|
/>
|
||||||
|
<div className="mt-2">
|
||||||
|
<CollaboratorDisplay
|
||||||
|
collaborator={session.resolvedParticipant as ResolvedCollaborator}
|
||||||
|
size="lg"
|
||||||
|
showEmail
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="primary">{session.items.length} items</Badge>
|
||||||
|
<Badge variant="default">Année {session.year}</Badge>
|
||||||
|
<span className="text-sm text-muted">
|
||||||
|
{new Date(session.updatedAt).toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live Wrapper + Board */}
|
||||||
|
<YearReviewLiveWrapper
|
||||||
|
sessionId={session.id}
|
||||||
|
sessionTitle={session.title}
|
||||||
|
currentUserId={authSession.user.id}
|
||||||
|
shares={session.shares}
|
||||||
|
isOwner={session.isOwner}
|
||||||
|
canEdit={session.canEdit}
|
||||||
|
userTeams={userTeams}
|
||||||
|
>
|
||||||
|
<YearReviewBoard sessionId={session.id} items={session.items} />
|
||||||
|
</YearReviewLiveWrapper>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
src/app/year-review/new/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
'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 py-8">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
100
src/components/collaboration/BaseSessionLiveWrapper.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
'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';
|
||||||
|
|
||||||
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { BaseSessionLiveWrapper } from './BaseSessionLiveWrapper';
|
||||||
import { useSessionLive, type LiveEvent } from '@/hooks/useSessionLive';
|
import { shareSessionAction, removeShareAction } from '@/actions/share';
|
||||||
import { LiveIndicator } from './LiveIndicator';
|
import type { TeamWithMembers, Share } from '@/lib/share-utils';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionLiveWrapperProps {
|
interface SessionLiveWrapperProps {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -28,6 +11,7 @@ interface SessionLiveWrapperProps {
|
|||||||
shares: Share[];
|
shares: Share[];
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
|
userTeams?: TeamWithMembers[];
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,95 +22,36 @@ export function SessionLiveWrapper({
|
|||||||
shares,
|
shares,
|
||||||
isOwner,
|
isOwner,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
userTeams = [],
|
||||||
children,
|
children,
|
||||||
}: SessionLiveWrapperProps) {
|
}: 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 (
|
return (
|
||||||
<>
|
<BaseSessionLiveWrapper
|
||||||
{/* Header toolbar */}
|
sessionId={sessionId}
|
||||||
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
|
sessionTitle={sessionTitle}
|
||||||
<div className="flex items-center gap-4">
|
currentUserId={currentUserId}
|
||||||
<LiveIndicator isConnected={isConnected} error={error} />
|
shares={shares}
|
||||||
|
isOwner={isOwner}
|
||||||
{lastEventUser && (
|
canEdit={canEdit}
|
||||||
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
|
userTeams={userTeams}
|
||||||
<span>✏️</span>
|
config={{
|
||||||
<span>{lastEventUser} édite...</span>
|
apiPath: 'sessions',
|
||||||
</div>
|
shareModal: {
|
||||||
)}
|
title: 'Partager la session',
|
||||||
|
sessionSubtitle: 'Session',
|
||||||
{!canEdit && (
|
helpText: (
|
||||||
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
|
<>
|
||||||
<span>👁️</span>
|
<strong>Éditeur</strong> : peut modifier les items et actions
|
||||||
<span>Mode lecture</span>
|
<br />
|
||||||
</div>
|
<strong>Lecteur</strong> : peut uniquement consulter
|
||||||
)}
|
</>
|
||||||
</div>
|
),
|
||||||
|
},
|
||||||
<div className="flex items-center gap-2">
|
onShareWithEmail: (email, role) => shareSessionAction(sessionId, email, role),
|
||||||
{/* Collaborators avatars */}
|
onRemoveShare: (userId) => removeShareAction(sessionId, userId),
|
||||||
{shares.length > 0 && (
|
}}
|
||||||
<div className="flex -space-x-2">
|
>
|
||||||
{shares.slice(0, 3).map((share) => (
|
{children}
|
||||||
<Avatar
|
</BaseSessionLiveWrapper>
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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,88 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useTransition } from 'react';
|
import { useState, useTransition } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
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';
|
import type { ShareRole } from '@prisma/client';
|
||||||
|
|
||||||
interface ShareUser {
|
type ShareTab = 'teamMember' | 'team' | 'email';
|
||||||
id: string;
|
|
||||||
name: string | null;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Share {
|
|
||||||
id: string;
|
|
||||||
role: ShareRole;
|
|
||||||
user: ShareUser;
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShareModalProps {
|
interface ShareModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
sessionId: string;
|
title: string;
|
||||||
|
sessionSubtitle?: string;
|
||||||
sessionTitle: string;
|
sessionTitle: string;
|
||||||
shares: Share[];
|
shares: Share[];
|
||||||
isOwner: boolean;
|
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({
|
export function ShareModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
sessionId,
|
title,
|
||||||
|
sessionSubtitle,
|
||||||
sessionTitle,
|
sessionTitle,
|
||||||
shares,
|
shares,
|
||||||
isOwner,
|
isOwner,
|
||||||
|
userTeams = [],
|
||||||
|
currentUserId = '',
|
||||||
|
onShareWithEmail,
|
||||||
|
onShareWithTeam,
|
||||||
|
onRemoveShare,
|
||||||
|
helpText,
|
||||||
}: ShareModalProps) {
|
}: ShareModalProps) {
|
||||||
|
const teamMembers = getTeamMembersForShare(userTeams, currentUserId);
|
||||||
|
const hasTeamShare = !!onShareWithTeam;
|
||||||
|
const [shareType, setShareType] = useState<ShareTab>('teamMember');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
const [teamId, setTeamId] = useState('');
|
||||||
|
const [selectedMemberId, setSelectedMemberId] = useState('');
|
||||||
const [role, setRole] = useState<ShareRole>('EDITOR');
|
const [role, setRole] = useState<ShareRole>('EDITOR');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setEmail('');
|
||||||
|
setTeamId('');
|
||||||
|
setSelectedMemberId('');
|
||||||
|
};
|
||||||
|
|
||||||
async function handleShare(e: React.FormEvent) {
|
async function handleShare(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
startTransition(async () => {
|
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) {
|
if (result.success) {
|
||||||
setEmail('');
|
resetForm();
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || 'Erreur lors du partage');
|
setError(result.error || 'Erreur lors du partage');
|
||||||
}
|
}
|
||||||
@@ -60,53 +91,157 @@ export function ShareModal({
|
|||||||
|
|
||||||
async function handleRemove(userId: string) {
|
async function handleRemove(userId: string) {
|
||||||
startTransition(async () => {
|
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 (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title="Partager la session">
|
<Modal isOpen={isOpen} onClose={onClose} title={title}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Session info */}
|
|
||||||
<div>
|
<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>
|
<p className="font-medium text-foreground">{sessionTitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Share form (only for owner) */}
|
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<form onSubmit={handleShare} className="space-y-4">
|
<form onSubmit={handleShare} className="space-y-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 border-b border-border pb-3 flex-wrap">
|
||||||
<Input
|
{tabs.map((tab) => (
|
||||||
type="email"
|
<button
|
||||||
placeholder="Email de l'utilisateur"
|
key={tab.value}
|
||||||
value={email}
|
type="button"
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onClick={() => {
|
||||||
className="flex-1"
|
setShareType(tab.value);
|
||||||
required
|
resetForm();
|
||||||
/>
|
}}
|
||||||
<select
|
className={`flex-1 min-w-0 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
value={role}
|
shareType === tab.value
|
||||||
onChange={(e) => setRole(e.target.value as ShareRole)}
|
? 'bg-primary text-primary-foreground'
|
||||||
className="rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground"
|
: 'bg-card-hover text-muted hover:text-foreground'
|
||||||
>
|
}`}
|
||||||
<option value="EDITOR">Éditeur</option>
|
>
|
||||||
<option value="VIEWER">Lecteur</option>
|
{tab.icon} {tab.label}
|
||||||
</select>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</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>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
<Button type="submit" disabled={isPending || !email} className="w-full">
|
<Button
|
||||||
{isPending ? 'Partage...' : 'Partager'}
|
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>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Current shares */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
|
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
|
||||||
|
|
||||||
{shares.length === 0 ? (
|
{shares.length === 0 ? (
|
||||||
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
|
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -125,7 +260,6 @@ export function ShareModal({
|
|||||||
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
|
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
|
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
|
||||||
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
|
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
|
||||||
@@ -137,12 +271,7 @@ export function ShareModal({
|
|||||||
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
|
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
|
||||||
title="Retirer l'accès"
|
title="Retirer l'accès"
|
||||||
>
|
>
|
||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
className="h-4 w-4"
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
|
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
|
||||||
@@ -158,14 +287,7 @@ export function ShareModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Help text */}
|
{helpText && <div className="rounded-lg bg-primary/5 p-3">{helpText}</div>}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
export { LiveIndicator } from './LiveIndicator';
|
export { LiveIndicator } from './LiveIndicator';
|
||||||
export { ShareModal } from './ShareModal';
|
export { ShareModal } from './ShareModal';
|
||||||
export { SessionLiveWrapper } from './SessionLiveWrapper';
|
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';
|
||||||
|
|||||||
@@ -4,14 +4,20 @@ import Link from 'next/link';
|
|||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useSession, signOut } from 'next-auth/react';
|
import { useSession, signOut } from 'next-auth/react';
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { Avatar } from '@/components/ui';
|
import { Avatar, RocketIcon } from '@/components/ui';
|
||||||
|
import { WORKSHOPS } from '@/lib/workshops';
|
||||||
|
import { useClickOutside } from '@/hooks/useClickOutside';
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [workshopsOpen, setWorkshopsOpen] = useState(false);
|
const [workshopsOpen, setWorkshopsOpen] = useState(false);
|
||||||
|
const workshopsDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
useClickOutside(workshopsDropdownRef, () => setWorkshopsOpen(false), workshopsOpen);
|
||||||
|
useClickOutside(userMenuRef, () => setMenuOpen(false), menuOpen);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
const isActiveLink = (path: string) => pathname.startsWith(path);
|
const isActiveLink = (path: string) => pathname.startsWith(path);
|
||||||
@@ -20,7 +26,7 @@ export function Header() {
|
|||||||
<header className="sticky top-0 z-50 border-b border-border bg-card/80 backdrop-blur-sm">
|
<header className="sticky top-0 z-50 border-b border-border bg-card/80 backdrop-blur-sm">
|
||||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
|
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
|
||||||
<Link href="/" className="flex items-center gap-2">
|
<Link href="/" className="flex items-center gap-2">
|
||||||
<span className="text-2xl">🚀</span>
|
<RocketIcon className="h-7 w-7 shrink-0 text-primary" />
|
||||||
<span className="text-xl font-bold text-foreground">Workshop Manager</span>
|
<span className="text-xl font-bold text-foreground">Workshop Manager</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -39,18 +45,37 @@ export function Header() {
|
|||||||
Mes Ateliers
|
Mes Ateliers
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Workshops Dropdown */}
|
{/* Objectives Link */}
|
||||||
<div className="relative">
|
<Link
|
||||||
|
href="/objectives"
|
||||||
|
className={`text-sm font-medium transition-colors ${
|
||||||
|
isActiveLink('/objectives') ? 'text-primary' : 'text-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🎯 Mes Objectifs
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Teams Link */}
|
||||||
|
<Link
|
||||||
|
href="/teams"
|
||||||
|
className={`text-sm font-medium transition-colors ${
|
||||||
|
isActiveLink('/teams') ? 'text-primary' : 'text-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
👥 Équipes
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* New Workshop Dropdown */}
|
||||||
|
<div className="relative" ref={workshopsDropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setWorkshopsOpen(!workshopsOpen)}
|
onClick={() => setWorkshopsOpen(!workshopsOpen)}
|
||||||
onBlur={() => setTimeout(() => setWorkshopsOpen(false), 150)}
|
|
||||||
className={`flex items-center gap-1 text-sm font-medium transition-colors ${
|
className={`flex items-center gap-1 text-sm font-medium transition-colors ${
|
||||||
isActiveLink('/sessions/') || isActiveLink('/motivators')
|
WORKSHOPS.some((w) => isActiveLink(w.path))
|
||||||
? 'text-primary'
|
? 'text-primary'
|
||||||
: 'text-muted hover:text-foreground'
|
: 'text-muted hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Ateliers
|
Nouvel atelier
|
||||||
<svg
|
<svg
|
||||||
className={`h-4 w-4 transition-transform ${workshopsOpen ? 'rotate-180' : ''}`}
|
className={`h-4 w-4 transition-transform ${workshopsOpen ? 'rotate-180' : ''}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -68,28 +93,20 @@ export function Header() {
|
|||||||
|
|
||||||
{workshopsOpen && (
|
{workshopsOpen && (
|
||||||
<div className="absolute left-0 z-20 mt-2 w-56 rounded-lg border border-border bg-card py-1 shadow-lg">
|
<div className="absolute left-0 z-20 mt-2 w-56 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||||
<Link
|
{WORKSHOPS.map((w) => (
|
||||||
href="/sessions/new"
|
<Link
|
||||||
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover"
|
key={w.id}
|
||||||
onClick={() => setWorkshopsOpen(false)}
|
href={w.newPath}
|
||||||
>
|
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover"
|
||||||
<span className="text-lg">📊</span>
|
onClick={() => setWorkshopsOpen(false)}
|
||||||
<div>
|
>
|
||||||
<div className="font-medium">Analyse SWOT</div>
|
<span className="text-lg">{w.icon}</span>
|
||||||
<div className="text-xs text-muted">Forces, faiblesses, opportunités</div>
|
<div>
|
||||||
</div>
|
<div className="font-medium">{w.label}</div>
|
||||||
</Link>
|
<div className="text-xs text-muted">{w.description}</div>
|
||||||
<Link
|
</div>
|
||||||
href="/motivators/new"
|
</Link>
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -107,7 +124,7 @@ export function Header() {
|
|||||||
{status === 'loading' ? (
|
{status === 'loading' ? (
|
||||||
<div className="h-9 w-20 animate-pulse rounded-lg bg-card-hover" />
|
<div className="h-9 w-20 animate-pulse rounded-lg bg-card-hover" />
|
||||||
) : status === 'authenticated' && session?.user ? (
|
) : status === 'authenticated' && session?.user ? (
|
||||||
<div className="relative">
|
<div ref={userMenuRef} className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
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"
|
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"
|
||||||
@@ -132,9 +149,7 @@ export function Header() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<>
|
<div className="absolute right-0 z-20 mt-2 w-48 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||||
<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">
|
<div className="border-b border-border px-4 py-2">
|
||||||
<p className="text-xs text-muted">Connecté en tant que</p>
|
<p className="text-xs text-muted">Connecté en tant que</p>
|
||||||
<p className="truncate text-sm font-medium text-foreground">
|
<p className="truncate text-sm font-medium text-foreground">
|
||||||
@@ -162,7 +177,6 @@ export function Header() {
|
|||||||
Se déconnecter
|
Se déconnecter
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useTransition } from 'react';
|
import { useState, useTransition, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
@@ -35,6 +35,11 @@ export function MotivatorBoard({ sessionId, cards: initialCards, canEdit }: Moti
|
|||||||
const [step, setStep] = useState<Step>('ranking');
|
const [step, setStep] = useState<Step>('ranking');
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// Sync local state with props when they change (e.g., from SSE refresh)
|
||||||
|
useEffect(() => {
|
||||||
|
setCards(initialCards);
|
||||||
|
}, [initialCards]);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { memo } from 'react';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
|
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
|
||||||
@@ -12,7 +13,7 @@ interface MotivatorCardProps {
|
|||||||
showInfluence?: boolean;
|
showInfluence?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MotivatorCard({
|
export const MotivatorCard = memo(function MotivatorCard({
|
||||||
card,
|
card,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
showInfluence = false,
|
showInfluence = false,
|
||||||
@@ -87,10 +88,10 @@ export function MotivatorCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
// Non-draggable version for summary
|
// Non-draggable version for summary
|
||||||
export function MotivatorCardStatic({
|
export const MotivatorCardStatic = memo(function MotivatorCardStatic({
|
||||||
card,
|
card,
|
||||||
size = 'normal',
|
size = 'normal',
|
||||||
}: {
|
}: {
|
||||||
@@ -156,4 +157,4 @@ export function MotivatorCardStatic({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,25 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { BaseSessionLiveWrapper } from '@/components/collaboration/BaseSessionLiveWrapper';
|
||||||
import { useMotivatorLive, type MotivatorLiveEvent } from '@/hooks/useMotivatorLive';
|
import { shareMotivatorSession, removeMotivatorShare } from '@/actions/moving-motivators';
|
||||||
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
|
import type { TeamWithMembers, Share } from '@/lib/share-utils';
|
||||||
import { MotivatorShareModal } from './MotivatorShareModal';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
|
||||||
import type { ShareRole } from '@prisma/client';
|
|
||||||
|
|
||||||
interface ShareUser {
|
|
||||||
id: string;
|
|
||||||
name: string | null;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Share {
|
|
||||||
id: string;
|
|
||||||
role: ShareRole;
|
|
||||||
user: ShareUser;
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MotivatorLiveWrapperProps {
|
interface MotivatorLiveWrapperProps {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -28,6 +11,7 @@ interface MotivatorLiveWrapperProps {
|
|||||||
shares: Share[];
|
shares: Share[];
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
|
userTeams?: TeamWithMembers[];
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,95 +22,36 @@ export function MotivatorLiveWrapper({
|
|||||||
shares,
|
shares,
|
||||||
isOwner,
|
isOwner,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
userTeams = [],
|
||||||
children,
|
children,
|
||||||
}: MotivatorLiveWrapperProps) {
|
}: MotivatorLiveWrapperProps) {
|
||||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
|
||||||
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleEvent = useCallback((event: MotivatorLiveEvent) => {
|
|
||||||
// Show who made the last change
|
|
||||||
if (event.user?.name || event.user?.email) {
|
|
||||||
setLastEventUser(event.user.name || event.user.email);
|
|
||||||
// Clear after 3 seconds
|
|
||||||
setTimeout(() => setLastEventUser(null), 3000);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { isConnected, error } = useMotivatorLive({
|
|
||||||
sessionId,
|
|
||||||
currentUserId,
|
|
||||||
onEvent: handleEvent,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<BaseSessionLiveWrapper
|
||||||
{/* Header toolbar */}
|
sessionId={sessionId}
|
||||||
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
|
sessionTitle={sessionTitle}
|
||||||
<div className="flex items-center gap-4">
|
currentUserId={currentUserId}
|
||||||
<LiveIndicator isConnected={isConnected} error={error} />
|
shares={shares}
|
||||||
|
isOwner={isOwner}
|
||||||
{lastEventUser && (
|
canEdit={canEdit}
|
||||||
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
|
userTeams={userTeams}
|
||||||
<span>✏️</span>
|
config={{
|
||||||
<span>{lastEventUser} édite...</span>
|
apiPath: 'motivators',
|
||||||
</div>
|
shareModal: {
|
||||||
)}
|
title: 'Partager la session',
|
||||||
|
sessionSubtitle: 'Session Moving Motivators',
|
||||||
{!canEdit && (
|
helpText: (
|
||||||
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
|
<>
|
||||||
<span>👁️</span>
|
<strong>Éditeur</strong> : peut modifier les cartes et leurs positions
|
||||||
<span>Mode lecture</span>
|
<br />
|
||||||
</div>
|
<strong>Lecteur</strong> : peut uniquement consulter
|
||||||
)}
|
</>
|
||||||
</div>
|
),
|
||||||
|
},
|
||||||
<div className="flex items-center gap-2">
|
onShareWithEmail: (email, role) => shareMotivatorSession(sessionId, email, role),
|
||||||
{/* Collaborators avatars */}
|
onRemoveShare: (userId) => removeMotivatorShare(sessionId, userId),
|
||||||
{shares.length > 0 && (
|
}}
|
||||||
<div className="flex -space-x-2">
|
>
|
||||||
{shares.slice(0, 3).map((share) => (
|
{children}
|
||||||
<Avatar
|
</BaseSessionLiveWrapper>
|
||||||
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 */}
|
|
||||||
<MotivatorShareModal
|
|
||||||
isOpen={shareModalOpen}
|
|
||||||
onClose={() => setShareModalOpen(false)}
|
|
||||||
sessionId={sessionId}
|
|
||||||
sessionTitle={sessionTitle}
|
|
||||||
shares={shares}
|
|
||||||
isOwner={isOwner}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useTransition } from 'react';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
|
||||||
import { shareMotivatorSession, removeMotivatorShare } from '@/actions/moving-motivators';
|
|
||||||
import type { ShareRole } from '@prisma/client';
|
|
||||||
|
|
||||||
interface ShareUser {
|
|
||||||
id: string;
|
|
||||||
name: string | null;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Share {
|
|
||||||
id: string;
|
|
||||||
role: ShareRole;
|
|
||||||
user: ShareUser;
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MotivatorShareModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
sessionId: string;
|
|
||||||
sessionTitle: string;
|
|
||||||
shares: Share[];
|
|
||||||
isOwner: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MotivatorShareModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
sessionId,
|
|
||||||
sessionTitle,
|
|
||||||
shares,
|
|
||||||
isOwner,
|
|
||||||
}: MotivatorShareModalProps) {
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [role, setRole] = useState<ShareRole>('EDITOR');
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
async function handleShare(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await shareMotivatorSession(sessionId, email, role);
|
|
||||||
if (result.success) {
|
|
||||||
setEmail('');
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Erreur lors du partage');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemove(userId: string) {
|
|
||||||
startTransition(async () => {
|
|
||||||
await removeMotivatorShare(sessionId, userId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title="Partager la session">
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Session info */}
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted">Session Moving Motivators</p>
|
|
||||||
<p className="font-medium text-foreground">{sessionTitle}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Share form (only for owner) */}
|
|
||||||
{isOwner && (
|
|
||||||
<form onSubmit={handleShare} className="space-y-4">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="Email de l'utilisateur"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="flex-1"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={role}
|
|
||||||
onChange={(e) => setRole(e.target.value as ShareRole)}
|
|
||||||
className="rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground"
|
|
||||||
>
|
|
||||||
<option value="EDITOR">Éditeur</option>
|
|
||||||
<option value="VIEWER">Lecteur</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
||||||
|
|
||||||
<Button type="submit" disabled={isPending || !email} className="w-full">
|
|
||||||
{isPending ? 'Partage...' : 'Partager'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Current shares */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
|
|
||||||
|
|
||||||
{shares.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{shares.map((share) => (
|
|
||||||
<li
|
|
||||||
key={share.id}
|
|
||||||
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Avatar email={share.user.email} name={share.user.name} size={32} />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-foreground">
|
|
||||||
{share.user.name || share.user.email}
|
|
||||||
</p>
|
|
||||||
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
|
|
||||||
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
|
|
||||||
</Badge>
|
|
||||||
{isOwner && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemove(share.user.id)}
|
|
||||||
disabled={isPending}
|
|
||||||
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
|
|
||||||
title="Retirer l'accès"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
className="h-4 w-4"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Help text */}
|
|
||||||
<div className="rounded-lg bg-primary/5 p-3">
|
|
||||||
<p className="text-xs text-muted">
|
|
||||||
<strong>Éditeur</strong> : peut modifier les cartes et leurs positions
|
|
||||||
<br />
|
|
||||||
<strong>Lecteur</strong> : peut uniquement consulter
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,4 +3,3 @@ export { MotivatorCard, MotivatorCardStatic } from './MotivatorCard';
|
|||||||
export { MotivatorSummary } from './MotivatorSummary';
|
export { MotivatorSummary } from './MotivatorSummary';
|
||||||
export { InfluenceZone } from './InfluenceZone';
|
export { InfluenceZone } from './InfluenceZone';
|
||||||
export { MotivatorLiveWrapper } from './MotivatorLiveWrapper';
|
export { MotivatorLiveWrapper } from './MotivatorLiveWrapper';
|
||||||
export { MotivatorShareModal } from './MotivatorShareModal';
|
|
||||||
|
|||||||
186
src/components/okrs/KeyResultItem.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Input } from '@/components/ui';
|
||||||
|
import { Textarea } from '@/components/ui';
|
||||||
|
import { Button } from '@/components/ui';
|
||||||
|
import { Badge } from '@/components/ui';
|
||||||
|
import type { KeyResult, KeyResultStatus } from '@/lib/types';
|
||||||
|
import { KEY_RESULT_STATUS_LABELS } from '@/lib/types';
|
||||||
|
|
||||||
|
// Helper function for Key Result status colors
|
||||||
|
function getKeyResultStatusColor(status: KeyResultStatus): { 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 'AT_RISK':
|
||||||
|
return {
|
||||||
|
bg: 'color-mix(in srgb, #f59e0b 15%, transparent)', // amber-500 (orange/yellow)
|
||||||
|
color: '#f59e0b',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
bg: 'color-mix(in srgb, #6b7280 15%, transparent)',
|
||||||
|
color: '#6b7280',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeyResultItemProps {
|
||||||
|
keyResult: KeyResult;
|
||||||
|
okrId: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
onUpdate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyResultItem({ keyResult, okrId, canEdit, onUpdate }: KeyResultItemProps) {
|
||||||
|
const [currentValue, setCurrentValue] = useState(keyResult.currentValue);
|
||||||
|
const [notes, setNotes] = useState(keyResult.notes || '');
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
const progress = keyResult.targetValue > 0 ? (currentValue / keyResult.targetValue) * 100 : 0;
|
||||||
|
const progressColor =
|
||||||
|
progress >= 100 ? 'var(--success)' : progress >= 50 ? 'var(--accent)' : 'var(--destructive)';
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
setUpdating(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/okrs/${okrId}/key-results/${keyResult.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
currentValue: Number(currentValue),
|
||||||
|
notes: notes || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Erreur lors de la mise à jour');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsEditing(false);
|
||||||
|
onUpdate?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating key result:', error);
|
||||||
|
alert('Erreur lors de la mise à jour');
|
||||||
|
} finally {
|
||||||
|
setUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4">
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<h4 className="font-medium text-foreground">{keyResult.title}</h4>
|
||||||
|
<Badge style={getKeyResultStatusColor(keyResult.status)}>
|
||||||
|
{KEY_RESULT_STATUS_LABELS[keyResult.status]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="mb-1 flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted">
|
||||||
|
{currentValue} / {keyResult.targetValue} {keyResult.unit}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium" style={{ color: progressColor }}>
|
||||||
|
{Math.round(progress)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-card-column">
|
||||||
|
<div
|
||||||
|
className="h-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(progress, 100)}%`,
|
||||||
|
backgroundColor: progressColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Form */}
|
||||||
|
{canEdit && (
|
||||||
|
<div className="space-y-3 border-t border-border pt-3">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Valeur actuelle
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(e) => setCurrentValue(Number(e.target.value))}
|
||||||
|
min={0}
|
||||||
|
max={keyResult.targetValue * 2}
|
||||||
|
step="0.1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">Notes</label>
|
||||||
|
<Textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Ajouter des notes..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleUpdate} disabled={updating} size="sm">
|
||||||
|
{updating ? 'Mise à jour...' : 'Enregistrer'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setCurrentValue(keyResult.currentValue);
|
||||||
|
setNotes(keyResult.notes || '');
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{keyResult.notes && (
|
||||||
|
<div className="mb-2 text-sm text-muted">
|
||||||
|
<strong>Notes:</strong> {keyResult.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button onClick={() => setIsEditing(true)} variant="outline" size="sm">
|
||||||
|
Mettre à jour la progression
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!canEdit && keyResult.notes && (
|
||||||
|
<div className="mt-3 border-t border-border pt-3 text-sm text-muted">
|
||||||
|
<strong>Notes:</strong> {keyResult.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
389
src/components/okrs/OKRCard.tsx
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
|
||||||
|
import { Badge } from '@/components/ui';
|
||||||
|
import { getGravatarUrl } from '@/lib/gravatar';
|
||||||
|
import type { OKR, KeyResult, OKRStatus, KeyResultStatus } from '@/lib/types';
|
||||||
|
import { OKR_STATUS_LABELS, KEY_RESULT_STATUS_LABELS } from '@/lib/types';
|
||||||
|
|
||||||
|
// Helper functions for 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 (success)
|
||||||
|
color: '#10b981',
|
||||||
|
};
|
||||||
|
case 'CANCELLED':
|
||||||
|
return {
|
||||||
|
bg: 'color-mix(in srgb, #ef4444 15%, transparent)', // red-500 (destructive)
|
||||||
|
color: '#ef4444',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
bg: 'color-mix(in srgb, #6b7280 15%, transparent)',
|
||||||
|
color: '#6b7280',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKeyResultStatusColor(status: KeyResultStatus): { bg: string; color: string } {
|
||||||
|
switch (status) {
|
||||||
|
case 'NOT_STARTED':
|
||||||
|
return {
|
||||||
|
bg: 'color-mix(in srgb, #6b7280 12%, transparent)', // gray-500
|
||||||
|
color: '#6b7280',
|
||||||
|
};
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return {
|
||||||
|
bg: 'color-mix(in srgb, #3b82f6 12%, transparent)', // blue-500
|
||||||
|
color: '#3b82f6',
|
||||||
|
};
|
||||||
|
case 'COMPLETED':
|
||||||
|
return {
|
||||||
|
bg: 'color-mix(in srgb, #10b981 12%, transparent)', // green-500
|
||||||
|
color: '#10b981',
|
||||||
|
};
|
||||||
|
case 'AT_RISK':
|
||||||
|
return {
|
||||||
|
bg: 'color-mix(in srgb, #f59e0b 12%, transparent)', // amber-500 (orange/yellow)
|
||||||
|
color: '#f59e0b',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
bg: 'color-mix(in srgb, #6b7280 12%, transparent)',
|
||||||
|
color: '#6b7280',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OKRCardProps {
|
||||||
|
okr: OKR & { teamMember?: { user: { id: string; email: string; name: string | null } } };
|
||||||
|
teamId: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OKRCard({ okr, teamId, isAdmin = false, compact = false }: OKRCardProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const progress = okr.progress || 0;
|
||||||
|
const progressColor =
|
||||||
|
progress >= 75 ? 'var(--success)' : progress >= 25 ? 'var(--accent)' : 'var(--destructive)';
|
||||||
|
|
||||||
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!confirm(`Êtes-vous sûr de vouloir supprimer l'OKR "${okr.objective}" ?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/okrs/${okr.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || "Erreur lors de la suppression de l'OKR");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting OKR:', error);
|
||||||
|
alert("Erreur lors de la suppression de l'OKR");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Card hover className="relative group">
|
||||||
|
<Link href={`/teams/${teamId}/okrs/${okr.id}`}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0 flex items-start gap-3">
|
||||||
|
<span className="text-xl flex-shrink-0">🎯</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-lg leading-snug mb-1.5 line-clamp-2">
|
||||||
|
{okr.objective}
|
||||||
|
</CardTitle>
|
||||||
|
{okr.teamMember && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{/* 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={16}
|
||||||
|
height={16}
|
||||||
|
className="rounded-full flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted line-clamp-1">
|
||||||
|
{okr.teamMember.user.name || okr.teamMember.user.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0 relative z-10">
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="h-5 w-5 p-0 flex items-center justify-center rounded hover:bg-destructive/10 transition-colors flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
color: 'var(--destructive)',
|
||||||
|
border: '1px solid color-mix(in srgb, var(--destructive) 40%, transparent)',
|
||||||
|
backgroundColor: 'color-mix(in srgb, var(--destructive) 5%, transparent)',
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
|
title="Supprimer l'OKR"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
|
||||||
|
color: 'var(--purple)',
|
||||||
|
fontSize: '11px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{okr.period}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0 pb-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="mb-1 flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted">Progression</span>
|
||||||
|
<span className="font-medium" style={{ color: progressColor }}>
|
||||||
|
{progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-card-column">
|
||||||
|
<div
|
||||||
|
className="h-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${progress}%`,
|
||||||
|
backgroundColor: progressColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Badge style={getOKRStatusColor(okr.status)} className="text-xs px-2 py-0.5">
|
||||||
|
{OKR_STATUS_LABELS[okr.status]}
|
||||||
|
</Badge>
|
||||||
|
{okr.keyResults && okr.keyResults.length > 0 && (
|
||||||
|
<span className="text-xs text-muted whitespace-nowrap">
|
||||||
|
{okr.keyResults.length} KR{okr.keyResults.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card hover className="h-full relative group">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<Link href={`/teams/${teamId}/okrs/${okr.id}`} className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 pr-2">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<span className="text-xl">🎯</span>
|
||||||
|
{okr.objective}
|
||||||
|
</CardTitle>
|
||||||
|
{okr.teamMember && (
|
||||||
|
<div className="mt-2 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={24}
|
||||||
|
height={24}
|
||||||
|
className="rounded-full"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted">
|
||||||
|
{okr.teamMember.user.name || okr.teamMember.user.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Action Zone */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0 relative z-10">
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="h-6 w-6 p-0 flex items-center justify-center rounded hover:bg-destructive/10 transition-colors flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
color: 'var(--destructive)',
|
||||||
|
border: '1px solid color-mix(in srgb, var(--destructive) 40%, transparent)',
|
||||||
|
backgroundColor: 'color-mix(in srgb, var(--destructive) 5%, transparent)',
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
|
title="Supprimer l'OKR"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
|
||||||
|
color: 'var(--purple)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{okr.period}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<Link href={`/teams/${teamId}/okrs/${okr.id}`}>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted">Progression</span>
|
||||||
|
<span className="font-medium" style={{ color: progressColor }}>
|
||||||
|
{progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-card-column">
|
||||||
|
<div
|
||||||
|
className="h-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${progress}%`,
|
||||||
|
backgroundColor: progressColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted">Statut:</span>
|
||||||
|
<Badge style={getOKRStatusColor(okr.status)}>{OKR_STATUS_LABELS[okr.status]}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Results List */}
|
||||||
|
{okr.keyResults && okr.keyResults.length > 0 && (
|
||||||
|
<div className="space-y-2 pt-2 border-t border-border">
|
||||||
|
<div className="text-xs font-medium text-muted uppercase tracking-wide">
|
||||||
|
Key Results ({okr.keyResults.length})
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{okr.keyResults
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map((kr: KeyResult) => {
|
||||||
|
const krProgress =
|
||||||
|
kr.targetValue > 0 ? (kr.currentValue / kr.targetValue) * 100 : 0;
|
||||||
|
const krProgressColor =
|
||||||
|
krProgress >= 100
|
||||||
|
? 'var(--success)'
|
||||||
|
: krProgress >= 50
|
||||||
|
? 'var(--accent)'
|
||||||
|
: 'var(--destructive)';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={kr.id} className="space-y-1">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="text-sm text-foreground flex-1 line-clamp-2">
|
||||||
|
{kr.title}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
style={{
|
||||||
|
...getKeyResultStatusColor(kr.status),
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{KEY_RESULT_STATUS_LABELS[kr.status]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted">
|
||||||
|
<span>
|
||||||
|
{kr.currentValue} / {kr.targetValue} {kr.unit}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium" style={{ color: krProgressColor }}>
|
||||||
|
{Math.round(krProgress)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-card-column">
|
||||||
|
<div
|
||||||
|
className="h-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(krProgress, 100)}%`,
|
||||||
|
backgroundColor: krProgressColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
494
src/components/okrs/OKRForm.tsx
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Input } from '@/components/ui';
|
||||||
|
import { Textarea } from '@/components/ui';
|
||||||
|
import { Button } from '@/components/ui';
|
||||||
|
import { Select } from '@/components/ui';
|
||||||
|
import type { CreateOKRInput, CreateKeyResultInput, TeamMember, KeyResult } from '@/lib/types';
|
||||||
|
import { PERIOD_SUGGESTIONS } from '@/lib/types';
|
||||||
|
|
||||||
|
// Calcule les dates de début et de fin pour un trimestre donné
|
||||||
|
function getQuarterDates(period: string): { startDate: string; endDate: string } | null {
|
||||||
|
// Format attendu: "Q1 2025", "Q2 2026", etc.
|
||||||
|
const match = period.match(/^Q(\d)\s+(\d{4})$/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quarter = parseInt(match[1], 10);
|
||||||
|
const year = parseInt(match[2], 10);
|
||||||
|
|
||||||
|
let startMonth = 0; // Janvier = 0
|
||||||
|
let endMonth = 2; // Mars = 2
|
||||||
|
let endDay = 31;
|
||||||
|
|
||||||
|
switch (quarter) {
|
||||||
|
case 1:
|
||||||
|
startMonth = 0; // Janvier
|
||||||
|
endMonth = 2; // Mars
|
||||||
|
endDay = 31;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
startMonth = 3; // Avril
|
||||||
|
endMonth = 5; // Juin
|
||||||
|
endDay = 30;
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
startMonth = 6; // Juillet
|
||||||
|
endMonth = 8; // Septembre
|
||||||
|
endDay = 30;
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
startMonth = 9; // Octobre
|
||||||
|
endMonth = 11; // Décembre
|
||||||
|
endDay = 31;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(year, startMonth, 1);
|
||||||
|
const endDate = new Date(year, endMonth, endDay);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: startDate.toISOString().split('T')[0],
|
||||||
|
endDate: endDate.toISOString().split('T')[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeyResultEditInput extends CreateKeyResultInput {
|
||||||
|
id?: string; // If present, it's an existing Key Result to update
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyResultUpdate = {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
targetValue?: number;
|
||||||
|
unit?: string;
|
||||||
|
order?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OKRFormSubmitData =
|
||||||
|
| (CreateOKRInput & {
|
||||||
|
startDate: Date | string;
|
||||||
|
endDate: Date | string;
|
||||||
|
keyResultsUpdates?: {
|
||||||
|
create?: CreateKeyResultInput[];
|
||||||
|
update?: KeyResultUpdate[];
|
||||||
|
delete?: string[];
|
||||||
|
};
|
||||||
|
})
|
||||||
|
| CreateOKRInput;
|
||||||
|
|
||||||
|
interface OKRFormProps {
|
||||||
|
teamMembers: TeamMember[];
|
||||||
|
onSubmit: (data: OKRFormSubmitData) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
initialData?: Partial<CreateOKRInput> & { keyResults?: KeyResult[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFormProps) {
|
||||||
|
const [teamMemberId, setTeamMemberId] = useState(initialData?.teamMemberId || '');
|
||||||
|
const [objective, setObjective] = useState(initialData?.objective || '');
|
||||||
|
const [description, setDescription] = useState(initialData?.description || '');
|
||||||
|
const [period, setPeriod] = useState(initialData?.period || '');
|
||||||
|
const [customPeriod, setCustomPeriod] = useState('');
|
||||||
|
const [startDate, setStartDate] = useState(
|
||||||
|
initialData?.startDate ? new Date(initialData.startDate).toISOString().split('T')[0] : ''
|
||||||
|
);
|
||||||
|
const [endDate, setEndDate] = useState(
|
||||||
|
initialData?.endDate ? new Date(initialData.endDate).toISOString().split('T')[0] : ''
|
||||||
|
);
|
||||||
|
// Initialize Key Results from existing ones if in edit mode, otherwise start with one empty
|
||||||
|
const [keyResults, setKeyResults] = useState<KeyResultEditInput[]>(() => {
|
||||||
|
if (initialData?.keyResults && initialData.keyResults.length > 0) {
|
||||||
|
return initialData.keyResults.map((kr): KeyResultEditInput => {
|
||||||
|
const result = {
|
||||||
|
title: kr.title,
|
||||||
|
targetValue: kr.targetValue,
|
||||||
|
unit: kr.unit || '%',
|
||||||
|
order: kr.order,
|
||||||
|
};
|
||||||
|
// @ts-expect-error - id is added to extend CreateKeyResultInput to KeyResultEditInput
|
||||||
|
result.id = kr.id;
|
||||||
|
return result as KeyResultEditInput;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [{ title: '', targetValue: 100, unit: '%', order: 0 }];
|
||||||
|
});
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Mise à jour automatique des dates quand la période change
|
||||||
|
useEffect(() => {
|
||||||
|
if (period && period !== 'custom' && period !== '') {
|
||||||
|
const dates = getQuarterDates(period);
|
||||||
|
if (dates) {
|
||||||
|
setStartDate(dates.startDate);
|
||||||
|
setEndDate(dates.endDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [period]);
|
||||||
|
|
||||||
|
const addKeyResult = () => {
|
||||||
|
if (keyResults.length >= 5) {
|
||||||
|
alert('Maximum 5 Key Results autorisés');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setKeyResults([
|
||||||
|
...keyResults,
|
||||||
|
{ title: '', targetValue: 100, unit: '%', order: keyResults.length },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeKeyResult = (index: number) => {
|
||||||
|
if (keyResults.length <= 1) {
|
||||||
|
alert('Au moins un Key Result est requis');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setKeyResults(keyResults.filter((_, i) => i !== index).map((kr, i) => ({ ...kr, order: i })));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateKeyResult = (
|
||||||
|
index: number,
|
||||||
|
field: keyof KeyResultEditInput,
|
||||||
|
value: string | number
|
||||||
|
) => {
|
||||||
|
const updated = [...keyResults];
|
||||||
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
|
setKeyResults(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!teamMemberId || !objective || !period || !startDate || !endDate) {
|
||||||
|
alert('Veuillez remplir tous les champs requis');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Key Results
|
||||||
|
if (keyResults.some((kr) => !kr.title || kr.targetValue <= 0)) {
|
||||||
|
alert('Tous les Key Results doivent avoir un titre et une valeur cible > 0');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalPeriod = period === 'custom' ? customPeriod : period;
|
||||||
|
if (!finalPeriod) {
|
||||||
|
alert('Veuillez spécifier une période');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
// Convert dates to ISO strings for JSON serialization
|
||||||
|
const startDateObj = new Date(startDate);
|
||||||
|
const endDateObj = new Date(endDate);
|
||||||
|
|
||||||
|
if (isNaN(startDateObj.getTime()) || isNaN(endDateObj.getTime())) {
|
||||||
|
alert('Dates invalides');
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEditMode = !!initialData?.teamMemberId;
|
||||||
|
|
||||||
|
if (isEditMode) {
|
||||||
|
// Type guard for Key Results with id
|
||||||
|
type KeyResultWithId = KeyResultEditInput & { id: string };
|
||||||
|
const hasId = (kr: KeyResultEditInput): kr is KeyResultWithId => !!kr.id;
|
||||||
|
|
||||||
|
// In edit mode, separate existing Key Results from new ones
|
||||||
|
const existingKeyResults: KeyResultWithId[] = keyResults.filter(hasId);
|
||||||
|
const originalKeyResults: KeyResult[] = initialData?.keyResults || [];
|
||||||
|
const originalIds = new Set(originalKeyResults.map((kr: KeyResult) => kr.id));
|
||||||
|
const currentIds = new Set(existingKeyResults.map((kr: KeyResultWithId) => kr.id));
|
||||||
|
|
||||||
|
// Find deleted Key Results
|
||||||
|
const deletedIds = Array.from(originalIds).filter((id) => !currentIds.has(id));
|
||||||
|
|
||||||
|
// Find updated Key Results (compare with original)
|
||||||
|
const updated = existingKeyResults
|
||||||
|
.map((kr: KeyResultWithId) => {
|
||||||
|
const original = originalKeyResults.find((okr: KeyResult) => okr.id === kr.id);
|
||||||
|
if (!original) return null;
|
||||||
|
|
||||||
|
const changes: {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
targetValue?: number;
|
||||||
|
unit?: string;
|
||||||
|
order?: number;
|
||||||
|
} = { id: kr.id };
|
||||||
|
if (original.title !== kr.title) changes.title = kr.title;
|
||||||
|
if (original.targetValue !== kr.targetValue) changes.targetValue = kr.targetValue;
|
||||||
|
if (original.unit !== kr.unit) changes.unit = kr.unit;
|
||||||
|
if (original.order !== kr.order) changes.order = kr.order;
|
||||||
|
|
||||||
|
return Object.keys(changes).length > 1 ? changes : null; // More than just 'id'
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(
|
||||||
|
u
|
||||||
|
): u is {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
targetValue?: number;
|
||||||
|
unit?: string;
|
||||||
|
order?: number;
|
||||||
|
} => u !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update order for all Key Results based on their position
|
||||||
|
const allKeyResultsWithOrder = keyResults.map((kr, i) => ({ ...kr, order: i }));
|
||||||
|
const existingWithOrder = allKeyResultsWithOrder.filter(hasId) as KeyResultWithId[];
|
||||||
|
const newWithOrder = allKeyResultsWithOrder.filter((kr) => !kr.id);
|
||||||
|
|
||||||
|
// Update order for existing Key Results that changed position
|
||||||
|
const orderUpdates = existingWithOrder
|
||||||
|
.map((kr) => {
|
||||||
|
const original = originalKeyResults.find((okr: KeyResult) => okr.id === kr.id);
|
||||||
|
if (!original || original.order === kr.order) return null;
|
||||||
|
return { id: kr.id, order: kr.order };
|
||||||
|
})
|
||||||
|
.filter((u): u is { id: string; order: number } => u !== null);
|
||||||
|
|
||||||
|
// Merge order updates with other updates
|
||||||
|
const allUpdates = [...updated];
|
||||||
|
orderUpdates.forEach((orderUpdate) => {
|
||||||
|
const existingUpdate = allUpdates.find((u) => u.id === orderUpdate.id);
|
||||||
|
if (existingUpdate) {
|
||||||
|
existingUpdate.order = orderUpdate.order;
|
||||||
|
} else {
|
||||||
|
allUpdates.push(orderUpdate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await onSubmit({
|
||||||
|
teamMemberId,
|
||||||
|
objective,
|
||||||
|
description: description || undefined,
|
||||||
|
period: finalPeriod,
|
||||||
|
startDate: startDateObj.toISOString() as Date | string,
|
||||||
|
endDate: endDateObj.toISOString() as Date | string,
|
||||||
|
keyResults: [], // Not used in edit mode
|
||||||
|
keyResultsUpdates: {
|
||||||
|
create:
|
||||||
|
newWithOrder.length > 0
|
||||||
|
? newWithOrder.map((kr) => ({
|
||||||
|
title: kr.title,
|
||||||
|
targetValue: kr.targetValue,
|
||||||
|
unit: kr.unit || '%',
|
||||||
|
order: kr.order,
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
update: allUpdates.length > 0 ? allUpdates : undefined,
|
||||||
|
delete: deletedIds.length > 0 ? deletedIds : undefined,
|
||||||
|
},
|
||||||
|
} as unknown as OKRFormSubmitData);
|
||||||
|
} else {
|
||||||
|
// In create mode, just send Key Results normally
|
||||||
|
await onSubmit({
|
||||||
|
teamMemberId,
|
||||||
|
objective,
|
||||||
|
description: description || undefined,
|
||||||
|
period: finalPeriod,
|
||||||
|
startDate: startDateObj,
|
||||||
|
endDate: endDateObj,
|
||||||
|
keyResults: keyResults.map((kr, i) => ({ ...kr, order: i })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting OKR:', error);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Team Member */}
|
||||||
|
<Select
|
||||||
|
label="Membre de l'équipe *"
|
||||||
|
value={teamMemberId}
|
||||||
|
onChange={(e) => setTeamMemberId(e.target.value)}
|
||||||
|
options={teamMembers.map((member) => ({
|
||||||
|
value: member.id,
|
||||||
|
label: member.user.name || member.user.email,
|
||||||
|
}))}
|
||||||
|
placeholder="Sélectionner un membre"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Objective */}
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="Objective *"
|
||||||
|
value={objective}
|
||||||
|
onChange={(e) => setObjective(e.target.value)}
|
||||||
|
placeholder="Ex: Améliorer la qualité du code"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<Textarea
|
||||||
|
label="Description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Description détaillée de l'objectif..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period */}
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
label="Période *"
|
||||||
|
value={period}
|
||||||
|
onChange={(e) => setPeriod(e.target.value)}
|
||||||
|
options={[
|
||||||
|
...PERIOD_SUGGESTIONS.map((p) => ({
|
||||||
|
value: p,
|
||||||
|
label: p,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
value: 'custom',
|
||||||
|
label: 'Personnalisée',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
placeholder="Sélectionner une période"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{period === 'custom' && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Input
|
||||||
|
value={customPeriod}
|
||||||
|
onChange={(e) => setCustomPeriod(e.target.value)}
|
||||||
|
placeholder="Ex: Q1-Q2 2025"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dates */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="Date de début *"
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="Date de fin *"
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{period && period !== 'custom' && period !== '' && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Les dates sont automatiquement définies selon le trimestre sélectionné. Vous pouvez les
|
||||||
|
modifier si nécessaire.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Key Results */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<label className="block text-sm font-medium text-foreground">
|
||||||
|
Key Results * ({keyResults.length}/5)
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={addKeyResult}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={keyResults.length >= 5}
|
||||||
|
>
|
||||||
|
+ Ajouter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{keyResults.map((kr, index) => (
|
||||||
|
<div
|
||||||
|
key={kr.id || `new-${index}`}
|
||||||
|
className="rounded-lg border border-border bg-card p-4"
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-foreground">Key Result {index + 1}</span>
|
||||||
|
{keyResults.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeKeyResult(index)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
style={{
|
||||||
|
color: 'var(--destructive)',
|
||||||
|
borderColor: 'var(--destructive)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Titre du Key Result"
|
||||||
|
value={kr.title}
|
||||||
|
onChange={(e) => updateKeyResult(index, 'title', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Valeur cible"
|
||||||
|
value={kr.targetValue}
|
||||||
|
onChange={(e) => updateKeyResult(index, 'targetValue', Number(e.target.value))}
|
||||||
|
min={0}
|
||||||
|
step="0.1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Unité (%)"
|
||||||
|
value={kr.unit}
|
||||||
|
onChange={(e) => updateKeyResult(index, 'unit', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button type="button" onClick={onCancel} variant="outline">
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"
|
||||||
|
>
|
||||||
|
{submitting
|
||||||
|
? initialData?.teamMemberId
|
||||||
|
? 'Modification...'
|
||||||
|
: 'Création...'
|
||||||
|
: initialData?.teamMemberId
|
||||||
|
? "Modifier l'OKR"
|
||||||
|
: "Créer l'OKR"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
src/components/okrs/OKRsList.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { OKRCard } from './OKRCard';
|
||||||
|
import { Card, ToggleGroup, Button } from '@/components/ui';
|
||||||
|
import { getGravatarUrl } from '@/lib/gravatar';
|
||||||
|
import { getCurrentQuarterPeriod, isCurrentQuarterPeriod } from '@/lib/okr-utils';
|
||||||
|
import type { OKR } from '@/lib/types';
|
||||||
|
|
||||||
|
type ViewMode = 'grid' | 'grouped';
|
||||||
|
type CardViewMode = 'detailed' | 'compact';
|
||||||
|
|
||||||
|
interface OKRsListProps {
|
||||||
|
okrsData: Array<{
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
};
|
||||||
|
okrs: Array<OKR & { progress?: number }>;
|
||||||
|
}>;
|
||||||
|
teamId: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARD_VIEW_STORAGE_KEY = 'okr-card-view-mode';
|
||||||
|
|
||||||
|
export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('grouped');
|
||||||
|
const [cardViewMode, setCardViewMode] = useState<CardViewMode>(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const stored = localStorage.getItem(CARD_VIEW_STORAGE_KEY);
|
||||||
|
return (stored as CardViewMode) || 'detailed';
|
||||||
|
}
|
||||||
|
return 'detailed';
|
||||||
|
});
|
||||||
|
const [showAllPeriods, setShowAllPeriods] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(CARD_VIEW_STORAGE_KEY, cardViewMode);
|
||||||
|
}
|
||||||
|
}, [cardViewMode]);
|
||||||
|
|
||||||
|
const currentQuarterPeriod = getCurrentQuarterPeriod();
|
||||||
|
|
||||||
|
// Filter OKRs based on period filter
|
||||||
|
const filteredOKRsData = useMemo(() => {
|
||||||
|
if (showAllPeriods) {
|
||||||
|
return okrsData;
|
||||||
|
}
|
||||||
|
return okrsData.map((tm) => ({
|
||||||
|
...tm,
|
||||||
|
okrs: tm.okrs.filter((okr) => isCurrentQuarterPeriod(okr.period)),
|
||||||
|
}));
|
||||||
|
}, [okrsData, showAllPeriods]);
|
||||||
|
|
||||||
|
// Flatten OKRs for grid view
|
||||||
|
const allOKRs = filteredOKRsData.flatMap((tm) =>
|
||||||
|
tm.okrs.map((okr) => ({
|
||||||
|
...okr,
|
||||||
|
teamMember: {
|
||||||
|
user: tm.user,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allOKRs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* View Toggle */}
|
||||||
|
<div className="mb-6 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">OKRs</h2>
|
||||||
|
{!showAllPeriods && (
|
||||||
|
<span className="text-sm text-muted">({currentQuarterPeriod})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowAllPeriods(!showAllPeriods)}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
{showAllPeriods ? `Afficher ${currentQuarterPeriod} uniquement` : 'Afficher tous les OKR'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Card className="p-12 text-center">
|
||||||
|
<div className="text-5xl mb-4">🎯</div>
|
||||||
|
<h3 className="text-xl font-semibold text-foreground mb-2">
|
||||||
|
{showAllPeriods ? 'Aucun OKR défini' : `Aucun OKR pour ${currentQuarterPeriod}`}
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted">
|
||||||
|
{showAllPeriods
|
||||||
|
? "Aucun OKR n'a encore été défini pour cette équipe"
|
||||||
|
: `Aucun OKR n'a été défini pour le trimestre ${currentQuarterPeriod}. Cliquez sur "Afficher tous les OKR" pour voir les OKR d'autres périodes.`}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* View Toggle */}
|
||||||
|
<div className="mb-6 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">OKRs</h2>
|
||||||
|
{!showAllPeriods && (
|
||||||
|
<span className="text-sm text-muted">({currentQuarterPeriod})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowAllPeriods(!showAllPeriods)}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
{showAllPeriods ? `Afficher ${currentQuarterPeriod} uniquement` : 'Afficher tous les OKR'}
|
||||||
|
</Button>
|
||||||
|
<ToggleGroup
|
||||||
|
value={cardViewMode}
|
||||||
|
onChange={setCardViewMode}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: 'detailed',
|
||||||
|
label: 'Détaillée',
|
||||||
|
icon: (
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'compact',
|
||||||
|
label: 'Mini',
|
||||||
|
icon: (
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 10h16M4 14h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ToggleGroup
|
||||||
|
value={viewMode}
|
||||||
|
onChange={setViewMode}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: 'grouped',
|
||||||
|
label: 'Par membre',
|
||||||
|
icon: (
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'grid',
|
||||||
|
label: 'Grille',
|
||||||
|
icon: (
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grouped View */}
|
||||||
|
{viewMode === 'grouped' ? (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{filteredOKRsData
|
||||||
|
.filter((tm) => tm.okrs.length > 0)
|
||||||
|
.map((teamMember) => (
|
||||||
|
<div key={teamMember.user.id}>
|
||||||
|
{/* Member Header */}
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={getGravatarUrl(teamMember.user.email, 96)}
|
||||||
|
alt={teamMember.user.name || teamMember.user.email}
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="rounded-full border-2 border-border"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">
|
||||||
|
{teamMember.user.name || 'Sans nom'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted">{teamMember.user.email}</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<span className="text-sm text-muted">
|
||||||
|
{teamMember.okrs.length} OKR{teamMember.okrs.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OKRs Grid */}
|
||||||
|
<div
|
||||||
|
className={`grid gap-6 ${cardViewMode === 'compact' ? 'md:grid-cols-2 lg:grid-cols-3' : 'md:grid-cols-2 lg:grid-cols-3'}`}
|
||||||
|
>
|
||||||
|
{teamMember.okrs.map((okr) => (
|
||||||
|
<OKRCard
|
||||||
|
key={okr.id}
|
||||||
|
okr={{
|
||||||
|
...okr,
|
||||||
|
teamMember: {
|
||||||
|
user: teamMember.user,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
teamId={teamId}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
compact={cardViewMode === 'compact'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Grid View */
|
||||||
|
<div
|
||||||
|
className={`grid gap-6 ${cardViewMode === 'compact' ? 'md:grid-cols-2 lg:grid-cols-3' : 'md:grid-cols-2 lg:grid-cols-3'}`}
|
||||||
|
>
|
||||||
|
{allOKRs.map((okr) => (
|
||||||
|
<OKRCard
|
||||||
|
key={okr.id}
|
||||||
|
okr={okr}
|
||||||
|
teamId={teamId}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
compact={cardViewMode === 'compact'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
src/components/okrs/ObjectivesList.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { OKRCard } from './OKRCard';
|
||||||
|
import { ToggleGroup } from '@/components/ui';
|
||||||
|
import type { OKR } from '@/lib/types';
|
||||||
|
|
||||||
|
type CardViewMode = 'detailed' | 'compact';
|
||||||
|
|
||||||
|
interface ObjectivesListProps {
|
||||||
|
okrsByPeriod: Record<
|
||||||
|
string,
|
||||||
|
Array<OKR & { progress?: number; team: { id: string; name: string } }>
|
||||||
|
>;
|
||||||
|
periods: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARD_VIEW_STORAGE_KEY = 'okr-card-view-mode';
|
||||||
|
|
||||||
|
export function ObjectivesList({ okrsByPeriod, periods }: ObjectivesListProps) {
|
||||||
|
const [cardViewMode, setCardViewMode] = useState<CardViewMode>(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const stored = localStorage.getItem(CARD_VIEW_STORAGE_KEY);
|
||||||
|
return (stored as CardViewMode) || 'detailed';
|
||||||
|
}
|
||||||
|
return 'detailed';
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(CARD_VIEW_STORAGE_KEY, cardViewMode);
|
||||||
|
}
|
||||||
|
}, [cardViewMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Global View Toggle */}
|
||||||
|
<div className="flex items-center justify-end mb-4">
|
||||||
|
<ToggleGroup
|
||||||
|
value={cardViewMode}
|
||||||
|
onChange={setCardViewMode}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: 'detailed',
|
||||||
|
label: 'Détaillée',
|
||||||
|
icon: (
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'compact',
|
||||||
|
label: 'Mini',
|
||||||
|
icon: (
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 10h16M4 14h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{periods.map((period) => {
|
||||||
|
const periodOKRs = okrsByPeriod[period];
|
||||||
|
const totalProgress =
|
||||||
|
periodOKRs.reduce((sum, okr) => sum + (okr.progress || 0), 0) / periodOKRs.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={period} className="space-y-4">
|
||||||
|
{/* Period Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className="rounded-lg px-3 py-1 text-sm font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
|
||||||
|
color: 'var(--purple)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{period}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted">
|
||||||
|
{periodOKRs.length} OKR{periodOKRs.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-foreground">
|
||||||
|
Progression moyenne:{' '}
|
||||||
|
<span style={{ color: 'var(--primary)' }}>{Math.round(totalProgress)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OKRs Grid */}
|
||||||
|
<div
|
||||||
|
className={`grid gap-6 ${cardViewMode === 'compact' ? 'md:grid-cols-2 lg:grid-cols-3' : 'md:grid-cols-2 lg:grid-cols-3'}`}
|
||||||
|
>
|
||||||
|
{periodOKRs.map((okr) => (
|
||||||
|
<OKRCard
|
||||||
|
key={okr.id}
|
||||||
|
okr={okr}
|
||||||
|
teamId={okr.team.id}
|
||||||
|
compact={cardViewMode === 'compact'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/components/okrs/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { OKRCard } from './OKRCard';
|
||||||
|
export { OKRForm } from './OKRForm';
|
||||||
|
export { KeyResultItem } from './KeyResultItem';
|
||||||
|
export { OKRsList } from './OKRsList';
|
||||||
|
export { ObjectivesList } from './ObjectivesList';
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { EditableTitle } from './EditableTitle';
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from 'react';
|
import { useState, useTransition } from 'react';
|
||||||
import type { SwotItem, Action, ActionLink, SwotCategory } from '@prisma/client';
|
import type { SwotItem, Action, ActionLink, SwotCategory } from '@prisma/client';
|
||||||
import { Button, Badge, Modal, ModalFooter, Input, Textarea } from '@/components/ui';
|
import { Button, Badge, Modal, ModalFooter, Input, Textarea, Select } from '@/components/ui';
|
||||||
import { createAction, updateAction, deleteAction } from '@/actions/swot';
|
import { createAction, updateAction, deleteAction } from '@/actions/swot';
|
||||||
|
|
||||||
type ActionWithLinks = Action & {
|
type ActionWithLinks = Action & {
|
||||||
@@ -40,11 +40,11 @@ const categoryShort: Record<SwotCategory, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const priorityLabels = ['Basse', 'Moyenne', 'Haute'];
|
const priorityLabels = ['Basse', 'Moyenne', 'Haute'];
|
||||||
const statusLabels: Record<string, string> = {
|
const statusOptions = [
|
||||||
todo: 'À faire',
|
{ value: 'todo', label: '📋 À faire' },
|
||||||
in_progress: 'En cours',
|
{ value: 'in_progress', label: '⏳ En cours' },
|
||||||
done: 'Terminé',
|
{ value: 'done', label: '✅ Terminé' },
|
||||||
};
|
];
|
||||||
|
|
||||||
export function ActionPanel({
|
export function ActionPanel({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -66,6 +66,7 @@ export function ActionPanel({
|
|||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [priority, setPriority] = useState(1);
|
const [priority, setPriority] = useState(1);
|
||||||
|
const [editingSelectedItems, setEditingSelectedItems] = useState<string[]>([]);
|
||||||
|
|
||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
if (selectedItems.length < 2) {
|
if (selectedItems.length < 2) {
|
||||||
@@ -75,6 +76,7 @@ export function ActionPanel({
|
|||||||
setTitle('');
|
setTitle('');
|
||||||
setDescription('');
|
setDescription('');
|
||||||
setPriority(1);
|
setPriority(1);
|
||||||
|
setEditingSelectedItems([]);
|
||||||
setEditingAction(null);
|
setEditingAction(null);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}
|
}
|
||||||
@@ -83,6 +85,7 @@ export function ActionPanel({
|
|||||||
setTitle(action.title);
|
setTitle(action.title);
|
||||||
setDescription(action.description || '');
|
setDescription(action.description || '');
|
||||||
setPriority(action.priority);
|
setPriority(action.priority);
|
||||||
|
setEditingSelectedItems(action.links.map((link) => link.swotItemId));
|
||||||
setEditingAction(action);
|
setEditingAction(action);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}
|
}
|
||||||
@@ -90,9 +93,16 @@ export function ActionPanel({
|
|||||||
function closeModal() {
|
function closeModal() {
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setEditingAction(null);
|
setEditingAction(null);
|
||||||
|
setEditingSelectedItems([]);
|
||||||
onExitLinkMode();
|
onExitLinkMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleEditingItem(itemId: string) {
|
||||||
|
setEditingSelectedItems((prev) =>
|
||||||
|
prev.includes(itemId) ? prev.filter((id) => id !== itemId) : [...prev, itemId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -100,10 +110,15 @@ export function ActionPanel({
|
|||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
if (editingAction) {
|
if (editingAction) {
|
||||||
|
if (editingSelectedItems.length < 2) {
|
||||||
|
alert('Une action doit être liée à au moins 2 items SWOT');
|
||||||
|
return;
|
||||||
|
}
|
||||||
await updateAction(editingAction.id, sessionId, {
|
await updateAction(editingAction.id, sessionId, {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
priority,
|
priority,
|
||||||
|
linkedItemIds: editingSelectedItems,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await createAction(sessionId, {
|
await createAction(sessionId, {
|
||||||
@@ -264,16 +279,15 @@ export function ActionPanel({
|
|||||||
>
|
>
|
||||||
{priorityLabels[action.priority]}
|
{priorityLabels[action.priority]}
|
||||||
</Badge>
|
</Badge>
|
||||||
<select
|
<Select
|
||||||
value={action.status}
|
value={action.status}
|
||||||
onChange={(e) => handleStatusChange(action, e.target.value)}
|
onChange={(e) => handleStatusChange(action, e.target.value)}
|
||||||
className="rounded border border-border bg-card px-2 py-1 text-xs text-foreground"
|
options={statusOptions}
|
||||||
|
size="xs"
|
||||||
|
wrapperClassName="!w-auto shrink-0"
|
||||||
|
className="!w-auto"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
/>
|
||||||
<option value="todo">{statusLabels.todo}</option>
|
|
||||||
<option value="in_progress">{statusLabels.in_progress}</option>
|
|
||||||
<option value="done">{statusLabels.done}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -302,6 +316,48 @@ export function ActionPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingAction && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="mb-2 text-sm font-medium text-foreground">
|
||||||
|
Items liés ({editingSelectedItems.length}) :
|
||||||
|
</p>
|
||||||
|
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border border-border bg-background p-3">
|
||||||
|
{allItems.map((item) => {
|
||||||
|
const isSelected = editingSelectedItems.includes(item.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={item.id}
|
||||||
|
className={`
|
||||||
|
flex cursor-pointer items-center gap-2 rounded-lg border p-2 transition-colors
|
||||||
|
${
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'border-border bg-card hover:bg-card-hover'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleEditingItem(item.id)}
|
||||||
|
className="h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<Badge variant={categoryBadgeVariant[item.category]} className="shrink-0">
|
||||||
|
{categoryShort[item.category]}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm text-foreground">{item.content}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{editingSelectedItems.length < 2 && (
|
||||||
|
<p className="mt-2 text-xs text-destructive">
|
||||||
|
Sélectionnez au moins 2 items SWOT
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Input
|
<Input
|
||||||
label="Titre de l'action"
|
label="Titre de l'action"
|
||||||
|
|||||||
@@ -62,9 +62,9 @@ interface QuadrantHelpProps {
|
|||||||
category: SwotCategory;
|
category: SwotCategory;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuadrantHelp({ category }: QuadrantHelpProps) {
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
export function QuadrantHelp({ category: _category }: QuadrantHelpProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const content = HELP_CONTENT[category];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { forwardRef, useState, useTransition } from 'react';
|
import { forwardRef, memo, useState, useTransition } from 'react';
|
||||||
import type { SwotItem, SwotCategory } from '@prisma/client';
|
import type { SwotItem, SwotCategory } from '@prisma/client';
|
||||||
import { updateSwotItem, deleteSwotItem, duplicateSwotItem } from '@/actions/swot';
|
import { updateSwotItem, deleteSwotItem, duplicateSwotItem } from '@/actions/swot';
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ const categoryStyles: Record<SwotCategory, { ring: string; text: string }> = {
|
|||||||
THREAT: { ring: 'ring-threat', text: 'text-threat' },
|
THREAT: { ring: 'ring-threat', text: 'text-threat' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
|
export const SwotCard = memo(forwardRef<HTMLDivElement, SwotCardProps>(
|
||||||
(
|
(
|
||||||
{ item, sessionId, isSelected, isHighlighted, isDragging, linkMode, onSelect, ...props },
|
{ item, sessionId, isSelected, isHighlighted, isDragging, linkMode, onSelect, ...props },
|
||||||
ref
|
ref
|
||||||
@@ -196,6 +196,5 @@ export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
));
|
||||||
|
|
||||||
SwotCard.displayName = 'SwotCard';
|
SwotCard.displayName = 'SwotCard';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { forwardRef, useState, useTransition, ReactNode } from 'react';
|
import { forwardRef, useState, useTransition, useRef, ReactNode } from 'react';
|
||||||
import type { SwotCategory } from '@prisma/client';
|
import type { SwotCategory } from '@prisma/client';
|
||||||
import { createSwotItem } from '@/actions/swot';
|
import { createSwotItem } from '@/actions/swot';
|
||||||
import { QuadrantHelpPanel } from './QuadrantHelp';
|
import { QuadrantHelpPanel } from './QuadrantHelp';
|
||||||
@@ -43,15 +43,17 @@ export const SwotQuadrant = forwardRef<HTMLDivElement, SwotQuadrantProps>(
|
|||||||
const [newContent, setNewContent] = useState('');
|
const [newContent, setNewContent] = useState('');
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
const isSubmittingRef = useRef(false);
|
||||||
|
|
||||||
const styles = categoryStyles[category];
|
const styles = categoryStyles[category];
|
||||||
|
|
||||||
async function handleAdd() {
|
async function handleAdd() {
|
||||||
if (!newContent.trim()) {
|
if (isSubmittingRef.current || !newContent.trim()) {
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isSubmittingRef.current = true;
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
await createSwotItem(sessionId, {
|
await createSwotItem(sessionId, {
|
||||||
content: newContent.trim(),
|
content: newContent.trim(),
|
||||||
@@ -59,6 +61,7 @@ export const SwotQuadrant = forwardRef<HTMLDivElement, SwotQuadrantProps>(
|
|||||||
});
|
});
|
||||||
setNewContent('');
|
setNewContent('');
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
|
isSubmittingRef.current = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +141,12 @@ export const SwotQuadrant = forwardRef<HTMLDivElement, SwotQuadrantProps>(
|
|||||||
value={newContent}
|
value={newContent}
|
||||||
onChange={(e) => setNewContent(e.target.value)}
|
onChange={(e) => setNewContent(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onBlur={handleAdd}
|
onBlur={(e) => {
|
||||||
|
// Don't trigger on blur if clicking on a button
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
|
handleAdd();
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder="Décrivez cet élément..."
|
placeholder="Décrivez cet élément..."
|
||||||
className="w-full resize-none rounded border-0 bg-transparent p-1 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-0"
|
className="w-full resize-none rounded border-0 bg-transparent p-1 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-0"
|
||||||
rows={2}
|
rows={2}
|
||||||
@@ -156,6 +164,9 @@ export const SwotQuadrant = forwardRef<HTMLDivElement, SwotQuadrantProps>(
|
|||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault(); // Prevent blur from textarea
|
||||||
|
}}
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
disabled={isPending || !newContent.trim()}
|
disabled={isPending || !newContent.trim()}
|
||||||
className={`rounded px-2 py-1 text-xs font-medium ${styles.text} hover:bg-white/50 disabled:opacity-50`}
|
className={`rounded px-2 py-1 text-xs font-medium ${styles.text} hover:bg-white/50 disabled:opacity-50`}
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ export { SwotBoard } from './SwotBoard';
|
|||||||
export { SwotQuadrant } from './SwotQuadrant';
|
export { SwotQuadrant } from './SwotQuadrant';
|
||||||
export { SwotCard } from './SwotCard';
|
export { SwotCard } from './SwotCard';
|
||||||
export { ActionPanel } from './ActionPanel';
|
export { ActionPanel } from './ActionPanel';
|
||||||
export { QuadrantHelp } from './QuadrantHelp';
|
export { QuadrantHelp, QuadrantHelpPanel } from './QuadrantHelp';
|
||||||
|
|||||||
160
src/components/teams/AddMemberModal.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Modal, ModalFooter } from '@/components/ui';
|
||||||
|
import { Button } from '@/components/ui';
|
||||||
|
import { Input } from '@/components/ui';
|
||||||
|
import { Select } from '@/components/ui';
|
||||||
|
import type { TeamRole } from '@/lib/types';
|
||||||
|
import { TEAM_ROLE_LABELS } from '@/lib/types';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddMemberModalProps {
|
||||||
|
teamId: string;
|
||||||
|
existingMemberIds: string[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddMemberModal({ teamId, existingMemberIds, onClose, onSuccess }: AddMemberModalProps) {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||||
|
const [role, setRole] = useState<TeamRole>('MEMBER');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [fetchingUsers, setFetchingUsers] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch all users
|
||||||
|
setFetchingUsers(true);
|
||||||
|
fetch('/api/users')
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
// Filter out existing members
|
||||||
|
const availableUsers = data.filter((user: User) => !existingMemberIds.includes(user.id));
|
||||||
|
setUsers(availableUsers);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error fetching users:', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setFetchingUsers(false);
|
||||||
|
});
|
||||||
|
}, [existingMemberIds]);
|
||||||
|
|
||||||
|
const filteredUsers = users.filter(
|
||||||
|
(user) =>
|
||||||
|
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
user.name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!selectedUserId) {
|
||||||
|
alert('Veuillez sélectionner un utilisateur');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/teams/${teamId}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: selectedUserId, role }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Erreur lors de l\'ajout du membre');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding member:', error);
|
||||||
|
alert('Erreur lors de l\'ajout du membre');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={true} onClose={onClose} title="Ajouter un membre" size="md">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* User Search */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Rechercher un utilisateur
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Email ou nom..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
disabled={fetchingUsers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User List */}
|
||||||
|
{fetchingUsers ? (
|
||||||
|
<div className="text-center py-4 text-muted">Chargement...</div>
|
||||||
|
) : filteredUsers.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-muted">
|
||||||
|
{searchTerm ? 'Aucun utilisateur trouvé' : 'Aucun utilisateur disponible'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-60 overflow-y-auto border border-border rounded-lg">
|
||||||
|
{filteredUsers.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedUserId(user.id)}
|
||||||
|
className={`
|
||||||
|
w-full text-left px-4 py-3 hover:bg-card-hover transition-colors
|
||||||
|
${selectedUserId === user.id ? 'bg-primary/10 border-l-2 border-primary' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-foreground">{user.name || 'Sans nom'}</div>
|
||||||
|
<div className="text-sm text-muted">{user.email}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Role Selection */}
|
||||||
|
<Select
|
||||||
|
label="Rôle"
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => setRole(e.target.value as TeamRole)}
|
||||||
|
options={[
|
||||||
|
{ value: 'MEMBER', label: TEAM_ROLE_LABELS.MEMBER },
|
||||||
|
{ value: 'ADMIN', label: TEAM_ROLE_LABELS.ADMIN },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!selectedUserId || loading}
|
||||||
|
className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"
|
||||||
|
>
|
||||||
|
{loading ? 'Ajout...' : 'Ajouter'}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
77
src/components/teams/DeleteTeamButton.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@/components/ui';
|
||||||
|
import { Modal, ModalFooter } from '@/components/ui';
|
||||||
|
|
||||||
|
interface DeleteTeamButtonProps {
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteTeamButton({ teamId, teamName }: DeleteTeamButtonProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/teams/${teamId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || "Erreur lors de la suppression de l'équipe");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/teams');
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting team:', error);
|
||||||
|
alert("Erreur lors de la suppression de l'équipe");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
variant="outline"
|
||||||
|
className="text-destructive border-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
Supprimer l'équipe
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
title="Supprimer l'équipe"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-muted">
|
||||||
|
Êtes-vous sûr de vouloir supprimer l'équipe{' '}
|
||||||
|
<strong className="text-foreground">"{teamName}"</strong> ?
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
Cette action est irréversible. Tous les membres, OKRs et données associées seront
|
||||||
|
supprimés.
|
||||||
|
</p>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setShowModal(false)} disabled={isPending}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete} disabled={isPending}>
|
||||||
|
{isPending ? 'Suppression...' : 'Supprimer'}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
src/components/teams/MembersList.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { getGravatarUrl } from '@/lib/gravatar';
|
||||||
|
import { Badge } from '@/components/ui';
|
||||||
|
import { Button } from '@/components/ui';
|
||||||
|
import { AddMemberModal } from './AddMemberModal';
|
||||||
|
import type { TeamMember, TeamRole } from '@/lib/types';
|
||||||
|
import { TEAM_ROLE_LABELS } from '@/lib/types';
|
||||||
|
|
||||||
|
interface MembersListProps {
|
||||||
|
members: TeamMember[];
|
||||||
|
teamId: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onMemberUpdate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MembersList({ members, teamId, isAdmin, onMemberUpdate }: MembersListProps) {
|
||||||
|
const [addMemberOpen, setAddMemberOpen] = useState(false);
|
||||||
|
const [updatingRole, setUpdatingRole] = useState<string | null>(null);
|
||||||
|
const [removingMember, setRemovingMember] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleRoleChange = async (userId: string, newRole: TeamRole) => {
|
||||||
|
setUpdatingRole(userId);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/teams/${teamId}/members`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId, role: newRole }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Erreur lors de la mise à jour du rôle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMemberUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating role:', error);
|
||||||
|
alert('Erreur lors de la mise à jour du rôle');
|
||||||
|
} finally {
|
||||||
|
setUpdatingRole(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveMember = async (userId: string) => {
|
||||||
|
if (!confirm('Êtes-vous sûr de vouloir retirer ce membre de l\'équipe ?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRemovingMember(userId);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/teams/${teamId}/members?userId=${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Erreur lors de la suppression du membre');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMemberUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing member:', error);
|
||||||
|
alert('Erreur lors de la suppression du membre');
|
||||||
|
} finally {
|
||||||
|
setRemovingMember(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Membres ({members.length})</h3>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setAddMemberOpen(true)}
|
||||||
|
className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"
|
||||||
|
>
|
||||||
|
Ajouter un membre
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{members.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className="flex items-center gap-4 rounded-xl border border-border bg-card p-4"
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={getGravatarUrl(member.user.email, 96)}
|
||||||
|
alt={member.user.name || member.user.email}
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="rounded-full border-2 border-border"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* User Info */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-foreground truncate">
|
||||||
|
{member.user.name || 'Sans nom'}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
member.role === 'ADMIN'
|
||||||
|
? 'color-mix(in srgb, var(--purple) 15%, transparent)'
|
||||||
|
: 'color-mix(in srgb, var(--gray) 15%, transparent)',
|
||||||
|
color: member.role === 'ADMIN' ? 'var(--purple)' : 'var(--gray)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TEAM_ROLE_LABELS[member.role]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted truncate">{member.user.email}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{member.role === 'MEMBER' ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleRoleChange(member.userId, 'ADMIN')}
|
||||||
|
disabled={updatingRole === member.userId}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{updatingRole === member.userId ? '...' : 'Promouvoir Admin'}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleRoleChange(member.userId, 'MEMBER')}
|
||||||
|
disabled={updatingRole === member.userId}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{updatingRole === member.userId ? '...' : 'Rétrograder'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => handleRemoveMember(member.userId)}
|
||||||
|
disabled={removingMember === member.userId}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
style={{
|
||||||
|
color: 'var(--destructive)',
|
||||||
|
borderColor: 'var(--destructive)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{removingMember === member.userId ? '...' : 'Retirer'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addMemberOpen && (
|
||||||
|
<AddMemberModal
|
||||||
|
teamId={teamId}
|
||||||
|
existingMemberIds={members.map((m) => m.userId)}
|
||||||
|
onClose={() => setAddMemberOpen(false)}
|
||||||
|
onSuccess={onMemberUpdate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||