Compare commits

..

61 Commits

Author SHA1 Message Date
c828ab1a48 perf: optimize DB queries, SSE polling, and client rendering
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m45s
- Fix resolveCollaborator N+1: replace full User table scan with findFirst
- Fix getAllUsersWithStats N+1: use groupBy instead of per-user count queries
- Cache getTeamMemberIdsForAdminTeams and isAdminOfUser with React.cache
- Increase SSE poll interval from 1s to 2s across all 5 subscribe routes
- Add cleanupOldEvents method to session-share-events for event table TTL
- Add React.memo to all card components (Swot, Motivator, Weather, WeeklyCheckIn, YearReview)
- Fix WeatherCard useEffect+setState lint error with idiomatic prop sync pattern
- Add optimizePackageImports for DnD libs and poweredByHeader:false in next.config
- Add inline theme script in layout.tsx to prevent dark mode FOUC
- Remove unused Next.js template SVGs from public/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 14:04:58 +01:00
6dfeab5eb8 docs: add CLAUDE.md with project conventions and architecture guide
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:45:49 +01:00
74b1b2e838 fix: restore WeatherAverageBar component in session header and adjust styling
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m12s
Reintroduced the WeatherAverageBar component in the WeatherSessionPage to display team averages. Updated the styling of the WeatherAverageBar for improved spacing. Enhanced the EvolutionIndicator component to use dynamic background colors for better visibility of status indicators.
2026-02-25 07:55:01 +01:00
73219c89fb fix: make evolution indicators visually prominent with badge style
Replace plain text-xs arrows with 20×20px colored circular badges
(green ↑, red ↓, muted →) to ensure they are clearly visible next
to emoji cells. Also widen emoji columns from w-24 → w-28 to give
the badge room without overflow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 18:18:28 +01:00
30c2b6cc1e fix: display evolution indicators inline (flex-row) next to emoji instead of below 2026-02-24 17:17:45 +01:00
51bc187374 fix: convert Map to Record for server-client boundary, remove dead currentUser prop
- WeatherBoard: change previousEntries type from Map<string, PreviousEntry> to Record<string, PreviousEntry> and update lookup from .get() to bracket notation
- page.tsx: wrap previousEntries with Object.fromEntries() before passing as prop, remove unused currentUser prop
- WeatherCard: remove spurious eslint-disable-next-line comment for non-existent rule react-hooks/set-state-in-effect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:12:46 +01:00
3e869bf8ad feat: show evolution indicators per person per axis in weather board
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:02:31 +01:00
3b212d6dda feat: fetch and pass previous weather entries through component tree
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:00:12 +01:00
9b8c9efbd6 feat: display team weather average bar in session header 2026-02-24 16:57:26 +01:00
11c770da9c feat: add WeatherAverageBar component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 16:55:57 +01:00
220dcf87b9 feat: add getPreviousWeatherEntriesForUsers service function
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 16:54:21 +01:00
6b8d3c42f7 refactor: extract WEATHER_EMOJIS and add scoring utils to weather-utils.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 16:50:26 +01:00
Julien Froidefond
739b0bf87d feat: refactor session components to utilize BaseSessionLiveWrapper, streamlining sharing functionality and reducing code duplication across various session types
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m14s
2026-02-18 08:39:15 +01:00
Julien Froidefond
35228441e3 feat: add editable functionality for current quarter OKRs, allowing participants and team admins to modify objectives and key results, enhancing user interaction and collaboration
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m26s
2026-02-18 08:31:32 +01:00
Julien Froidefond
ee13f8ba99 feat: enhance dropdown components by integrating useClickOutside hook for improved user experience and accessibility in NewWorkshopDropdown and WorkshopTabs 2026-02-18 08:25:08 +01:00
Julien Froidefond
d50a8a0266 style: update card hover colors in globals.css and page.tsx for improved UI consistency
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m29s
2026-02-17 15:13:00 +01:00
Julien Froidefond
520a1f4838 feat: implement auto-sharing functionality for session creation across motivators, weekly check-ins, and year reviews, enhancing collaboration capabilities 2026-02-17 15:11:46 +01:00
Julien Froidefond
d05157d498 feat: integrate user team retrieval into session components, enhancing sharing functionality and user experience across motivators, sessions, weekly check-ins, and year reviews
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m35s
2026-02-17 14:47:43 +01:00
Julien Froidefond
4d04d3ede8 feat: refactor session retrieval logic to utilize generic session queries, enhancing code maintainability and reducing duplication across session types 2026-02-17 14:38:54 +01:00
Julien Froidefond
aad4b7f111 feat: enhance session management by implementing edit permissions for team admins and updating session components to reflect new access controls 2026-02-17 14:20:40 +01:00
Julien Froidefond
5e9ae0936f feat: implement getWeekBounds function for calculating ISO week boundaries and integrate it into weather session sharing logic 2026-02-17 14:05:50 +01:00
Julien Froidefond
4e14112ffa feat: add team collaboration sessions for admins, enhancing session management and visibility in the application 2026-02-17 14:03:31 +01:00
Julien Froidefond
7f3eabbdb2 refactor: update application name and related metadata from SWOT Manager to Workshop Manager for consistency across the project 2026-02-17 10:05:51 +01:00
Julien Froidefond
cc7e73ce7b feat: refactor workshop management by centralizing workshop data and improving session navigation across components
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m0s
2026-02-17 09:43:08 +01:00
Julien Froidefond
a8f53bfe2a feat: replace individual workshop buttons with a dropdown for creating new workshops in SessionsPage and update WorkshopTabs for improved tab management 2026-02-17 09:30:46 +01:00
Julien Froidefond
e8282bb118 feat: add apple icon to metadata for enhanced application branding
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m32s
2026-02-17 08:14:05 +01:00
Julien Froidefond
31d9c00b6d docs: update README.md to enhance feature descriptions and improve clarity on collaboration tools
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m57s
2026-02-17 06:58:21 +01:00
Julien Froidefond
7805e8dcd0 feat: add RocketIcon to Header component and update metadata with application icon 2026-02-16 11:44:41 +01:00
Julien Froidefond
390c4c653e fix: update modal title in WeeklyCheckInShareModal for improved clarity 2026-02-16 11:38:09 +01:00
Julien Froidefond
39910f559e feat: improve SSE broadcasting for weather sessions with enhanced error handling and connection management
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m49s
2026-02-04 13:18:10 +01:00
Julien Froidefond
057732f00e feat: enhance real-time weather session updates by broadcasting user information and syncing local state in WeatherCard component
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m14s
2026-02-04 11:05:33 +01:00
Julien Froidefond
e8ffccd286 refactor: streamline date and title handling in NewWeatherPage and NewWeeklyCheckInPage components for improved user experience
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-02-04 11:02:52 +01:00
Julien Froidefond
ef0772f894 feat: enhance user experience by adding notifications for OKR updates and improving session timeout handling for better usability
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m21s
2026-02-04 10:50:38 +01:00
Julien Froidefond
163caa398c feat: implement Weather Workshop feature with models, UI components, and session management for enhanced team visibility and personal well-being tracking
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m16s
2026-02-03 18:08:06 +01:00
Julien Froidefond
3a2eb83197 feat: add comparePeriods utility for sorting OKR periods and refactor ObjectivesPage to utilize it for improved period sorting
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 8m28s
2026-01-28 13:58:01 +01:00
Julien Froidefond
e848e85b63 feat: implement period filtering in OKRsList component with toggle for viewing all OKRs or current quarter's OKRs
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-01-28 13:54:10 +01:00
Julien Froidefond
53ee344ae7 feat: add Weekly Check-in feature with models, UI components, and session management for enhanced team collaboration
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m24s
2026-01-14 10:23:58 +01:00
Julien Froidefond
67d685d346 refactor: update component exports in OKRs, SWOT, Teams, and UI modules for improved organization and clarity 2026-01-13 14:51:50 +01:00
Julien Froidefond
47703db348 refactor: update OKR form and edit page to use new CreateKeyResultInput type for improved type safety and clarity
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m54s
2026-01-07 17:32:27 +01:00
Julien Froidefond
86c26b5af8 fix: improve error handling in API routes and update date handling for OKR and Key Result submissions
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 3m38s
2026-01-07 17:22:33 +01:00
Julien Froidefond
97045342b7 feat: refactor ObjectivesPage to utilize ObjectivesList component for improved rendering and simplify OKR status handling in OKRCard with compact view option
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 4m17s
2026-01-07 17:18:16 +01:00
Julien Froidefond
ca9b68ebbd feat: enhance OKR management by adding permission checks for editing and deleting, and updating OKR forms to handle key results more effectively
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 4m44s
2026-01-07 16:48:23 +01:00
Julien Froidefond
5f661c8bfd feat: introduce Teams & OKRs feature with models, types, and UI components for team management and objective tracking
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 12m53s
2026-01-07 10:11:59 +01:00
Julien Froidefond
e3a47dd7e5 chore: update .gitignore to include database files and improve data management 2025-12-16 10:45:35 +01:00
Julien Froidefond
35b9ac8a66 chore: remove obsolete database files from the project to streamline data management 2025-12-16 10:45:30 +01:00
Julien Froidefond
fd65e0d5b9 feat: enhance live collaboration features by introducing useLive hook for real-time event handling across motivators, sessions, and year reviews; refactor existing hooks to utilize this new functionality
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m39s
2025-12-16 10:41:16 +01:00
Julien Froidefond
246298dd82 refactor: consolidate editable title components into a unified UI module, removing redundant files and updating imports 2025-12-16 08:58:09 +01:00
Julien Froidefond
56a9c2c3be feat: implement Year Review feature with session management, item categorization, and real-time collaboration
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m7s
2025-12-16 08:55:13 +01:00
Julien Froidefond
48ff86fb5f chore: update deploy workflow to rebuild Docker images during deployment
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m43s
2025-12-15 13:43:57 +01:00
Julien Froidefond
d735e1c4c5 feat: add linked item management to action updates in SWOT analysis
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5s
2025-12-15 13:34:09 +01:00
Julien Froidefond
0cf7437efe chore: optimize Dockerfile by adding cache mount for pnpm installation to improve build performance
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5s
2025-12-13 12:16:14 +01:00
Julien Froidefond
ccb5338aa6 chore: update Dockerfile to set PNPM_HOME environment variable and prepare directory for pnpm installation 2025-12-13 12:16:07 +01:00
Julien Froidefond
fa2879c903 fix: update next to 16.0.10
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4s
2025-12-13 07:28:00 +01:00
Julien Froidefond
0daade6533 chore: update docker-compose.yml to change data volume path to a relative directory for better portability
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4s
2025-12-11 11:24:26 +01:00
Julien Froidefond
acdcc37091 chore: update docker-compose.yml to set a specific data volume path for improved data management
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4s
2025-12-11 11:22:09 +01:00
Julien Froidefond
7a4de67b9c chore: update docker-compose.yml to set a specific data volume path and modify deploy workflow to include DATA_VOLUME_PATH variable
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6s
2025-12-11 11:21:15 +01:00
Julien Froidefond
27995e7e7f chore: update docker-compose.yml to use dynamic data volume path and modify deploy workflow to reference environment variables
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 9s
2025-12-11 11:18:15 +01:00
Julien Froidefond
8a3966e6a9 chore: update deploy workflow to enable Docker BuildKit and add environment variables for authentication and database configuration
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 13s
2025-12-11 08:56:03 +01:00
Julien Froidefond
e2232ca595 chore: update .gitignore to include data directory and modify docker-compose.yml for external volume mapping
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 20s
2025-12-11 07:58:58 +01:00
Julien Froidefond
434043041c chore: rename app service to workshop-manager-app in docker-compose.yml for clarity
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 10m44s
2025-12-10 14:29:28 +01:00
9764402ef2 feat: adapting dor server 2025-12-06 13:09:12 +01:00
151 changed files with 13555 additions and 1547 deletions

View File

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

4
.gitignore vendored
View File

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

89
CLAUDE.md Normal file
View 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

View File

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

74
PERF_OPTIMIZATIONS.md Normal file
View 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

View File

@@ -5,18 +5,23 @@ Plateforme d'ateliers managériaux interactifs et collaboratifs.
## ✨ Fonctionnalités
### 📊 Analyse SWOT
Cartographiez les forces, faiblesses, opportunités et menaces de vos collaborateurs.
- Matrice interactive avec drag & drop
- Actions croisées et plan de développement
- Collaboration en temps réel
### 🎯 Moving Motivators
Explorez les 10 motivations intrinsèques (Management 3.0).
- Classement par importance
- Évaluation de l'influence positive/négative
- Récapitulatif personnalisé
### 🤝 Collaboration
- Partage de sessions (Éditeur / Lecteur)
- Synchronisation temps réel (SSE)
- Historique sauvegardé

Binary file not shown.

Binary file not shown.

BIN
dev.db

Binary file not shown.

View File

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

View File

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

View File

@@ -2,6 +2,15 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
poweredByHeader: false,
experimental: {
optimizePackageImports: [
"@dnd-kit/core",
"@dnd-kit/sortable",
"@dnd-kit/utilities",
"@hello-pangea/dnd",
],
},
};
export default nextConfig;

View File

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

95
pnpm-lock.yaml generated
View File

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

View File

@@ -0,0 +1,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");

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,22 @@ model User {
motivatorSessions MovingMotivatorsSession[]
sharedMotivatorSessions MMSessionShare[]
motivatorSessionEvents MMSessionEvent[]
// Year Review relations
yearReviewSessions YearReviewSession[]
sharedYearReviewSessions YRSessionShare[]
yearReviewSessionEvents YRSessionEvent[]
// Weekly Check-in relations
weeklyCheckInSessions WeeklyCheckInSession[]
sharedWeeklyCheckInSessions WCISessionShare[]
weeklyCheckInSessionEvents WCISessionEvent[]
// Weather Workshop relations
weatherSessions WeatherSession[]
sharedWeatherSessions WeatherSessionShare[]
weatherSessionEvents WeatherSessionEvent[]
weatherEntries WeatherEntry[]
// Teams & OKRs relations
createdTeams Team[]
teamMembers TeamMember[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -200,3 +216,312 @@ model MMSessionEvent {
@@index([sessionId, createdAt])
}
// ============================================
// Year Review Workshop
// ============================================
enum YearReviewCategory {
ACHIEVEMENTS // Réalisations / Accomplissements
CHALLENGES // Défis / Difficultés rencontrées
LEARNINGS // Apprentissages / Compétences développées
GOALS // Objectifs pour l'année suivante
MOMENTS // Moments forts / Moments difficiles
}
model YearReviewSession {
id String @id @default(cuid())
title String
participant String // Nom du participant
year Int // Année du bilan (ex: 2024)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items YearReviewItem[]
shares YRSessionShare[]
events YRSessionEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([year])
}
model YearReviewItem {
id String @id @default(cuid())
content String
category YearReviewCategory
order Int @default(0)
sessionId String
session YearReviewSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([sessionId])
@@index([sessionId, category])
}
model YRSessionShare {
id String @id @default(cuid())
sessionId String
session YearReviewSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role ShareRole @default(EDITOR)
createdAt DateTime @default(now())
@@unique([sessionId, userId])
@@index([sessionId])
@@index([userId])
}
model YRSessionEvent {
id String @id @default(cuid())
sessionId String
session YearReviewSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // ITEM_CREATED, ITEM_UPDATED, ITEM_DELETED, etc.
payload String // JSON payload
createdAt DateTime @default(now())
@@index([sessionId, createdAt])
}
// ============================================
// Teams & OKRs
// ============================================
enum TeamRole {
ADMIN
MEMBER
}
enum OKRStatus {
NOT_STARTED
IN_PROGRESS
COMPLETED
CANCELLED
}
enum KeyResultStatus {
NOT_STARTED
IN_PROGRESS
COMPLETED
AT_RISK
}
model Team {
id String @id @default(cuid())
name String
description String?
createdById String
creator User @relation(fields: [createdById], references: [id], onDelete: Cascade)
members TeamMember[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([createdById])
}
model TeamMember {
id String @id @default(cuid())
teamId String
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role TeamRole @default(MEMBER)
okrs OKR[]
joinedAt DateTime @default(now())
@@unique([teamId, userId])
@@index([teamId])
@@index([userId])
}
model OKR {
id String @id @default(cuid())
teamMemberId String
teamMember TeamMember @relation(fields: [teamMemberId], references: [id], onDelete: Cascade)
objective String
description String?
period String // Q1 2025, Q2 2025, H1 2025, 2025, etc.
startDate DateTime
endDate DateTime
status OKRStatus @default(NOT_STARTED)
keyResults KeyResult[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([teamMemberId])
@@index([teamMemberId, period])
@@index([status])
}
model KeyResult {
id String @id @default(cuid())
okrId String
okr OKR @relation(fields: [okrId], references: [id], onDelete: Cascade)
title String
targetValue Float
currentValue Float @default(0)
unit String @default("%") // %, nombre, etc.
status KeyResultStatus @default(NOT_STARTED)
order Int @default(0)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([okrId])
@@index([okrId, order])
}
// ============================================
// Weekly Check-in Workshop
// ============================================
enum WeeklyCheckInCategory {
WENT_WELL // Ce qui s'est bien passé
WENT_WRONG // Ce qui s'est mal passé
CURRENT_FOCUS // Les enjeux du moment (je me concentre sur ...)
NEXT_FOCUS // Les prochains enjeux
}
enum Emotion {
PRIDE // Fierté
JOY // Joie
SATISFACTION // Satisfaction
GRATITUDE // Gratitude
CONFIDENCE // Confiance
FRUSTRATION // Frustration
WORRY // Inquiétude
DISAPPOINTMENT // Déception
EXCITEMENT // Excitement
ANTICIPATION // Anticipation
DETERMINATION // Détermination
NONE // Aucune émotion
}
model WeeklyCheckInSession {
id String @id @default(cuid())
title String
participant String // Nom du participant
date DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items WeeklyCheckInItem[]
shares WCISessionShare[]
events WCISessionEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([date])
}
model WeeklyCheckInItem {
id String @id @default(cuid())
content String
category WeeklyCheckInCategory
emotion Emotion @default(NONE)
order Int @default(0)
sessionId String
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([sessionId])
@@index([sessionId, category])
}
model WCISessionShare {
id String @id @default(cuid())
sessionId String
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role ShareRole @default(EDITOR)
createdAt DateTime @default(now())
@@unique([sessionId, userId])
@@index([sessionId])
@@index([userId])
}
model WCISessionEvent {
id String @id @default(cuid())
sessionId String
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // ITEM_CREATED, ITEM_UPDATED, ITEM_DELETED, etc.
payload String // JSON payload
createdAt DateTime @default(now())
@@index([sessionId, createdAt])
}
// ============================================
// Weather Workshop
// ============================================
model WeatherSession {
id String @id @default(cuid())
title String
date DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
entries WeatherEntry[]
shares WeatherSessionShare[]
events WeatherSessionEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([date])
}
model WeatherEntry {
id String @id @default(cuid())
sessionId String
session WeatherSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
performanceEmoji String? // Emoji météo pour Performance
moralEmoji String? // Emoji météo pour Moral
fluxEmoji String? // Emoji météo pour Flux
valueCreationEmoji String? // Emoji météo pour Création de valeur
notes String? // Notes globales
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([sessionId, userId]) // Un seul entry par membre par session
@@index([sessionId])
@@index([userId])
}
model WeatherSessionShare {
id String @id @default(cuid())
sessionId String
session WeatherSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role ShareRole @default(EDITOR)
createdAt DateTime @default(now())
@@unique([sessionId, userId])
@@index([sessionId])
@@index([userId])
}
model WeatherSessionEvent {
id String @id @default(cuid())
sessionId String
session WeatherSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // ENTRY_CREATED, ENTRY_UPDATED, ENTRY_DELETED, SESSION_UPDATED, etc.
payload String // JSON payload
createdAt DateTime @default(now())
@@index([sessionId, createdAt])
}

View File

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

View File

@@ -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
View 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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

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

View File

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

View File

@@ -16,6 +16,16 @@ export async function createMotivatorSession(data: { title: string; participant:
try {
const motivatorSession = await motivatorsService.createMotivatorSession(session.user.id, data);
try {
await motivatorsService.shareMotivatorSession(
motivatorSession.id,
session.user.id,
data.participant,
'EDITOR'
);
} catch (shareError) {
console.error('Auto-share failed:', shareError);
}
revalidatePath('/motivators');
return { success: true, data: motivatorSession };
} catch (error) {

View File

@@ -17,6 +17,9 @@ export async function createSwotItem(
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
return { success: false, error: 'Non autorisé' };
}
try {
const item = await sessionsService.createSwotItem(sessionId, data);
@@ -45,6 +48,9 @@ export async function updateSwotItem(
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
return { success: false, error: 'Non autorisé' };
}
try {
const item = await sessionsService.updateSwotItem(itemId, data);
@@ -68,6 +74,9 @@ export async function deleteSwotItem(itemId: string, sessionId: string) {
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
return { success: false, error: 'Non autorisé' };
}
try {
await sessionsService.deleteSwotItem(itemId);
@@ -90,6 +99,9 @@ export async function duplicateSwotItem(itemId: string, sessionId: string) {
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
return { success: false, error: 'Non autorisé' };
}
try {
const item = await sessionsService.duplicateSwotItem(itemId);
@@ -120,6 +132,9 @@ export async function moveSwotItem(
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
return { success: false, error: 'Non autorisé' };
}
try {
const item = await sessionsService.moveSwotItem(itemId, newCategory, newOrder);
@@ -156,6 +171,9 @@ export async function createAction(
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
return { success: false, error: 'Non autorisé' };
}
try {
const action = await sessionsService.createAction(sessionId, data);
@@ -183,12 +201,16 @@ export async function updateAction(
description?: string;
priority?: number;
status?: string;
linkedItemIds?: string[];
}
) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
return { success: false, error: 'Non autorisé' };
}
try {
const action = await sessionsService.updateAction(actionId, data);
@@ -212,6 +234,9 @@ export async function deleteAction(actionId: string, sessionId: string) {
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
if (!(await sessionsService.canEditSession(sessionId, session.user.id))) {
return { success: false, error: 'Non autorisé' };
}
try {
await sessionsService.deleteAction(actionId);

276
src/actions/weather.ts Normal file
View 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' };
}
}

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

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { RocketIcon } from '@/components/ui';
export default function LoginPage() {
const router = useRouter();
@@ -44,8 +45,8 @@ export default function LoginPage() {
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<Link href="/" className="inline-flex items-center gap-2">
<span className="text-3xl">📊</span>
<span className="text-2xl font-bold text-foreground">SWOT Manager</span>
<RocketIcon className="h-8 w-8 shrink-0 text-primary" />
<span className="text-2xl font-bold text-foreground">Workshop Manager</span>
</Link>
<p className="mt-2 text-muted">Connectez-vous à votre compte</p>
</div>

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { RocketIcon } from '@/components/ui';
export default function RegisterPage() {
const router = useRouter();
@@ -73,8 +74,8 @@ export default function RegisterPage() {
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<Link href="/" className="inline-flex items-center gap-2">
<span className="text-3xl">📊</span>
<span className="text-2xl font-bold text-foreground">SWOT Manager</span>
<RocketIcon className="h-8 w-8 shrink-0 text-primary" />
<span className="text-2xl font-bold text-foreground">Workshop Manager</span>
</Link>
<p className="mt-2 text-muted">Créez votre compte</p>
</div>

View File

@@ -77,7 +77,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
// Connection might be closed
clearInterval(pollInterval);
}
}, 1000); // Poll every second
}, 2000); // Poll every 2 seconds
// Cleanup on abort
request.signal.addEventListener('abort', () => {

View 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 }
);
}
}

View 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
View 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 });
}
}

View File

@@ -77,7 +77,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
// Connection might be closed
clearInterval(pollInterval);
}
}, 1000); // Poll every second
}, 2000); // Poll every 2 seconds
// Cleanup on abort
request.signal.addEventListener('abort', () => {

View File

@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { prisma } from '@/services/database';
import { shareSession } from '@/services/sessions';
export async function GET() {
try {
@@ -56,6 +57,12 @@ export async function POST(request: Request) {
},
});
try {
await shareSession(newSession.id, session.user.id, collaborator, 'EDITOR');
} catch (shareError) {
console.error('Auto-share failed:', shareError);
}
return NextResponse.json(newSession, { status: 201 });
} catch (error) {
console.error('Error creating session:', error);

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -0,0 +1,123 @@
import { auth } from '@/lib/auth';
import {
canAccessYearReviewSession,
getYearReviewSessionEvents,
} from '@/services/year-review';
export const dynamic = 'force-dynamic';
// Store active connections per session
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id: sessionId } = await params;
const session = await auth();
if (!session?.user?.id) {
return new Response('Unauthorized', { status: 401 });
}
// Check access
const hasAccess = await canAccessYearReviewSession(sessionId, session.user.id);
if (!hasAccess) {
return new Response('Forbidden', { status: 403 });
}
const userId = session.user.id;
let lastEventTime = new Date();
let controller: ReadableStreamDefaultController;
const stream = new ReadableStream({
start(ctrl) {
controller = ctrl;
// Register connection
if (!connections.has(sessionId)) {
connections.set(sessionId, new Set());
}
connections.get(sessionId)!.add(controller);
// Send initial ping
const encoder = new TextEncoder();
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`)
);
},
cancel() {
// Remove connection on close
connections.get(sessionId)?.delete(controller);
if (connections.get(sessionId)?.size === 0) {
connections.delete(sessionId);
}
},
});
// Poll for new events (simple approach, works with any DB)
const pollInterval = setInterval(async () => {
try {
const events = await getYearReviewSessionEvents(sessionId, lastEventTime);
if (events.length > 0) {
const encoder = new TextEncoder();
for (const event of events) {
// Don't send events to the user who created them
if (event.userId !== userId) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: event.type,
payload: JSON.parse(event.payload),
userId: event.userId,
user: event.user,
timestamp: event.createdAt,
})}\n\n`
)
);
}
lastEventTime = event.createdAt;
}
}
} catch {
// Connection might be closed
clearInterval(pollInterval);
}
}, 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);
}
}

View File

@@ -1,7 +1,7 @@
@import 'tailwindcss';
/* ============================================
SWOT Manager - CSS Variables Theme System
Workshop Manager - CSS Variables Theme System
============================================ */
:root {
@@ -11,7 +11,7 @@
/* Cards & Surfaces */
--card: #ffffff;
--card-hover: #f1f5f9;
--card-hover: #e2e8f0;
--card-border: #e2e8f0;
/* Primary - Cyan/Teal */
@@ -39,6 +39,7 @@
/* Accent Colors */
--accent: #8b5cf6;
--accent-hover: #7c3aed;
--purple: #8b5cf6;
/* Status */
--success: #059669;
@@ -75,7 +76,7 @@
/* Cards & Surfaces */
--card: #1e293b;
--card-hover: #283548;
--card-hover: #334155;
--card-border: #2d3d53;
/* Primary - Cyan/Teal (softened) */
@@ -103,6 +104,7 @@
/* Accent Colors */
--accent: #a78bfa;
--accent-hover: #c4b5fd;
--purple: #a78bfa;
/* Status (softened) */
--success: #4ade80;

View File

@@ -15,7 +15,11 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: 'Workshop Manager',
description: "Application de gestion d'ateliers SWOT pour entretiens managériaux",
description: "Application de gestion d'ateliers pour entretiens managériaux",
icons: {
icon: '/icon.svg',
apple: '/rocket_blue_gradient_large_logo.jpg',
},
};
export default function RootLayout({
@@ -25,6 +29,13 @@ export default function RootLayout({
}>) {
return (
<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`}>
<Providers>{children}</Providers>
</body>

25
src/app/manifest.ts Normal file
View 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',
},
],
};
}

View File

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

View File

@@ -1,10 +1,13 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
import { getMotivatorSessionById } from '@/services/moving-motivators';
import { getUserTeams } from '@/services/teams';
import type { ResolvedCollaborator } from '@/services/auth';
import { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators';
import { Badge, CollaboratorDisplay } from '@/components/ui';
import { EditableMotivatorTitle } from './EditableTitle';
import { EditableMotivatorTitle } from '@/components/ui';
interface MotivatorSessionPageProps {
params: Promise<{ id: string }>;
@@ -18,7 +21,10 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
return null;
}
const session = await getMotivatorSessionById(id, authSession.user.id);
const [session, userTeams] = await Promise.all([
getMotivatorSessionById(id, authSession.user.id),
getUserTeams(authSession.user.id),
]);
if (!session) {
notFound();
@@ -29,8 +35,8 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-muted mb-2">
<Link href="/sessions?tab=motivators" className="hover:text-foreground">
Moving Motivators
<Link href={getSessionsTabUrl('motivators')} className="hover:text-foreground">
{getWorkshop('motivators').label}
</Link>
<span>/</span>
<span className="text-foreground">{session.title}</span>
@@ -46,10 +52,14 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
<EditableMotivatorTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
canEdit={session.canEdit}
/>
<div className="mt-2">
<CollaboratorDisplay collaborator={session.resolvedParticipant} size="lg" showEmail />
<CollaboratorDisplay
collaborator={session.resolvedParticipant as ResolvedCollaborator}
size="lg"
showEmail
/>
</div>
</div>
<div className="flex items-center gap-3">
@@ -75,6 +85,7 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
userTeams={userTeams}
>
<MotivatorBoard sessionId={session.id} cards={session.cards} canEdit={session.canEdit} />
</MotivatorLiveWrapper>

View File

@@ -10,6 +10,7 @@ import {
CardContent,
Button,
Input,
ParticipantInput,
} from '@/components/ui';
import { createMotivatorSession } from '@/actions/moving-motivators';
@@ -72,12 +73,7 @@ export default function NewMotivatorSessionPage() {
required
/>
<Input
label="Nom du participant"
name="participant"
placeholder="Ex: Jean Dupont"
required
/>
<ParticipantInput name="participant" required />
<div className="rounded-lg border border-border bg-card-hover p-4">
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>

View 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&apos;avez pas encore d&apos;OKR défini. Contactez un administrateur d&apos;é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>
);
}

View File

@@ -1,4 +1,5 @@
import Link from 'next/link';
import { WORKSHOPS, getSessionsTabUrl } from '@/lib/workshops';
export default function Home() {
return (
@@ -20,38 +21,20 @@ export default function Home() {
<h2 className="mb-8 text-center text-2xl font-bold text-foreground">
Choisissez votre atelier
</h2>
<div className="grid gap-8 md:grid-cols-2 max-w-4xl mx-auto">
{/* SWOT Workshop Card */}
<WorkshopCard
href="/sessions?tab=swot"
icon="📊"
title="Analyse SWOT"
tagline="Analysez. Planifiez. Progressez."
description="Cartographiez les forces et faiblesses de vos collaborateurs. Identifiez opportunités et menaces pour définir des actions concrètes."
features={[
'Matrice interactive Forces/Faiblesses/Opportunités/Menaces',
'Actions croisées et plan de développement',
'Collaboration en temps réel',
]}
accentColor="#06b6d4"
newHref="/sessions/new"
/>
{/* Moving Motivators Workshop Card */}
<WorkshopCard
href="/sessions?tab=motivators"
icon="🎯"
title="Moving Motivators"
tagline="Révélez ce qui motive vraiment"
description="Explorez les 10 motivations intrinsèques de vos collaborateurs. Comprenez leur impact et alignez aspirations et missions."
features={[
'10 cartes de motivation à classer',
"Évaluation de l'influence positive/négative",
'Récapitulatif personnalisé des motivations',
]}
accentColor="#8b5cf6"
newHref="/motivators/new"
/>
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3 max-w-6xl mx-auto">
{WORKSHOPS.map((w) => (
<WorkshopCard
key={w.id}
href={getSessionsTabUrl(w.id)}
icon={w.icon}
title={w.cardLabel}
tagline={w.home.tagline}
description={w.home.description}
features={w.home.features}
accentColor={w.accentColor}
newHref={w.newPath}
/>
))}
</div>
</section>
@@ -250,6 +233,379 @@ export default function Home() {
</div>
</section>
{/* Year Review Deep Dive Section */}
<section className="mb-16">
<div className="flex items-center gap-3 mb-8">
<span className="text-4xl">📅</span>
<div>
<h2 className="text-3xl font-bold text-foreground">Year Review</h2>
<p className="text-amber-500 font-medium">Faites le bilan de l&apos;année écoulée</p>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-2">
{/* Why */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">💭</span>
Pourquoi faire un bilan annuel ?
</h3>
<p className="text-muted mb-4">
Le Year Review est un exercice de réflexion structuré qui permet de prendre du recul
sur l&apos;année écoulée. Il aide à identifier les patterns, célébrer les réussites,
apprendre des défis et préparer l&apos;avenir avec clarté.
</p>
<ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2">
<span className="text-amber-500"></span>
Prendre conscience de ses accomplissements et les célébrer
</li>
<li className="flex items-start gap-2">
<span className="text-amber-500"></span>
Identifier les apprentissages et compétences développées
</li>
<li className="flex items-start gap-2">
<span className="text-amber-500"></span>
Comprendre les défis rencontrés pour mieux les anticiper
</li>
<li className="flex items-start gap-2">
<span className="text-amber-500"></span>
Définir des objectifs clairs et motivants pour l&apos;année à venir
</li>
</ul>
</div>
{/* The 5 categories */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">📋</span>
Les 5 catégories du bilan
</h3>
<div className="space-y-3">
<CategoryPill icon="🏆" name="Réalisations" color="#22c55e" description="Ce que vous avez accompli" />
<CategoryPill icon="⚔️" name="Défis" color="#ef4444" description="Les difficultés rencontrées" />
<CategoryPill icon="📚" name="Apprentissages" color="#3b82f6" description="Ce que vous avez appris" />
<CategoryPill icon="🎯" name="Objectifs" color="#8b5cf6" description="Vos ambitions pour l'année prochaine" />
<CategoryPill icon="⭐" name="Moments" color="#f59e0b" description="Les moments forts et marquants" />
</div>
</div>
{/* How it works */}
<div className="rounded-xl border border-border bg-card p-6 lg:col-span-2">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl"></span>
Comment ça marche ?
</h3>
<div className="grid md:grid-cols-4 gap-4">
<StepCard
number={1}
title="Réfléchir"
description="Prenez le temps de revenir sur l'année écoulée, consultez votre agenda, vos notes, vos projets"
/>
<StepCard
number={2}
title="Catégoriser"
description="Organisez vos réflexions dans les 5 catégories : réalisations, défis, apprentissages, objectifs et moments"
/>
<StepCard
number={3}
title="Prioriser"
description="Classez les éléments par importance et impact pour identifier ce qui compte vraiment"
/>
<StepCard
number={4}
title="Planifier"
description="Utilisez ce bilan pour définir vos objectifs et actions pour l'année à venir"
/>
</div>
</div>
</div>
</section>
{/* 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&apos;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&apos;échange ouvert 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&apos;œ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&apos;é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&apos;objectifs qui permet
d&apos;aligner les efforts de l&apos;équipe autour d&apos;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&apos;équipe
</li>
<li className="flex items-start gap-2">
<span className="text-purple-500"></span>
Suivre la progression en temps réel avec des métriques claires
</li>
<li className="flex items-start gap-2">
<span className="text-purple-500"></span>
Favoriser la transparence et la visibilité des objectifs de chacun
</li>
<li className="flex items-start gap-2">
<span className="text-purple-500"></span>
Créer une culture de responsabilisation et de résultats
</li>
</ul>
</div>
{/* Features */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl"></span>
Fonctionnalités principales
</h3>
<div className="space-y-3">
<FeaturePill
icon="👥"
name="Gestion d'équipes"
color="#8b5cf6"
description="Créez des équipes et gérez les membres avec des rôles admin/membre"
/>
<FeaturePill
icon="🎯"
name="OKRs par période"
color="#3b82f6"
description="Définissez des OKRs pour des trimestres ou périodes personnalisées"
/>
<FeaturePill
icon="📊"
name="Key Results mesurables"
color="#10b981"
description="Suivez la progression de chaque Key Result avec des valeurs et pourcentages"
/>
<FeaturePill
icon="👁️"
name="Visibilité transparente"
color="#f59e0b"
description="Tous les membres de l'équipe peuvent voir les OKRs de chacun"
/>
</div>
</div>
{/* How it works */}
<div className="rounded-xl border border-border bg-card p-6 lg:col-span-2">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl"></span>
Comment ça marche ?
</h3>
<div className="grid md:grid-cols-4 gap-4">
<StepCard
number={1}
title="Créer une équipe"
description="Formez votre équipe et ajoutez les membres avec leurs rôles (admin ou membre)"
/>
<StepCard
number={2}
title="Définir les OKRs"
description="Pour chaque membre, créez un Objectif avec plusieurs Key Results mesurables"
/>
<StepCard
number={3}
title="Suivre la progression"
description="Mettez à jour régulièrement les valeurs des Key Results pour suivre l'avancement"
/>
<StepCard
number={4}
title="Visualiser et analyser"
description="Consultez les OKRs par membre ou en grille, avec les progressions et statuts colorés"
/>
</div>
</div>
</div>
</section>
{/* Benefits Section */}
<section className="rounded-2xl border border-border bg-card p-8">
<h2 className="mb-8 text-center text-2xl font-bold text-foreground">
@@ -303,7 +659,7 @@ function WorkshopCard({
newHref: string;
}) {
return (
<div className="group relative overflow-hidden rounded-2xl border-2 border-border bg-card p-8 transition-all hover:border-primary/50 hover:shadow-xl">
<div className="group relative overflow-hidden rounded-2xl border-2 border-border bg-card p-8 transition-all hover:border-primary/50 hover:bg-card-hover hover:shadow-xl">
{/* Accent gradient */}
<div
className="absolute inset-x-0 top-0 h-1 opacity-80"
@@ -420,3 +776,57 @@ function MotivatorPill({ icon, name, color }: { icon: string; name: string; colo
</div>
);
}
function CategoryPill({
icon,
name,
color,
description,
}: {
icon: string;
name: string;
color: string;
description: string;
}) {
return (
<div
className="flex items-start gap-3 px-4 py-3 rounded-lg"
style={{ backgroundColor: `${color}10`, border: `1px solid ${color}30` }}
>
<span className="text-xl">{icon}</span>
<div className="flex-1">
<p className="font-semibold text-sm mb-0.5" style={{ color }}>
{name}
</p>
<p className="text-xs text-muted">{description}</p>
</div>
</div>
);
}
function FeaturePill({
icon,
name,
color,
description,
}: {
icon: string;
name: string;
color: string;
description: string;
}) {
return (
<div
className="flex items-start gap-3 px-4 py-3 rounded-lg"
style={{ backgroundColor: `${color}10`, border: `1px solid ${color}30` }}
>
<span className="text-xl">{icon}</span>
<div className="flex-1">
<p className="font-semibold text-sm mb-0.5" style={{ color }}>
{name}
</p>
<p className="text-xs text-muted">{description}</p>
</div>
</div>
);
}

View 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>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useTransition } from 'react';
import { useState, useTransition, useRef } from 'react';
import Link from 'next/link';
import { useSearchParams, useRouter } from 'next/navigation';
import {
@@ -14,22 +14,26 @@ import {
} from '@/components/ui';
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review';
import { deleteWeeklyCheckInSession, updateWeeklyCheckInSession } from '@/actions/weekly-checkin';
import { deleteWeatherSession, updateWeatherSession } from '@/actions/weather';
import {
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'];
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: 'VIEWER' | 'EDITOR';
user: ShareUser;
}
const TYPE_TABS = [
{ value: 'all' as const, icon: '📋', label: 'Tous' },
{ value: 'team' as const, icon: '🏢', label: 'Équipe' },
...WORKSHOPS.map((w) => ({ value: w.id, icon: w.icon, label: w.labelShort })),
];
interface ResolvedCollaborator {
raw: string;
@@ -52,6 +56,8 @@ interface SwotSession {
shares: Share[];
_count: { items: number; actions: number };
workshopType: 'swot';
isTeamCollab?: true;
canEdit?: boolean;
}
interface MotivatorSession {
@@ -66,36 +72,92 @@ interface MotivatorSession {
shares: Share[];
_count: { cards: number };
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 {
swotSessions: SwotSession[];
motivatorSessions: MotivatorSession[];
}
// Helper to get participant name from any session
function getParticipant(session: AnySession): string {
return session.workshopType === 'swot'
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant;
yearReviewSessions: YearReviewSession[];
weeklyCheckInSessions: WeeklyCheckInSession[];
weatherSessions: WeatherSession[];
teamCollabSessions?: (AnySession & { isTeamCollab?: true })[];
}
// Helper to get resolved collaborator from any session
function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
return session.workshopType === 'swot'
? (session as SwotSession).resolvedCollaborator
: (session as MotivatorSession).resolvedParticipant;
}
// Get display name for grouping - prefer matched user name
function getDisplayName(session: AnySession): string {
const resolved = getResolvedCollaborator(session);
if (resolved.matchedUser?.name) {
return resolved.matchedUser.name;
if (session.workshopType === 'swot') {
return (session as SwotSession).resolvedCollaborator;
} else if (session.workshopType === 'year-review') {
return (session as YearReviewSession).resolvedParticipant;
} else if (session.workshopType === 'weekly-checkin') {
return (session as WeeklyCheckInSession).resolvedParticipant;
} else if (session.workshopType === 'weather') {
// For weather sessions, use the owner as the "participant" since it's a personal weather
const weatherSession = session as WeatherSession;
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
@@ -132,16 +194,26 @@ function groupByPerson(sessions: AnySession[]): Map<string, AnySession[]> {
return grouped;
}
export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsProps) {
export function WorkshopTabs({
swotSessions,
motivatorSessions,
yearReviewSessions,
weeklyCheckInSessions,
weatherSessions,
teamCollabSessions = [],
}: WorkshopTabsProps) {
const searchParams = useSearchParams();
const router = useRouter();
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false);
// Get tab from URL or default to 'all'
const tabParam = searchParams.get('tab');
const activeTab: WorkshopType =
tabParam && VALID_TABS.includes(tabParam as WorkshopType) ? (tabParam as WorkshopType) : 'all';
const activeTab: WorkshopTabType =
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());
if (tab === 'all') {
params.delete('tab');
@@ -151,22 +223,36 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
router.push(`/sessions${params.toString() ? `?${params.toString()}` : ''}`);
};
// Combine and sort all sessions
const allSessions: AnySession[] = [...swotSessions, ...motivatorSessions].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
// Combine and sort all sessions (exclude team collab from main list - they're shown separately)
const allSessions: AnySession[] = [
...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)
const filteredSessions =
activeTab === 'all' || activeTab === 'byPerson'
? allSessions
: activeTab === 'swot'
: activeTab === 'team'
? teamCollabSessions
: activeTab === 'swot'
? 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 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)
const sessionsByPerson = groupByPerson(allSessions);
@@ -177,7 +263,7 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
return (
<div className="space-y-6">
{/* 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
active={activeTab === 'all'}
onClick={() => setActiveTab('all')}
@@ -192,19 +278,28 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
label="Par personne"
count={sessionsByPerson.size}
/>
<TabButton
active={activeTab === 'swot'}
onClick={() => setActiveTab('swot')}
icon="📊"
label="SWOT"
count={swotSessions.length}
/>
<TabButton
active={activeTab === 'motivators'}
onClick={() => setActiveTab('motivators')}
icon="🎯"
label="Moving Motivators"
count={motivatorSessions.length}
{teamCollabSessions.length > 0 && (
<TabButton
active={activeTab === 'team'}
onClick={() => setActiveTab('team')}
icon="🏢"
label="Équipe"
count={teamCollabSessions.length}
/>
)}
<TypeFilterDropdown
activeTab={activeTab}
setActiveTab={setActiveTab}
open={typeDropdownOpen}
onOpenChange={setTypeDropdownOpen}
counts={{
swot: swotSessions.length,
motivators: motivatorSessions.length,
'year-review': yearReviewSessions.length,
'weekly-checkin': weeklyCheckInSessions.length,
weather: weatherSessions.length,
team: teamCollabSessions.length,
}}
/>
</div>
@@ -235,6 +330,28 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
})}
</div>
)
) : activeTab === 'team' ? (
teamCollabSessions.length === 0 ? (
<div className="text-center py-12 text-muted">
Aucun atelier de vos collaborateurs d&apos;é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&apos;équipe non partagés ({teamCollabSessions.length})
</h2>
<p className="text-sm text-muted mb-4">
En tant qu&apos;admin d&apos;é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 ? (
<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>
</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>
@@ -287,9 +505,10 @@ function TabButton({
}) {
return (
<button
type="button"
onClick={onClick}
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
? 'bg-primary text-primary-foreground'
@@ -299,14 +518,14 @@ function TabButton({
>
<span>{icon}</span>
<span>{label}</span>
<Badge variant={active ? 'default' : 'primary'} className="ml-1">
<Badge variant={active ? 'default' : 'primary'} className="ml-1 text-xs">
{count}
</Badge>
</button>
);
}
function SessionCard({ session }: { session: AnySession }) {
function SessionCard({ session, isTeamCollab = false }: { session: AnySession; isTeamCollab?: boolean }) {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [isPending, startTransition] = useTransition();
@@ -316,22 +535,41 @@ function SessionCard({ session }: { session: AnySession }) {
const [editParticipant, setEditParticipant] = useState(
session.workshopType === 'swot'
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant
: session.workshopType === 'year-review'
? (session as YearReviewSession).participant
: session.workshopType === 'weather'
? ''
: (session as MotivatorSession).participant
);
const workshop = getWorkshop(session.workshopType as WorkshopTypeId);
const isSwot = session.workshopType === 'swot';
const href = isSwot ? `/sessions/${session.id}` : `/motivators/${session.id}`;
const icon = isSwot ? '📊' : '🎯';
const isYearReview = session.workshopType === 'year-review';
const isWeeklyCheckIn = session.workshopType === 'weekly-checkin';
const isWeather = session.workshopType === 'weather';
const href = getSessionPath(session.workshopType as WorkshopTypeId, session.id);
const participant = isSwot
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant;
const accentColor = isSwot ? '#06b6d4' : '#8b5cf6';
: isYearReview
? (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 = () => {
startTransition(async () => {
const result = isSwot
? 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) {
setShowDeleteModal(false);
@@ -345,10 +583,22 @@ function SessionCard({ session }: { session: AnySession }) {
startTransition(async () => {
const result = isSwot
? await updateSwotSession(session.id, { title: editTitle, collaborator: editParticipant })
: await updateMotivatorSession(session.id, {
title: editTitle,
participant: editParticipant,
});
: isYearReview
? await updateYearReviewSession(session.id, {
title: editTitle,
participant: editParticipant,
})
: 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) {
setShowEditModal(false);
@@ -365,11 +615,10 @@ function SessionCard({ session }: { session: AnySession }) {
setShowEditModal(true);
};
return (
<>
<div className="relative group">
<Link href={href}>
<Card hover className="h-full p-4 relative overflow-hidden">
const editParticipantLabel = workshop.participantLabel;
const cardContent = (
<Card hover={!isTeamCollab} className={`h-full p-4 relative overflow-hidden ${isTeamCollab ? 'opacity-60' : ''}`}>
{/* Accent bar */}
<div
className="absolute top-0 left-0 right-0 h-1"
@@ -378,7 +627,7 @@ function SessionCard({ session }: { session: AnySession }) {
{/* Header: Icon + Title + Role badge */}
<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>
{!session.isOwner && (
<span
@@ -414,6 +663,34 @@ function SessionCard({ session }: { session: AnySession }) {
<span>·</span>
<span>{(session as SwotSession)._count.actions} actions</span>
</>
) : isYearReview ? (
<>
<span>{(session as YearReviewSession)._count.items} items</span>
<span>·</span>
<span>Année {(session as YearReviewSession).year}</span>
</>
) : 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>
)}
@@ -452,10 +729,21 @@ function SessionCard({ session }: { session: AnySession }) {
</div>
)}
</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>
{/* Action buttons - only for owner */}
{session.isOwner && (
{/* Edit: owner, EDITOR, or team admin | Delete: owner or team admin only (not EDITOR) */}
{(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">
<button
onClick={(e) => {
@@ -475,6 +763,7 @@ function SessionCard({ session }: { session: AnySession }) {
/>
</svg>
</button>
{(session.isOwner || session.isTeamCollab) && (
<button
onClick={(e) => {
e.preventDefault();
@@ -493,6 +782,7 @@ function SessionCard({ session }: { session: AnySession }) {
/>
</svg>
</button>
)}
</div>
)}
</div>
@@ -528,15 +818,17 @@ function SessionCard({ session }: { session: AnySession }) {
htmlFor="edit-participant"
className="block text-sm font-medium text-foreground mb-1"
>
{isSwot ? 'Collaborateur' : 'Participant'}
{editParticipantLabel}
</label>
<Input
id="edit-participant"
value={editParticipant}
onChange={(e) => setEditParticipant(e.target.value)}
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'}
required
/>
{!isWeather && (
<Input
id="edit-participant"
value={editParticipant}
onChange={(e) => setEditParticipant(e.target.value)}
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'}
required
/>
)}
</div>
<ModalFooter>
<Button
@@ -549,7 +841,7 @@ function SessionCard({ session }: { session: AnySession }) {
</Button>
<Button
type="submit"
disabled={isPending || !editTitle.trim() || !editParticipant.trim()}
disabled={isPending || !editTitle.trim() || (!isWeather && !editParticipant.trim())}
>
{isPending ? 'Enregistrement...' : 'Enregistrer'}
</Button>

View File

@@ -1,10 +1,12 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
import { getSessionById } from '@/services/sessions';
import { getUserTeams } from '@/services/teams';
import { SwotBoard } from '@/components/swot/SwotBoard';
import { SessionLiveWrapper } from '@/components/collaboration';
import { EditableTitle } from '@/components/session';
import { EditableSessionTitle } from '@/components/ui';
import { Badge, CollaboratorDisplay } from '@/components/ui';
interface SessionPageProps {
@@ -19,7 +21,10 @@ export default async function SessionPage({ params }: SessionPageProps) {
return null;
}
const session = await getSessionById(id, authSession.user.id);
const [session, userTeams] = await Promise.all([
getSessionById(id, authSession.user.id),
getUserTeams(authSession.user.id),
]);
if (!session) {
notFound();
@@ -30,8 +35,8 @@ export default async function SessionPage({ params }: SessionPageProps) {
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-muted mb-2">
<Link href="/sessions?tab=swot" className="hover:text-foreground">
SWOT
<Link href={getSessionsTabUrl('swot')} className="hover:text-foreground">
{getWorkshop('swot').labelShort}
</Link>
<span>/</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>
<EditableTitle
<EditableSessionTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
canEdit={session.canEdit}
/>
<div className="mt-2">
<CollaboratorDisplay
@@ -79,6 +84,7 @@ export default async function SessionPage({ params }: SessionPageProps) {
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
userTeams={userTeams}
>
<SwotBoard sessionId={session.id} items={session.items} actions={session.actions} />
</SessionLiveWrapper>

View File

@@ -10,6 +10,7 @@ import {
CardContent,
Button,
Input,
ParticipantInput,
} from '@/components/ui';
export default function NewSessionPage() {
@@ -82,12 +83,7 @@ export default function NewSessionPage() {
required
/>
<Input
label="Nom du collaborateur"
name="collaborator"
placeholder="Ex: Jean Dupont"
required
/>
<ParticipantInput name="collaborator" required />
<div className="flex gap-3 pt-4">
<Button

View File

@@ -1,10 +1,29 @@
import { Suspense } from 'react';
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getSessionsByUserId } from '@/services/sessions';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
import { Card, Button } from '@/components/ui';
import {
getSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamSwotSessions,
} from '@/services/sessions';
import {
getMotivatorSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamMotivatorSessions,
} from '@/services/moving-motivators';
import {
getYearReviewSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamYearReviewSessions,
} from '@/services/year-review';
import {
getWeeklyCheckInSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamWeeklyCheckInSessions,
} from '@/services/weekly-checkin';
import {
getWeatherSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamWeatherSessions,
} from '@/services/weather';
import { Card } from '@/components/ui';
import { withWorkshopType } from '@/lib/workshops';
import { WorkshopTabs } from './WorkshopTabs';
import { NewWorkshopDropdown } from './NewWorkshopDropdown';
function WorkshopTabsSkeleton() {
return (
@@ -32,27 +51,52 @@ export default async function SessionsPage() {
return null;
}
// Fetch both SWOT and Moving Motivators sessions
const [swotSessions, motivatorSessions] = await Promise.all([
// Fetch sessions (owned + shared) and team collab sessions (for team admins, non-shared)
const [
swotSessions,
motivatorSessions,
yearReviewSessions,
weeklyCheckInSessions,
weatherSessions,
teamSwotSessions,
teamMotivatorSessions,
teamYearReviewSessions,
teamWeeklyCheckInSessions,
teamWeatherSessions,
] = await Promise.all([
getSessionsByUserId(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
const allSwotSessions = swotSessions.map((s) => ({
...s,
workshopType: 'swot' as const,
}));
// Add workshopType to each session for unified display
const allSwotSessions = withWorkshopType(swotSessions, 'swot');
const allMotivatorSessions = withWorkshopType(motivatorSessions, 'motivators');
const allYearReviewSessions = withWorkshopType(yearReviewSessions, 'year-review');
const allWeeklyCheckInSessions = withWorkshopType(weeklyCheckInSessions, 'weekly-checkin');
const allWeatherSessions = withWorkshopType(weatherSessions, 'weather');
const allMotivatorSessions = motivatorSessions.map((s) => ({
...s,
workshopType: 'motivators' as const,
}));
const teamSwotWithType = withWorkshopType(teamSwotSessions, 'swot');
const teamMotivatorWithType = withWorkshopType(teamMotivatorSessions, 'motivators');
const teamYearReviewWithType = withWorkshopType(teamYearReviewSessions, 'year-review');
const teamWeeklyCheckInWithType = withWorkshopType(teamWeeklyCheckInSessions, 'weekly-checkin');
const teamWeatherWithType = withWorkshopType(teamWeatherSessions, 'weather');
// Combine and sort by updatedAt
const allSessions = [...allSwotSessions, ...allMotivatorSessions].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
const allSessions = [
...allSwotSessions,
...allMotivatorSessions,
...allYearReviewSessions,
...allWeeklyCheckInSessions,
...allWeatherSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
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>
<p className="mt-1 text-muted">Tous vos ateliers en un seul endroit</p>
</div>
<div className="flex gap-2">
<Link href="/sessions/new">
<Button variant="outline">
<span>📊</span>
Nouveau SWOT
</Button>
</Link>
<Link href="/motivators/new">
<Button>
<span>🎯</span>
Nouveau Motivators
</Button>
</Link>
</div>
<NewWorkshopDropdown />
</div>
{/* Content */}
@@ -88,27 +119,30 @@ export default async function SessionsPage() {
Commencez votre premier atelier
</h2>
<p className="text-muted mb-6 max-w-md mx-auto">
Créez un atelier SWOT pour analyser les forces et faiblesses, ou un Moving Motivators
pour découvrir les motivations de vos collaborateurs.
Créez un atelier SWOT pour analyser les forces et faiblesses, un Moving Motivators pour
découvrir les motivations, un Year Review pour faire le bilan de l&apos;année, ou un
Weekly Check-in pour le suivi hebdomadaire.
</p>
<div className="flex gap-3 justify-center">
<Link href="/sessions/new">
<Button variant="outline">
<span>📊</span>
Créer un SWOT
</Button>
</Link>
<Link href="/motivators/new">
<Button>
<span>🎯</span>
Créer un Moving Motivators
</Button>
</Link>
<div className="flex justify-center">
<NewWorkshopDropdown />
</div>
</Card>
) : (
<Suspense fallback={<WorkshopTabsSkeleton />}>
<WorkshopTabs swotSessions={allSwotSessions} motivatorSessions={allMotivatorSessions} />
<WorkshopTabs
swotSessions={allSwotSessions}
motivatorSessions={allMotivatorSessions}
yearReviewSessions={allYearReviewSessions}
weeklyCheckInSessions={allWeeklyCheckInSessions}
weatherSessions={allWeatherSessions}
teamCollabSessions={[
...teamSwotWithType,
...teamMotivatorWithType,
...teamYearReviewWithType,
...teamWeeklyCheckInWithType,
...teamWeatherWithType,
]}
/>
</Suspense>
)}
</main>

View 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&apos;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&apos;OKR
</Link>
</div>
<Card className="p-6">
<h1 className="text-2xl font-bold text-foreground mb-6">Modifier l&apos;OKR</h1>
<OKRForm
teamMembers={teamMembers}
onSubmit={handleSubmit}
onCancel={() => router.push(`/teams/${teamId}/okrs/${okrId}`)}
initialData={initialData}
/>
</Card>
</main>
);
}

View 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&apos;é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>
);
}

View 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&apos;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&apos;é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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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&apos;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>
);
}

View 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>
);
}

View 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&apos;est bien passé</strong> : Notez les réussites et points
positifs de la semaine
</li>
<li>
<strong>Ce qui s&apos;est mal passé</strong> : Identifiez les difficultés et
points d&apos;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>
);
}

View 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>
);
}

View 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&apos;année pour faire le point sur les réalisations, défis,
apprentissages et objectifs
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<Input
label="Titre du bilan"
name="title"
placeholder={`Ex: Bilan annuel ${currentYear}`}
required
/>
<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&apos;année prochaine
</li>
<li>
<strong>Moments</strong> : Partagez les moments forts et marquants
</li>
</ol>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={loading}
>
Annuler
</Button>
<Button type="submit" loading={loading} className="flex-1">
Créer le bilan
</Button>
</div>
</form>
</CardContent>
</Card>
</main>
);
}

View File

@@ -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}
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,25 +1,8 @@
'use client';
import { useState, useCallback } from 'react';
import { useSessionLive, type LiveEvent } from '@/hooks/useSessionLive';
import { LiveIndicator } from './LiveIndicator';
import { ShareModal } from './ShareModal';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
import { BaseSessionLiveWrapper } from './BaseSessionLiveWrapper';
import { shareSessionAction, removeShareAction } from '@/actions/share';
import type { TeamWithMembers, Share } from '@/lib/share-utils';
interface SessionLiveWrapperProps {
sessionId: string;
@@ -28,6 +11,7 @@ interface SessionLiveWrapperProps {
shares: Share[];
isOwner: boolean;
canEdit: boolean;
userTeams?: TeamWithMembers[];
children: React.ReactNode;
}
@@ -38,95 +22,36 @@ export function SessionLiveWrapper({
shares,
isOwner,
canEdit,
userTeams = [],
children,
}: SessionLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: LiveEvent) => {
// Show who made the last change
if (event.user?.name || event.user?.email) {
setLastEventUser(event.user.name || event.user.email);
// Clear after 3 seconds
setTimeout(() => setLastEventUser(null), 3000);
}
}, []);
const { isConnected, error } = useSessionLive({
sessionId,
currentUserId,
onEvent: handleEvent,
});
return (
<>
{/* Header toolbar */}
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-4">
<LiveIndicator isConnected={isConnected} error={error} />
{lastEventUser && (
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
<span></span>
<span>{lastEventUser} édite...</span>
</div>
)}
{!canEdit && (
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
<span>👁</span>
<span>Mode lecture</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Collaborators avatars */}
{shares.length > 0 && (
<div className="flex -space-x-2">
{shares.slice(0, 3).map((share) => (
<Avatar
key={share.id}
email={share.user.email}
name={share.user.name}
size={32}
className="border-2 border-card"
/>
))}
{shares.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
+{shares.length - 3}
</div>
)}
</div>
)}
<Button variant="outline" size="sm" onClick={() => setShareModalOpen(true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="mr-2 h-4 w-4"
>
<path d="M13 4.5a2.5 2.5 0 11.702 1.737L6.97 9.604a2.518 2.518 0 010 .792l6.733 3.367a2.5 2.5 0 11-.671 1.341l-6.733-3.367a2.5 2.5 0 110-3.475l6.733-3.366A2.52 2.52 0 0113 4.5z" />
</svg>
Partager
</Button>
</div>
</div>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<ShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
/>
</>
<BaseSessionLiveWrapper
sessionId={sessionId}
sessionTitle={sessionTitle}
currentUserId={currentUserId}
shares={shares}
isOwner={isOwner}
canEdit={canEdit}
userTeams={userTeams}
config={{
apiPath: 'sessions',
shareModal: {
title: 'Partager la session',
sessionSubtitle: 'Session',
helpText: (
<>
<strong>Éditeur</strong> : peut modifier les items et actions
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</>
),
},
onShareWithEmail: (email, role) => shareSessionAction(sessionId, email, role),
onRemoveShare: (userId) => removeShareAction(sessionId, userId),
}}
>
{children}
</BaseSessionLiveWrapper>
);
}

View 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>
);
}

View File

@@ -1,57 +1,88 @@
'use client';
import { useState, useTransition } from 'react';
import Link from 'next/link';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Avatar } from '@/components/ui/Avatar';
import { shareSessionAction, removeShareAction } from '@/actions/share';
import { Select } from '@/components/ui/Select';
import { getTeamMembersForShare, type TeamWithMembers, type Share } from '@/lib/share-utils';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
type ShareTab = 'teamMember' | 'team' | 'email';
interface ShareModalProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
title: string;
sessionSubtitle?: string;
sessionTitle: string;
shares: Share[];
isOwner: boolean;
userTeams?: TeamWithMembers[];
currentUserId?: string;
onShareWithEmail: (email: string, role: ShareRole) => Promise<{ success: boolean; error?: string }>;
onShareWithTeam?: (teamId: string, role: ShareRole) => Promise<{ success: boolean; error?: string }>;
onRemoveShare: (userId: string) => Promise<unknown>;
helpText?: React.ReactNode;
}
const ROLE_OPTIONS = [
{ value: 'EDITOR', label: 'Éditeur' },
{ value: 'VIEWER', label: 'Lecteur' },
] as const;
export function ShareModal({
isOpen,
onClose,
sessionId,
title,
sessionSubtitle,
sessionTitle,
shares,
isOwner,
userTeams = [],
currentUserId = '',
onShareWithEmail,
onShareWithTeam,
onRemoveShare,
helpText,
}: ShareModalProps) {
const teamMembers = getTeamMembersForShare(userTeams, currentUserId);
const hasTeamShare = !!onShareWithTeam;
const [shareType, setShareType] = useState<ShareTab>('teamMember');
const [email, setEmail] = useState('');
const [teamId, setTeamId] = useState('');
const [selectedMemberId, setSelectedMemberId] = useState('');
const [role, setRole] = useState<ShareRole>('EDITOR');
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const resetForm = () => {
setEmail('');
setTeamId('');
setSelectedMemberId('');
};
async function handleShare(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
const result = await shareSessionAction(sessionId, email, role);
let result: { success: boolean; error?: string };
if (shareType === 'team' && onShareWithTeam) {
result = await onShareWithTeam(teamId, role);
} else {
const targetEmail =
shareType === 'teamMember'
? teamMembers.find((m) => m.id === selectedMemberId)?.email ?? ''
: email;
result = await onShareWithEmail(targetEmail, role);
}
if (result.success) {
setEmail('');
resetForm();
} else {
setError(result.error || 'Erreur lors du partage');
}
@@ -60,53 +91,157 @@ export function ShareModal({
async function handleRemove(userId: string) {
startTransition(async () => {
await removeShareAction(sessionId, userId);
await onRemoveShare(userId);
});
}
const tabs: { value: ShareTab; label: string; icon: string }[] = [
{ value: 'teamMember', label: 'Membre', icon: '👥' },
...(hasTeamShare ? [{ value: 'team' as ShareTab, label: 'Équipe', icon: '🏢' }] : []),
{ value: 'email', label: 'Email', icon: '👤' },
];
return (
<Modal isOpen={isOpen} onClose={onClose} title="Partager la session">
<Modal isOpen={isOpen} onClose={onClose} title={title}>
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Session</p>
{sessionSubtitle && <p className="text-sm text-muted">{sessionSubtitle}</p>}
<p className="font-medium text-foreground">{sessionTitle}</p>
</div>
{/* Share form (only for owner) */}
{isOwner && (
<form onSubmit={handleShare} className="space-y-4">
<div className="flex gap-2">
<Input
type="email"
placeholder="Email de l'utilisateur"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1"
required
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground"
>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
<div className="flex gap-2 border-b border-border pb-3 flex-wrap">
{tabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => {
setShareType(tab.value);
resetForm();
}}
className={`flex-1 min-w-0 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
shareType === tab.value
? 'bg-primary text-primary-foreground'
: 'bg-card-hover text-muted hover:text-foreground'
}`}
>
{tab.icon} {tab.label}
</button>
))}
</div>
{shareType === 'email' && (
<div className="flex gap-2">
<Input
type="email"
placeholder="Email de l'utilisateur"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1"
required
/>
<Select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
options={[...ROLE_OPTIONS]}
wrapperClassName="w-auto shrink-0 min-w-[7rem]"
/>
</div>
)}
{shareType === 'teamMember' && (
<div className="space-y-2">
{teamMembers.length === 0 ? (
<p className="text-sm text-muted">
Vous n&apos;êtes membre d&apos;aucune équipe ou vos équipes n&apos;ont pas d&apos;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&apos;êtes membre d&apos;aucune équipe. Créez une équipe depuis la page{' '}
<Link href="/teams" className="text-primary hover:underline">
Équipes
</Link>
.
</p>
) : (
<div className="flex gap-2">
<Select
value={teamId}
onChange={(e) => setTeamId(e.target.value)}
options={[
{ value: '', label: 'Sélectionner une équipe', disabled: true },
...userTeams.map((team) => ({
value: team.id,
label: `${team.name}${team.userRole === 'ADMIN' ? ' (Admin)' : ''}`,
})),
]}
wrapperClassName="flex-1 min-w-0"
required
/>
<Select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
options={[...ROLE_OPTIONS]}
wrapperClassName="w-auto shrink-0 min-w-[7rem]"
/>
</div>
)}
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={isPending || !email} className="w-full">
{isPending ? 'Partage...' : 'Partager'}
<Button
type="submit"
disabled={
isPending ||
(shareType === 'email' && !email) ||
(shareType === 'teamMember' && !selectedMemberId) ||
(shareType === 'team' && !teamId)
}
className="w-full"
>
{isPending ? 'Partage...' : shareType === 'team' ? "Partager à l'équipe" : 'Partager'}
</Button>
</form>
)}
{/* Current shares */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
{shares.length === 0 ? (
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
) : (
@@ -125,7 +260,6 @@ export function ShareModal({
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
@@ -137,12 +271,7 @@ export function ShareModal({
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"
>
<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"
@@ -158,14 +287,7 @@ export function ShareModal({
)}
</div>
{/* Help text */}
<div className="rounded-lg bg-primary/5 p-3">
<p className="text-xs text-muted">
<strong>Éditeur</strong> : peut modifier les items et actions
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</p>
</div>
{helpText && <div className="rounded-lg bg-primary/5 p-3">{helpText}</div>}
</div>
</Modal>
);

View File

@@ -1,3 +1,8 @@
export { LiveIndicator } from './LiveIndicator';
export { ShareModal } from './ShareModal';
export { SessionLiveWrapper } from './SessionLiveWrapper';
export { BaseSessionLiveWrapper } from './BaseSessionLiveWrapper';
export { ShareButton } from './ShareButton';
export { CollaboratorAvatars } from './CollaboratorAvatars';
export { CollaborationToolbar } from './CollaborationToolbar';
export type { LiveApiPath } from './BaseSessionLiveWrapper';

View File

@@ -4,14 +4,20 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useSession, signOut } from 'next-auth/react';
import { useTheme } from '@/contexts/ThemeContext';
import { useState } from 'react';
import { Avatar } from '@/components/ui';
import { useState, useRef } from 'react';
import { Avatar, RocketIcon } from '@/components/ui';
import { WORKSHOPS } from '@/lib/workshops';
import { useClickOutside } from '@/hooks/useClickOutside';
export function Header() {
const { theme, toggleTheme } = useTheme();
const { data: session, status } = useSession();
const [menuOpen, setMenuOpen] = 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 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">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
<Link href="/" className="flex items-center gap-2">
<span className="text-2xl">🚀</span>
<RocketIcon className="h-7 w-7 shrink-0 text-primary" />
<span className="text-xl font-bold text-foreground">Workshop Manager</span>
</Link>
@@ -39,18 +45,37 @@ export function Header() {
Mes Ateliers
</Link>
{/* Workshops Dropdown */}
<div className="relative">
{/* Objectives Link */}
<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
onClick={() => setWorkshopsOpen(!workshopsOpen)}
onBlur={() => setTimeout(() => setWorkshopsOpen(false), 150)}
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-muted hover:text-foreground'
}`}
>
Ateliers
Nouvel atelier
<svg
className={`h-4 w-4 transition-transform ${workshopsOpen ? 'rotate-180' : ''}`}
fill="none"
@@ -68,28 +93,20 @@ export function Header() {
{workshopsOpen && (
<div className="absolute left-0 z-20 mt-2 w-56 rounded-lg border border-border bg-card py-1 shadow-lg">
<Link
href="/sessions/new"
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover"
onClick={() => setWorkshopsOpen(false)}
>
<span className="text-lg">📊</span>
<div>
<div className="font-medium">Analyse SWOT</div>
<div className="text-xs text-muted">Forces, faiblesses, opportunités</div>
</div>
</Link>
<Link
href="/motivators/new"
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover"
onClick={() => setWorkshopsOpen(false)}
>
<span className="text-lg">🎯</span>
<div>
<div className="font-medium">Moving Motivators</div>
<div className="text-xs text-muted">Motivations intrinsèques</div>
</div>
</Link>
{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={() => setWorkshopsOpen(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>
@@ -107,7 +124,7 @@ export function Header() {
{status === 'loading' ? (
<div className="h-9 w-20 animate-pulse rounded-lg bg-card-hover" />
) : status === 'authenticated' && session?.user ? (
<div className="relative">
<div ref={userMenuRef} className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="flex h-9 items-center gap-2 rounded-lg border border-border bg-card pl-1.5 pr-3 transition-colors hover:bg-card-hover"
@@ -132,9 +149,7 @@ export function Header() {
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 z-20 mt-2 w-48 rounded-lg border border-border bg-card py-1 shadow-lg">
<div className="absolute right-0 z-20 mt-2 w-48 rounded-lg border border-border bg-card py-1 shadow-lg">
<div className="border-b border-border px-4 py-2">
<p className="text-xs text-muted">Connecté en tant que</p>
<p className="truncate text-sm font-medium text-foreground">
@@ -162,7 +177,6 @@ export function Header() {
Se déconnecter
</button>
</div>
</>
)}
</div>
) : (

View File

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

View File

@@ -1,5 +1,6 @@
'use client';
import { memo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
@@ -12,7 +13,7 @@ interface MotivatorCardProps {
showInfluence?: boolean;
}
export function MotivatorCard({
export const MotivatorCard = memo(function MotivatorCard({
card,
disabled = false,
showInfluence = false,
@@ -87,10 +88,10 @@ export function MotivatorCard({
</div>
</div>
);
}
});
// Non-draggable version for summary
export function MotivatorCardStatic({
export const MotivatorCardStatic = memo(function MotivatorCardStatic({
card,
size = 'normal',
}: {
@@ -156,4 +157,4 @@ export function MotivatorCardStatic({
</div>
</div>
);
}
});

View File

@@ -1,25 +1,8 @@
'use client';
import { useState, useCallback } from 'react';
import { useMotivatorLive, type MotivatorLiveEvent } from '@/hooks/useMotivatorLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { MotivatorShareModal } from './MotivatorShareModal';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
import { BaseSessionLiveWrapper } from '@/components/collaboration/BaseSessionLiveWrapper';
import { shareMotivatorSession, removeMotivatorShare } from '@/actions/moving-motivators';
import type { TeamWithMembers, Share } from '@/lib/share-utils';
interface MotivatorLiveWrapperProps {
sessionId: string;
@@ -28,6 +11,7 @@ interface MotivatorLiveWrapperProps {
shares: Share[];
isOwner: boolean;
canEdit: boolean;
userTeams?: TeamWithMembers[];
children: React.ReactNode;
}
@@ -38,95 +22,36 @@ export function MotivatorLiveWrapper({
shares,
isOwner,
canEdit,
userTeams = [],
children,
}: MotivatorLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: MotivatorLiveEvent) => {
// Show who made the last change
if (event.user?.name || event.user?.email) {
setLastEventUser(event.user.name || event.user.email);
// Clear after 3 seconds
setTimeout(() => setLastEventUser(null), 3000);
}
}, []);
const { isConnected, error } = useMotivatorLive({
sessionId,
currentUserId,
onEvent: handleEvent,
});
return (
<>
{/* Header toolbar */}
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-4">
<LiveIndicator isConnected={isConnected} error={error} />
{lastEventUser && (
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
<span></span>
<span>{lastEventUser} édite...</span>
</div>
)}
{!canEdit && (
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
<span>👁</span>
<span>Mode lecture</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Collaborators avatars */}
{shares.length > 0 && (
<div className="flex -space-x-2">
{shares.slice(0, 3).map((share) => (
<Avatar
key={share.id}
email={share.user.email}
name={share.user.name}
size={32}
className="border-2 border-card"
/>
))}
{shares.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
+{shares.length - 3}
</div>
)}
</div>
)}
<Button variant="outline" size="sm" onClick={() => setShareModalOpen(true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="mr-2 h-4 w-4"
>
<path d="M13 4.5a2.5 2.5 0 11.702 1.737L6.97 9.604a2.518 2.518 0 010 .792l6.733 3.367a2.5 2.5 0 11-.671 1.341l-6.733-3.367a2.5 2.5 0 110-3.475l6.733-3.366A2.52 2.52 0 0113 4.5z" />
</svg>
Partager
</Button>
</div>
</div>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<MotivatorShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
/>
</>
<BaseSessionLiveWrapper
sessionId={sessionId}
sessionTitle={sessionTitle}
currentUserId={currentUserId}
shares={shares}
isOwner={isOwner}
canEdit={canEdit}
userTeams={userTeams}
config={{
apiPath: 'motivators',
shareModal: {
title: 'Partager la session',
sessionSubtitle: 'Session Moving Motivators',
helpText: (
<>
<strong>Éditeur</strong> : peut modifier les cartes et leurs positions
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</>
),
},
onShareWithEmail: (email, role) => shareMotivatorSession(sessionId, email, role),
onRemoveShare: (userId) => removeMotivatorShare(sessionId, userId),
}}
>
{children}
</BaseSessionLiveWrapper>
);
}

View File

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

View File

@@ -3,4 +3,3 @@ export { MotivatorCard, MotivatorCardStatic } from './MotivatorCard';
export { MotivatorSummary } from './MotivatorSummary';
export { InfluenceZone } from './InfluenceZone';
export { MotivatorLiveWrapper } from './MotivatorLiveWrapper';
export { MotivatorShareModal } from './MotivatorShareModal';

View File

@@ -0,0 +1,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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

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

View File

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

View File

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

View File

@@ -62,9 +62,9 @@ interface QuadrantHelpProps {
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 content = HELP_CONTENT[category];
return (
<button

View File

@@ -1,6 +1,6 @@
'use client';
import { forwardRef, useState, useTransition } from 'react';
import { forwardRef, memo, useState, useTransition } from 'react';
import type { SwotItem, SwotCategory } from '@prisma/client';
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' },
};
export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
export const SwotCard = memo(forwardRef<HTMLDivElement, SwotCardProps>(
(
{ item, sessionId, isSelected, isHighlighted, isDragging, linkMode, onSelect, ...props },
ref
@@ -196,6 +196,5 @@ export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
</div>
);
}
);
));
SwotCard.displayName = 'SwotCard';

View File

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

View File

@@ -2,4 +2,4 @@ export { SwotBoard } from './SwotBoard';
export { SwotQuadrant } from './SwotQuadrant';
export { SwotCard } from './SwotCard';
export { ActionPanel } from './ActionPanel';
export { QuadrantHelp } from './QuadrantHelp';
export { QuadrantHelp, QuadrantHelpPanel } from './QuadrantHelp';

View 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>
);
}

View 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&apos;é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&apos;équipe{' '}
<strong className="text-foreground">&quot;{teamName}&quot;</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>
</>
);
}

View 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>
);
}

Some files were not shown because too many files have changed in this diff Show More