Compare commits

...

77 Commits

Author SHA1 Message Date
313ad53e2e refactor(weather): move top disclosures below board
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m3s
2026-03-04 08:39:19 +01:00
8bff21bede feat(weather): show trend indicators on team averages 2026-03-04 08:34:23 +01:00
4aea17124e feat(ui): allow per-disclosure emoji icons 2026-03-04 08:32:19 +01:00
db7a0cef96 refactor(ui): unify low-level controls and expand design system
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m57s
2026-03-03 15:50:15 +01:00
9a43980412 refactor: extract Icons and InlineFormActions UI components
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m28s
- Add Icons.tsx: IconEdit, IconTrash, IconDuplicate, IconPlus, IconCheck, IconClose
- Add InlineFormActions.tsx: unified Annuler/Ajouter-Enregistrer button pair
- Replace inline SVGs in SwotCard, YearReviewCard, WeeklyCheckInCard, SwotQuadrant,
  YearReviewSection, WeeklyCheckInSection, EditableTitle, Modal, GifMoodCard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 14:25:35 +01:00
09a849279b refactor: add SessionPageHeader and apply to all 6 session detail pages
- Create SessionPageHeader component (breadcrumb + editable title + collaborator + badges + date)
- Embed UPDATE_FN map internally, keyed by workshopType — no prop drilling
- Replace duplicated header blocks in sessions, motivators, year-review, weather, weekly-checkin, gif-mood

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 14:15:43 +01:00
b1ba43fd30 refactor: merge 6 EditableTitle wrappers into one file
Replace EditableSessionTitle, EditableMotivatorTitle, EditableYearReviewTitle,
EditableWeatherTitle, EditableWeeklyCheckInTitle, EditableGifMoodTitle individual
files with a single EditableTitles.tsx using spread props. Same public API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 14:06:45 +01:00
2e00522bfc feat: add PageHeader component and centralize page spacing
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m1s
- Create reusable PageHeader component (emoji + title + subtitle + actions)
- Use PageHeader in sessions, teams, users, objectives pages
- Centralize vertical padding in layout (py-6) and remove per-page py-* values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 14:01:07 +01:00
66ac190c15 feat: redesign sessions dashboard with multi-view layout and sortable table
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m17s
- Redesign session cards with colored left border (Figma-style), improved
  visual hierarchy, hover states, and stats in footer
- Add 4 switchable view modes: grid, list, sortable table, and timeline
- Table view: unified flat table with clickable column headers for sorting
  (Type, Titre, Créateur, Participant, Stats, Date)
- Add Créateur column showing the workshop owner with Gravatar avatar
- Widen Type column to 160px for better readability
- Improve tabs navigation with pill-shaped active state and shadow
- Fix TypeFilterDropdown to exclude 'Équipe' from type list
- Make filter tabs visually distinct with bg-card + border + shadow-sm
- Split WorkshopTabs.tsx into 4 focused modules:
  workshop-session-types.ts, workshop-session-helpers.ts,
  SessionCard.tsx, WorkshopTabs.tsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 13:54:23 +01:00
7be296231c feat: add weather trend chart showing indicator averages over time
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m6s
Adds a collapsible SVG line graph on weather session pages displaying
the evolution of all 4 indicators (Performance, Moral, Flux, Création
de valeur) across sessions, with per-session average scores, hover
tooltips, and a marker on the current session.

Also fixes pre-existing lint errors: non-null assertion on optional
chain in Header and eslint-disable for intentional hydration pattern
in ThemeToggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 11:45:19 +01:00
c3b653601c fix: notes patching in weather
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m58s
2026-03-03 11:18:11 +01:00
8de4c1985f feat: update gif mood board column options to 4/5/6
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m58s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 10:14:36 +01:00
766f3d5a59 feat: add GIF Mood Board workshop
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m5s
- New workshop where each team member shares up to 5 GIFs with notes to express their weekly mood
- Per-user week rating (1-5 stars) visible next to each member's section
- Masonry-style grid with adjustable column count (3/4/5) toggle
- Handwriting font (Caveat) for GIF notes
- Full real-time collaboration via SSE
- Clean migration (add_gif_mood_workshop) safe for production deploy
- DB backup via cp before each migration in docker-entrypoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 10:04:56 +01:00
7c68fb81e3 fix: prevent ThemeToggle hydration mismatch by deferring icon render
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m32s
Server doesn't know localStorage theme, so defer emoji rendering until
after mount to avoid server/client text mismatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:16:31 +01:00
9298eef0cb refactor: make Header a server component to avoid auth flash on load
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
Move session check from client-side useSession() to server-side auth(),
so the authenticated state is known at initial render. Extract interactive
parts (ThemeToggle, UserMenu, WorkshopsDropdown, NavLinks) into small
client components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:14:27 +01:00
a10205994c refactor: improve team management, OKRs, and session components 2026-02-25 17:29:40 +01:00
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
190 changed files with 20391 additions and 3548 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

View File

@@ -6,4 +6,3 @@
"printWidth": 100,
"plugins": []
}

96
CLAUDE.md Normal file
View File

@@ -0,0 +1,96 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Development
pnpm dev # Start dev server (http://localhost:3000)
pnpm build # Production build (standalone output)
pnpm lint # Run ESLint
# Database (use pnpm instead of npx)
pnpm prisma migrate dev --name <name> # Create and apply migration
pnpm prisma generate # Regenerate Prisma client
pnpm prisma studio # Open DB GUI
```
## Stack
- **Next.js 16** (App Router, standalone output)
- **SQLite + Prisma 7** via `@prisma/adapter-better-sqlite3` (not the default SQLite adapter)
- **NextAuth.js v5** (beta.30) — JWT sessions, Credentials provider only
- **Tailwind CSS v4** — CSS Variables theming in `src/app/globals.css`
- **Drag & Drop** — `@dnd-kit` and `@hello-pangea/dnd` (both present)
- **pnpm** — package manager (use pnpm, not npm/yarn)
## Architecture Overview
### Workshop Types
There are 5 workshop types: `swot` (sessions), `motivators`, `year-review`, `weekly-checkin`, `weather`.
**Single source of truth**: `src/lib/workshops.ts` exports `WORKSHOPS`, `WORKSHOP_BY_ID`, and helpers. Every place that lists or routes workshop types must use this file.
**All types and UI config constants** are in `src/lib/types.ts` (e.g. `MOTIVATORS_CONFIG`, `YEAR_REVIEW_SECTIONS`, `SWOT_QUADRANTS`, `EMOTIONS_CONFIG`).
### Layer Structure
```
src/
├── actions/ # Next.js Server Actions ('use server') — call services, revalidate paths
├── services/ # Prisma queries + business logic (server-only)
│ ├── database.ts # Prisma singleton (global for dev HMR)
│ ├── session-permissions.ts # Shared permission factories
│ └── session-share-events.ts # Shared share + SSE event handlers
├── app/
│ ├── api/ # Route handlers (SSE subscribe + auth)
│ ├── (auth)/ # Login/register pages
│ └── [workshop]/ # One folder per workshop type
├── components/
│ └── collaboration/ # BaseSessionLiveWrapper + share/live UI
├── hooks/
│ └── useLive.ts # SSE client hook (EventSource + reconnect)
└── lib/
├── workshops.ts # Workshop metadata registry
├── types.ts # All TypeScript types + UI config
└── share-utils.ts # Shared share types
```
### Real-Time Collaboration (SSE)
Each workshop has `/api/[path]/[id]/subscribe` — a GET route that opens a `ReadableStream` (SSE). The server polls the DB every 1 second for new events and pushes them to connected clients. Server Actions write events to the DB after mutations.
Client side: `useLive` hook (`src/hooks/useLive.ts`) connects to the subscribe endpoint with `EventSource`, filters out events from the current user (to avoid duplicates), and calls `router.refresh()` on incoming events.
`BaseSessionLiveWrapper` (`src/components/collaboration/`) is the shared wrapper component that wires `useLive`, `CollaborationToolbar`, and `ShareModal` for all workshop session pages.
### Shared Permission System
`createSessionPermissionChecks(model)` in `src/services/session-permissions.ts` returns `canAccess`, `canEdit`, `canDelete` for any Prisma model that follows the session shape (has `userId` + `shares` relation). Team admins have implicit access to their members' sessions.
`createShareAndEventHandlers(...)` in `src/services/session-share-events.ts` returns `share`, `removeShare`, `getShares`, `createEvent`, `getEvents` — used by all workshop services.
### Auth
- `src/lib/auth.ts` — NextAuth config (signIn, signOut, auth exports)
- `src/lib/auth.config.ts` — config object (used separately for Edge middleware)
- `src/middleware.ts` — protects all routes except `/api/auth`, `_next/static`, `_next/image`, `favicon.ico`
- Session user ID is available via `auth()` call server-side; token includes `id` field
### Database
Prisma client is a singleton in `src/services/database.ts`. `DATABASE_URL` env var controls the SQLite file path (default: `file:./prisma/dev.db`). Schema is at `prisma/schema.prisma`.
### Adding a New Workshop
Pattern followed by all existing workshops:
1. Add entry to `WORKSHOPS` in `src/lib/workshops.ts`
2. Add Prisma models (Session, Item, Share, Event) following the existing pattern
3. Create service in `src/services/` using `createSessionPermissionChecks` and `createShareAndEventHandlers`
4. Create server actions in `src/actions/`
5. Create API route `src/app/api/[path]/[id]/subscribe/route.ts` (copy from existing)
6. Create pages under `src/app/[path]/`
7. Use `BaseSessionLiveWrapper` for the session live page

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

84
PERF_OPTIMIZATIONS.md Normal file
View File

@@ -0,0 +1,84 @@
# Optimisations de performance
## Requêtes DB (impact critique)
### resolveCollaborator — suppression du scan complet de la table User
**Fichier:** `src/services/auth.ts`
Avant : `findMany` sur tous les users puis `find()` en JS pour un match case-insensitive par nom.
Après : `findFirst` avec `contains` + vérification exacte. O(1) au lieu de O(N users).
### getAllUsersWithStats — suppression du N+1
**Fichier:** `src/services/auth.ts`
Avant : 2 queries `count` par utilisateur (`Promise.all` avec map).
Après : 2 `groupBy` en bulk + construction d'une Map. 3 queries au lieu de 2N+1.
### React.cache sur les fonctions teams
**Fichier:** `src/services/teams.ts`
`getTeamMemberIdsForAdminTeams` et `isAdminOfUser` wrappées avec `React.cache()`.
Sur la page `/sessions`, ces fonctions étaient appelées ~10 fois par requête (5 workshop types × 2). Maintenant dédupliquées en 1 appel.
## SSE / Temps réel (impact haut)
### Polling interval 1s → 2s
**Fichiers:** 5 routes `src/app/api/*/[id]/subscribe/route.ts`
Réduit de 50% le nombre de queries DB en temps réel. Imperceptible côté UX (la plupart des outils collab utilisent 2-5s).
### Nettoyage des events
**Fichier:** `src/services/session-share-events.ts`
Ajout de `cleanupOldEvents(maxAgeHours)` pour purger les events périmés. Les tables d'events n'ont pas de mécanisme de TTL — cette méthode peut être appelée périodiquement ou à la connexion SSE.
## Rendu client (impact haut)
### React.memo sur les composants de cartes
**Fichiers:**
- `src/components/swot/SwotCard.tsx`
- `src/components/moving-motivators/MotivatorCard.tsx` (+ `MotivatorCardStatic`)
- `src/components/weather/WeatherCard.tsx`
- `src/components/weekly-checkin/WeeklyCheckInCard.tsx`
- `src/components/year-review/YearReviewCard.tsx`
Ces composants sont rendus en liste et re-rendaient tous à chaque drag, changement d'état, ou `router.refresh()` SSE.
### WeatherCard — fix du pattern useEffect + setState
**Fichier:** `src/components/weather/WeatherCard.tsx`
Remplacé le `useEffect` qui appelait 5 `setState` (cascading renders, erreur lint React 19) par le pattern idiomatique de state-driven prop sync (comparaison directe dans le render body).
## Configuration Next.js (impact moyen)
### next.config.ts
**Fichier:** `next.config.ts`
- `poweredByHeader: false` — supprime le header `X-Powered-By` (sécurité)
- `optimizePackageImports` — tree-shaking amélioré pour `@dnd-kit/*` et `@hello-pangea/dnd`
### Fix FOUC dark mode
**Fichier:** `src/app/layout.tsx`
Script inline dans `<head>` qui lit `localStorage` et applique la classe `dark`/`light` sur `<html>` avant l'hydratation React. Élimine le flash blanc pour les utilisateurs en dark mode.
## Nettoyage
- Suppression de 5 SVGs inutilisés du template Next.js (`file.svg`, `globe.svg`, `next.svg`, `vercel.svg`, `window.svg`)
## Non traité (pour plus tard)
- **Migration DnD** : consolider `@hello-pangea/dnd` et `@dnd-kit` en une seule lib (~45KB économisés) — 3 boards à réécrire
- **Split WorkshopTabs** (879 lignes) — découper en sous-composants par type
- **Suspense boundaries** sur les pages de détail de session
- **Appel périodique de `cleanupOldEvents`** — à brancher via cron ou à la connexion SSE

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
@@ -47,6 +47,7 @@ Application de gestion d'ateliers SWOT pour entretiens managériaux.
- [x] Installer et configurer Prisma
- [x] Créer le schéma de base de données :
```prisma
model User {
id String @id @default(cuid())
@@ -110,10 +111,11 @@ Application de gestion d'ateliers SWOT pour entretiens managériaux.
action Action @relation(fields: [actionId], references: [id], onDelete: Cascade)
swotItemId String
swotItem SwotItem @relation(fields: [swotItemId], references: [id], onDelete: Cascade)
@@unique([actionId, swotItemId])
}
```
- [x] Générer le client Prisma
- [x] Créer les migrations initiales
- [x] Créer le service database.ts (pool de connexion)
@@ -260,7 +262,7 @@ Application de gestion d'ateliers SWOT pour entretiens managériaux.
```typescript
// actions/swot-items.ts
'use server'
'use server';
import { swotService } from '@/services/swot';
import { revalidatePath } from 'next/cache';
@@ -310,4 +312,3 @@ npm run build
# Lint
npm run lint
```

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

@@ -1,6 +1,19 @@
#!/bin/sh
set -e
DB_PATH="/app/data/dev.db"
BACKUP_DIR="/app/data/backups"
if [ -f "$DB_PATH" ]; then
mkdir -p "$BACKUP_DIR"
BACKUP_FILE="$BACKUP_DIR/dev-$(date +%Y%m%d-%H%M%S).db"
cp "$DB_PATH" "$BACKUP_FILE"
echo "💾 Database backed up to $BACKUP_FILE"
# Keep only the 10 most recent backups
ls -t "$BACKUP_DIR"/*.db 2>/dev/null | tail -n +11 | xargs rm -f
fi
echo "🔄 Running database migrations..."
pnpm prisma migrate deploy

View File

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

View File

@@ -13,7 +13,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"prettier": "prettier --write ."
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -24,7 +25,7 @@
"@prisma/client": "^7.1.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.4.6",
"next": "16.0.7",
"next": "16.0.10",
"next-auth": "5.0.0-beta.30",
"prisma": "^7.1.0",
"react": "19.2.0",

3580
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
'@tailwindcss/postcss': {},
},
};

View File

@@ -1,14 +1,14 @@
// This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';
export default defineConfig({
schema: "prisma/schema.prisma",
schema: 'prisma/schema.prisma',
migrations: {
path: "prisma/migrations",
path: 'prisma/migrations',
},
datasource: {
url: env("DATABASE_URL"),
url: env('DATABASE_URL'),
},
});

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

@@ -0,0 +1,137 @@
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "Emotion";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "KeyResultStatus";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "OKRStatus";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "TeamRole";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "WeeklyCheckInCategory";
PRAGMA foreign_keys=on;
-- CreateTable
CREATE TABLE "GifMoodSession" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"date" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "GifMoodSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "GifMoodUserRating" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"rating" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "GifMoodUserRating_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "GifMoodSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "GifMoodUserRating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "GifMoodItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"gifUrl" TEXT NOT NULL,
"note" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "GifMoodItem_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "GifMoodSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "GifMoodItem_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "GMSessionShare" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'EDITOR',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GMSessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "GifMoodSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "GMSessionShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "GMSessionEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payload" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GMSessionEvent_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "GifMoodSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "GMSessionEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_WeeklyCheckInItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"content" TEXT NOT NULL,
"category" TEXT NOT NULL,
"emotion" TEXT NOT NULL DEFAULT 'NONE',
"order" INTEGER NOT NULL DEFAULT 0,
"sessionId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WeeklyCheckInItem_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeeklyCheckInSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_WeeklyCheckInItem" ("category", "content", "createdAt", "emotion", "id", "order", "sessionId", "updatedAt") SELECT "category", "content", "createdAt", "emotion", "id", "order", "sessionId", "updatedAt" FROM "WeeklyCheckInItem";
DROP TABLE "WeeklyCheckInItem";
ALTER TABLE "new_WeeklyCheckInItem" RENAME TO "WeeklyCheckInItem";
CREATE INDEX "WeeklyCheckInItem_sessionId_idx" ON "WeeklyCheckInItem"("sessionId");
CREATE INDEX "WeeklyCheckInItem_sessionId_category_idx" ON "WeeklyCheckInItem"("sessionId", "category");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE INDEX "GifMoodSession_userId_idx" ON "GifMoodSession"("userId");
-- CreateIndex
CREATE INDEX "GifMoodSession_date_idx" ON "GifMoodSession"("date");
-- CreateIndex
CREATE INDEX "GifMoodUserRating_sessionId_idx" ON "GifMoodUserRating"("sessionId");
-- CreateIndex
CREATE UNIQUE INDEX "GifMoodUserRating_sessionId_userId_key" ON "GifMoodUserRating"("sessionId", "userId");
-- CreateIndex
CREATE INDEX "GifMoodItem_sessionId_userId_idx" ON "GifMoodItem"("sessionId", "userId");
-- CreateIndex
CREATE INDEX "GifMoodItem_sessionId_idx" ON "GifMoodItem"("sessionId");
-- CreateIndex
CREATE INDEX "GMSessionShare_sessionId_idx" ON "GMSessionShare"("sessionId");
-- CreateIndex
CREATE INDEX "GMSessionShare_userId_idx" ON "GMSessionShare"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "GMSessionShare_sessionId_userId_key" ON "GMSessionShare"("sessionId", "userId");
-- CreateIndex
CREATE INDEX "GMSessionEvent_sessionId_createdAt_idx" ON "GMSessionEvent"("sessionId", "createdAt");

View File

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

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

340
src/actions/gif-mood.ts Normal file
View File

@@ -0,0 +1,340 @@
'use server';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import * as gifMoodService from '@/services/gif-mood';
import { getUserById } from '@/services/auth';
import { broadcastToGifMoodSession } from '@/app/api/gif-mood/[id]/subscribe/route';
// ============================================
// Session Actions
// ============================================
export async function createGifMoodSession(data: { title: string; date?: Date }) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const gifMoodSession = await gifMoodService.createGifMoodSession(session.user.id, data);
revalidatePath('/gif-mood');
revalidatePath('/sessions');
return { success: true, data: gifMoodSession };
} catch (error) {
console.error('Error creating gif mood session:', error);
return { success: false, error: 'Erreur lors de la création' };
}
}
export async function updateGifMoodSession(
sessionId: string,
data: { title?: string; date?: Date }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await gifMoodService.updateGifMoodSession(sessionId, authSession.user.id, data);
const user = await getUserById(authSession.user.id);
if (!user) {
return { success: false, error: 'Utilisateur non trouvé' };
}
const event = await gifMoodService.createGifMoodSessionEvent(
sessionId,
authSession.user.id,
'SESSION_UPDATED',
data
);
broadcastToGifMoodSession(sessionId, {
type: 'SESSION_UPDATED',
payload: data,
userId: authSession.user.id,
user: { id: user.id, name: user.name, email: user.email },
timestamp: event.createdAt,
});
revalidatePath(`/gif-mood/${sessionId}`);
revalidatePath('/gif-mood');
revalidatePath('/sessions');
return { success: true };
} catch (error) {
console.error('Error updating gif mood session:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function deleteGifMoodSession(sessionId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await gifMoodService.deleteGifMoodSession(sessionId, authSession.user.id);
revalidatePath('/gif-mood');
revalidatePath('/sessions');
return { success: true };
} catch (error) {
console.error('Error deleting gif mood session:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}
// ============================================
// Item Actions
// ============================================
export async function addGifMoodItem(
sessionId: string,
data: { gifUrl: string; note?: string }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
const canEdit = await gifMoodService.canEditGifMoodSession(sessionId, authSession.user.id);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
const item = await gifMoodService.addGifMoodItem(sessionId, authSession.user.id, data);
const user = await getUserById(authSession.user.id);
if (!user) {
return { success: false, error: 'Utilisateur non trouvé' };
}
const event = await gifMoodService.createGifMoodSessionEvent(
sessionId,
authSession.user.id,
'GIF_ADDED',
{ itemId: item.id, userId: item.userId, gifUrl: item.gifUrl, note: item.note }
);
broadcastToGifMoodSession(sessionId, {
type: 'GIF_ADDED',
payload: { itemId: item.id, userId: item.userId, gifUrl: item.gifUrl, note: item.note },
userId: authSession.user.id,
user: { id: user.id, name: user.name, email: user.email },
timestamp: event.createdAt,
});
revalidatePath(`/gif-mood/${sessionId}`);
return { success: true, data: item };
} catch (error) {
console.error('Error adding gif mood item:', error);
const message = error instanceof Error ? error.message : "Erreur lors de l'ajout";
return { success: false, error: message };
}
}
export async function updateGifMoodItem(
sessionId: string,
itemId: string,
data: { note?: string; order?: number }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
const canEdit = await gifMoodService.canEditGifMoodSession(sessionId, authSession.user.id);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await gifMoodService.updateGifMoodItem(itemId, authSession.user.id, data);
const user = await getUserById(authSession.user.id);
if (!user) {
return { success: false, error: 'Utilisateur non trouvé' };
}
const event = await gifMoodService.createGifMoodSessionEvent(
sessionId,
authSession.user.id,
'GIF_UPDATED',
{ itemId, ...data }
);
broadcastToGifMoodSession(sessionId, {
type: 'GIF_UPDATED',
payload: { itemId, ...data },
userId: authSession.user.id,
user: { id: user.id, name: user.name, email: user.email },
timestamp: event.createdAt,
});
revalidatePath(`/gif-mood/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error updating gif mood item:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function deleteGifMoodItem(sessionId: string, itemId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
const canEdit = await gifMoodService.canEditGifMoodSession(sessionId, authSession.user.id);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await gifMoodService.deleteGifMoodItem(itemId, authSession.user.id);
const user = await getUserById(authSession.user.id);
if (!user) {
return { success: false, error: 'Utilisateur non trouvé' };
}
const event = await gifMoodService.createGifMoodSessionEvent(
sessionId,
authSession.user.id,
'GIF_DELETED',
{ itemId, userId: authSession.user.id }
);
broadcastToGifMoodSession(sessionId, {
type: 'GIF_DELETED',
payload: { itemId, userId: authSession.user.id },
userId: authSession.user.id,
user: { id: user.id, name: user.name, email: user.email },
timestamp: event.createdAt,
});
revalidatePath(`/gif-mood/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error deleting gif mood item:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}
// ============================================
// Week Rating Actions
// ============================================
export async function setGifMoodUserRating(sessionId: string, rating: number) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
const canEdit = await gifMoodService.canEditGifMoodSession(sessionId, authSession.user.id);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await gifMoodService.upsertGifMoodUserRating(sessionId, authSession.user.id, rating);
const user = await getUserById(authSession.user.id);
if (user) {
const event = await gifMoodService.createGifMoodSessionEvent(
sessionId,
authSession.user.id,
'SESSION_UPDATED',
{ rating, userId: authSession.user.id }
);
broadcastToGifMoodSession(sessionId, {
type: 'SESSION_UPDATED',
payload: { rating, userId: authSession.user.id },
userId: authSession.user.id,
user: { id: user.id, name: user.name, email: user.email },
timestamp: event.createdAt,
});
}
revalidatePath(`/gif-mood/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error setting gif mood user rating:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
// ============================================
// Sharing Actions
// ============================================
export async function shareGifMoodSession(
sessionId: string,
targetEmail: string,
role: 'VIEWER' | 'EDITOR' = 'EDITOR'
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const share = await gifMoodService.shareGifMoodSession(
sessionId,
authSession.user.id,
targetEmail,
role
);
revalidatePath(`/gif-mood/${sessionId}`);
return { success: true, data: share };
} catch (error) {
console.error('Error sharing gif mood session:', error);
const message = error instanceof Error ? error.message : 'Erreur lors du partage';
return { success: false, error: message };
}
}
export async function shareGifMoodSessionToTeam(
sessionId: string,
teamId: string,
role: 'VIEWER' | 'EDITOR' = 'EDITOR'
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const shares = await gifMoodService.shareGifMoodSessionToTeam(
sessionId,
authSession.user.id,
teamId,
role
);
revalidatePath(`/gif-mood/${sessionId}`);
return { success: true, data: shares };
} catch (error) {
console.error('Error sharing gif mood session to team:', error);
const message = error instanceof Error ? error.message : "Erreur lors du partage à l'équipe";
return { success: false, error: message };
}
}
export async function removeGifMoodShare(sessionId: string, shareUserId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await gifMoodService.removeGifMoodShare(sessionId, authSession.user.id, shareUserId);
revalidatePath(`/gif-mood/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error removing gif mood share:', error);
return { success: false, error: 'Erreur lors de la suppression du partage' };
}
}

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

281
src/actions/weather.ts Normal file
View File

@@ -0,0 +1,281 @@
'use server';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import * as weatherService from '@/services/weather';
import { getUserById } from '@/services/auth';
import { broadcastToWeatherSession } from '@/app/api/weather/[id]/subscribe/route';
// ============================================
// Session Actions
// ============================================
export async function createWeatherSession(data: { title: string; date?: Date }) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const weatherSession = await weatherService.createWeatherSession(session.user.id, data);
revalidatePath('/weather');
revalidatePath('/sessions');
return { success: true, data: weatherSession };
} catch (error) {
console.error('Error creating weather session:', error);
return { success: false, error: 'Erreur lors de la création' };
}
}
export async function updateWeatherSession(
sessionId: string,
data: { title?: string; date?: Date }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await weatherService.updateWeatherSession(sessionId, authSession.user.id, data);
// Get user info for broadcast
const user = await getUserById(authSession.user.id);
if (!user) {
return { success: false, error: 'Utilisateur non trouvé' };
}
// Emit event for real-time sync
const event = await weatherService.createWeatherSessionEvent(
sessionId,
authSession.user.id,
'SESSION_UPDATED',
data
);
// Broadcast immediately via SSE
broadcastToWeatherSession(sessionId, {
type: 'SESSION_UPDATED',
payload: data,
userId: authSession.user.id,
user: { id: user.id, name: user.name, email: user.email },
timestamp: event.createdAt,
});
revalidatePath(`/weather/${sessionId}`);
revalidatePath('/weather');
revalidatePath('/sessions');
return { success: true };
} catch (error) {
console.error('Error updating weather session:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function deleteWeatherSession(sessionId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await weatherService.deleteWeatherSession(sessionId, authSession.user.id);
revalidatePath('/weather');
revalidatePath('/sessions');
return { success: true };
} catch (error) {
console.error('Error deleting weather session:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}
// ============================================
// Entry Actions
// ============================================
export async function createOrUpdateWeatherEntry(
sessionId: string,
data: {
performanceEmoji?: string | null;
moralEmoji?: string | null;
fluxEmoji?: string | null;
valueCreationEmoji?: string | null;
notes?: string | null;
}
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await weatherService.canEditWeatherSession(sessionId, authSession.user.id);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
const entry = await weatherService.createOrUpdateWeatherEntry(
sessionId,
authSession.user.id,
data
);
// Get user info for broadcast
const user = await getUserById(authSession.user.id);
if (!user) {
return { success: false, error: 'Utilisateur non trouvé' };
}
// Emit event for real-time sync
const eventType =
entry.createdAt.getTime() === entry.updatedAt.getTime() ? 'ENTRY_CREATED' : 'ENTRY_UPDATED';
const event = await weatherService.createWeatherSessionEvent(
sessionId,
authSession.user.id,
eventType,
{
entryId: entry.id,
userId: entry.userId,
...data,
}
);
// Broadcast immediately via SSE
broadcastToWeatherSession(sessionId, {
type: eventType,
payload: {
entryId: entry.id,
userId: entry.userId,
...data,
},
userId: authSession.user.id,
user: { id: user.id, name: user.name, email: user.email },
timestamp: event.createdAt,
});
revalidatePath(`/weather/${sessionId}`);
return { success: true, data: entry };
} catch (error) {
console.error('Error creating/updating weather entry:', error);
return { success: false, error: 'Erreur lors de la sauvegarde' };
}
}
export async function deleteWeatherEntry(sessionId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await weatherService.canEditWeatherSession(sessionId, authSession.user.id);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await weatherService.deleteWeatherEntry(sessionId, authSession.user.id);
// Get user info for broadcast
const user = await getUserById(authSession.user.id);
if (!user) {
return { success: false, error: 'Utilisateur non trouvé' };
}
// Emit event for real-time sync
const event = await weatherService.createWeatherSessionEvent(
sessionId,
authSession.user.id,
'ENTRY_DELETED',
{ userId: authSession.user.id }
);
// Broadcast immediately via SSE
broadcastToWeatherSession(sessionId, {
type: 'ENTRY_DELETED',
payload: { userId: authSession.user.id },
userId: authSession.user.id,
user: { id: user.id, name: user.name, email: user.email },
timestamp: event.createdAt,
});
revalidatePath(`/weather/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error deleting weather entry:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}
// ============================================
// Sharing Actions
// ============================================
export async function shareWeatherSession(
sessionId: string,
targetEmail: string,
role: 'VIEWER' | 'EDITOR' = 'EDITOR'
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const share = await weatherService.shareWeatherSession(
sessionId,
authSession.user.id,
targetEmail,
role
);
revalidatePath(`/weather/${sessionId}`);
return { success: true, data: share };
} catch (error) {
console.error('Error sharing weather session:', error);
const message = error instanceof Error ? error.message : 'Erreur lors du partage';
return { success: false, error: message };
}
}
export async function shareWeatherSessionToTeam(
sessionId: string,
teamId: string,
role: 'VIEWER' | 'EDITOR' = 'EDITOR'
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const shares = await weatherService.shareWeatherSessionToTeam(
sessionId,
authSession.user.id,
teamId,
role
);
revalidatePath(`/weather/${sessionId}`);
return { success: true, data: shares };
} catch (error) {
console.error('Error sharing weather session to team:', error);
const message = error instanceof Error ? error.message : "Erreur lors du partage à l'équipe";
return { success: false, error: message };
}
}
export async function removeWeatherShare(sessionId: string, shareUserId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await weatherService.removeWeatherShare(sessionId, authSession.user.id, shareUserId);
revalidatePath(`/weather/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error removing weather share:', error);
return { success: false, error: 'Erreur lors de la suppression du partage' };
}
}

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

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

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

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 { Button, Input, 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>
@@ -61,42 +62,32 @@ export default function LoginPage() {
)}
<div className="mb-4">
<label htmlFor="email" className="mb-2 block text-sm font-medium text-foreground">
Email
</label>
<input
<Input
id="email"
name="email"
type="email"
label="Email"
required
autoComplete="email"
className="w-full rounded-lg border border-input-border bg-input px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="vous@exemple.com"
/>
</div>
<div className="mb-6">
<label htmlFor="password" className="mb-2 block text-sm font-medium text-foreground">
Mot de passe
</label>
<input
<Input
id="password"
name="password"
type="password"
label="Mot de passe"
required
autoComplete="current-password"
className="w-full rounded-lg border border-input-border bg-input px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-primary px-4 py-2.5 font-semibold text-primary-foreground transition-colors hover:bg-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
<Button type="submit" disabled={loading} loading={loading} className="w-full">
{loading ? 'Connexion...' : 'Se connecter'}
</button>
</Button>
<p className="mt-6 text-center text-sm text-muted">
Pas encore de compte ?{' '}

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 { Button, Input, 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>
@@ -90,74 +91,55 @@ export default function RegisterPage() {
)}
<div className="mb-4">
<label htmlFor="name" className="mb-2 block text-sm font-medium text-foreground">
Nom
</label>
<input
<Input
id="name"
name="name"
type="text"
label="Nom"
autoComplete="name"
className="w-full rounded-lg border border-input-border bg-input px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="Jean Dupont"
/>
</div>
<div className="mb-4">
<label htmlFor="email" className="mb-2 block text-sm font-medium text-foreground">
Email
</label>
<input
<Input
id="email"
name="email"
type="email"
label="Email"
required
autoComplete="email"
className="w-full rounded-lg border border-input-border bg-input px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="vous@exemple.com"
/>
</div>
<div className="mb-4">
<label htmlFor="password" className="mb-2 block text-sm font-medium text-foreground">
Mot de passe
</label>
<input
<Input
id="password"
name="password"
type="password"
label="Mot de passe"
required
autoComplete="new-password"
className="w-full rounded-lg border border-input-border bg-input px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="••••••••"
/>
</div>
<div className="mb-6">
<label
htmlFor="confirmPassword"
className="mb-2 block text-sm font-medium text-foreground"
>
Confirmer le mot de passe
</label>
<input
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
label="Confirmer le mot de passe"
required
autoComplete="new-password"
className="w-full rounded-lg border border-input-border bg-input px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-primary px-4 py-2.5 font-semibold text-primary-foreground transition-colors hover:bg-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
<Button type="submit" disabled={loading} loading={loading} className="w-full">
{loading ? 'Création...' : 'Créer mon compte'}
</button>
</Button>
<p className="mt-6 text-center text-sm text-muted">
Déjà un compte ?{' '}

View File

@@ -0,0 +1,112 @@
import { auth } from '@/lib/auth';
import { canAccessGifMoodSession, getGifMoodSessionEvents } from '@/services/gif-mood';
export const dynamic = 'force-dynamic';
// Store active connections per session
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id: sessionId } = await params;
const session = await auth();
if (!session?.user?.id) {
return new Response('Unauthorized', { status: 401 });
}
const hasAccess = await canAccessGifMoodSession(sessionId, session.user.id);
if (!hasAccess) {
return new Response('Forbidden', { status: 403 });
}
const userId = session.user.id;
let lastEventTime = new Date();
let controller: ReadableStreamDefaultController;
const stream = new ReadableStream({
start(ctrl) {
controller = ctrl;
if (!connections.has(sessionId)) {
connections.set(sessionId, new Set());
}
connections.get(sessionId)!.add(controller);
const encoder = new TextEncoder();
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`)
);
},
cancel() {
connections.get(sessionId)?.delete(controller);
if (connections.get(sessionId)?.size === 0) {
connections.delete(sessionId);
}
},
});
const pollInterval = setInterval(async () => {
try {
const events = await getGifMoodSessionEvents(sessionId, lastEventTime);
if (events.length > 0) {
const encoder = new TextEncoder();
for (const event of events) {
if (event.userId !== userId) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: event.type,
payload: JSON.parse(event.payload),
userId: event.userId,
user: event.user,
timestamp: event.createdAt,
})}\n\n`
)
);
}
lastEventTime = event.createdAt;
}
}
} catch {
clearInterval(pollInterval);
}
}, 2000);
request.signal.addEventListener('abort', () => {
clearInterval(pollInterval);
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
export function broadcastToGifMoodSession(sessionId: string, event: object) {
try {
const sessionConnections = connections.get(sessionId);
if (!sessionConnections || sessionConnections.size === 0) {
return;
}
const encoder = new TextEncoder();
const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
for (const controller of sessionConnections) {
try {
controller.enqueue(message);
} catch {
sessionConnections.delete(controller);
}
}
if (sessionConnections.size === 0) {
connections.delete(sessionId);
}
} catch (error) {
console.error('[SSE Broadcast] Error broadcasting:', error);
}
}

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,61 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { updateKeyResult } from '@/services/okrs';
import { getOKR } from '@/services/okrs';
import { isTeamMember, isTeamAdmin } from '@/services/teams';
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string; krId: string }> }
) {
try {
const { id, krId } = await params;
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
// Get OKR to check permissions
const okr = await getOKR(id);
if (!okr) {
return NextResponse.json({ error: 'OKR non trouvé' }, { status: 404 });
}
// Check if user is a member of the team
const isMember = await isTeamMember(okr.teamMember.team.id, session.user.id);
if (!isMember) {
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
}
// Check if user is admin or the concerned member
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
const isConcernedMember = okr.teamMember.userId === session.user.id;
if (!isAdmin && !isConcernedMember) {
return NextResponse.json(
{
error:
'Seuls les administrateurs et le membre concerné peuvent mettre à jour les Key Results',
},
{ status: 403 }
);
}
const body = await request.json();
const { currentValue, notes } = body;
if (currentValue === undefined) {
return NextResponse.json({ error: 'Valeur actuelle requise' }, { status: 400 });
}
const updated = await updateKeyResult(krId, Number(currentValue), notes || null);
return NextResponse.json(updated);
} catch (error) {
console.error('Error updating key result:', error);
const errorMessage =
error instanceof Error ? error.message : 'Erreur lors de la mise à jour du Key Result';
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}

View File

@@ -0,0 +1,149 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { getOKR, updateOKR, deleteOKR } from '@/services/okrs';
import { isTeamMember, isTeamAdmin } from '@/services/teams';
import type { UpdateOKRInput } from '@/lib/types';
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
const okr = await getOKR(id);
if (!okr) {
return NextResponse.json({ error: 'OKR non trouvé' }, { status: 404 });
}
// Check if user is a member of the team
const isMember = await isTeamMember(okr.teamMember.team.id, session.user.id);
if (!isMember) {
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
}
// Check permissions
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
const isConcernedMember = okr.teamMember.userId === session.user.id;
return NextResponse.json({
...okr,
permissions: {
isAdmin,
isConcernedMember,
canEdit: isAdmin || isConcernedMember,
canDelete: isAdmin,
},
});
} catch (error) {
console.error('Error fetching OKR:', error);
return NextResponse.json({ error: "Erreur lors de la récupération de l'OKR" }, { status: 500 });
}
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
const okr = await getOKR(id);
if (!okr) {
return NextResponse.json({ error: 'OKR non trouvé' }, { status: 404 });
}
// Check if user is admin of the team or the concerned member
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
const isConcernedMember = okr.teamMember.userId === session.user.id;
if (!isAdmin && !isConcernedMember) {
return NextResponse.json(
{ error: 'Seuls les administrateurs et le membre concerné peuvent modifier les OKRs' },
{ status: 403 }
);
}
const body: UpdateOKRInput & {
startDate?: string;
endDate?: string;
keyResultsUpdates?: {
create?: Array<{ title: string; targetValue: number; unit: string; order: number }>;
update?: Array<{
id: string;
title?: string;
targetValue?: number;
unit?: string;
order?: number;
}>;
delete?: string[];
};
} = await request.json();
// Convert date strings to Date objects if provided
const updateData: UpdateOKRInput = { ...body };
if (body.startDate) {
updateData.startDate = new Date(body.startDate);
}
if (body.endDate) {
updateData.endDate = new Date(body.endDate);
}
// Remove keyResultsUpdates from updateData as it's not part of UpdateOKRInput
const { keyResultsUpdates, ...okrUpdateData } = body;
const finalUpdateData: UpdateOKRInput = { ...okrUpdateData };
if (finalUpdateData.startDate && typeof finalUpdateData.startDate === 'string') {
finalUpdateData.startDate = new Date(finalUpdateData.startDate);
}
if (finalUpdateData.endDate && typeof finalUpdateData.endDate === 'string') {
finalUpdateData.endDate = new Date(finalUpdateData.endDate);
}
const updated = await updateOKR(id, finalUpdateData, keyResultsUpdates);
return NextResponse.json(updated);
} catch (error) {
console.error('Error updating OKR:', error);
const errorMessage =
error instanceof Error ? error.message : "Erreur lors de la mise à jour de l'OKR";
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
const okr = await getOKR(id);
if (!okr) {
return NextResponse.json({ error: 'OKR non trouvé' }, { status: 404 });
}
// Check if user is admin of the team
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
if (!isAdmin) {
return NextResponse.json(
{ error: 'Seuls les administrateurs peuvent supprimer les OKRs' },
{ status: 403 }
);
}
await deleteOKR(id);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting OKR:', error);
const errorMessage =
error instanceof Error ? error.message : "Erreur lors de la suppression de l'OKR";
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}

74
src/app/api/okrs/route.ts Normal file
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,96 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { getTeam, updateTeam, deleteTeam, isTeamAdmin, isTeamMember } from '@/services/teams';
import type { UpdateTeamInput } from '@/lib/types';
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
const team = await getTeam(id);
if (!team) {
return NextResponse.json({ error: 'Équipe non trouvée' }, { status: 404 });
}
// Check if user is a member
const isMember = await isTeamMember(id, session.user.id);
if (!isMember) {
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
}
return NextResponse.json(team);
} catch (error) {
console.error('Error fetching team:', error);
return NextResponse.json(
{ error: "Erreur lors de la récupération de l'équipe" },
{ status: 500 }
);
}
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
// Check if user is admin
const isAdmin = await isTeamAdmin(id, session.user.id);
if (!isAdmin) {
return NextResponse.json(
{ error: "Seuls les administrateurs peuvent modifier l'équipe" },
{ status: 403 }
);
}
const body: UpdateTeamInput = await request.json();
const team = await updateTeam(id, body);
return NextResponse.json(team);
} catch (error) {
console.error('Error updating team:', error);
return NextResponse.json(
{ error: "Erreur lors de la mise à jour de l'équipe" },
{ status: 500 }
);
}
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
// Check if user is admin
const isAdmin = await isTeamAdmin(id, session.user.id);
if (!isAdmin) {
return NextResponse.json(
{ error: "Seuls les administrateurs peuvent supprimer l'équipe" },
{ status: 403 }
);
}
await deleteTeam(id);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting team:', error);
return NextResponse.json(
{ error: "Erreur lors de la suppression de l'équipe" },
{ status: 500 }
);
}
}

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,48 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { getUserTeams, createTeam } from '@/services/teams';
import type { CreateTeamInput } from '@/lib/types';
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
const teams = await getUserTeams(session.user.id);
return NextResponse.json(teams);
} catch (error) {
console.error('Error fetching teams:', error);
return NextResponse.json(
{ error: 'Erreur lors de la récupération des équipes' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
const body: CreateTeamInput = await request.json();
const { name, description } = body;
if (!name) {
return NextResponse.json({ error: "Le nom de l'équipe est requis" }, { status: 400 });
}
const team = await createTeam(name, description || null, session.user.id);
return NextResponse.json(team, { status: 201 });
} catch (error) {
console.error('Error creating team:', error);
return NextResponse.json({ error: "Erreur lors de la création de l'équipe" }, { status: 500 });
}
}

View File

@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { prisma } from '@/services/database';
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
},
orderBy: {
createdAt: 'desc',
},
});
return NextResponse.json(users);
} catch (error) {
console.error('Error fetching users:', error);
return NextResponse.json(
{ error: 'Erreur lors de la récupération des utilisateurs' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,136 @@
import { auth } from '@/lib/auth';
import { canAccessWeatherSession, getWeatherSessionEvents } from '@/services/weather';
export const dynamic = 'force-dynamic';
// Store active connections per session
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id: sessionId } = await params;
const session = await auth();
if (!session?.user?.id) {
return new Response('Unauthorized', { status: 401 });
}
// Check access
const hasAccess = await canAccessWeatherSession(sessionId, session.user.id);
if (!hasAccess) {
return new Response('Forbidden', { status: 403 });
}
const userId = session.user.id;
let lastEventTime = new Date();
let controller: ReadableStreamDefaultController;
const stream = new ReadableStream({
start(ctrl) {
controller = ctrl;
// Register connection
if (!connections.has(sessionId)) {
connections.set(sessionId, new Set());
}
connections.get(sessionId)!.add(controller);
// Send initial ping
const encoder = new TextEncoder();
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`)
);
},
cancel() {
// Remove connection on close
connections.get(sessionId)?.delete(controller);
if (connections.get(sessionId)?.size === 0) {
connections.delete(sessionId);
}
},
});
// Poll for new events (simple approach, works with any DB)
const pollInterval = setInterval(async () => {
try {
const events = await getWeatherSessionEvents(sessionId, lastEventTime);
if (events.length > 0) {
const encoder = new TextEncoder();
for (const event of events) {
// Don't send events to the user who created them
if (event.userId !== userId) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: event.type,
payload: JSON.parse(event.payload),
userId: event.userId,
user: event.user,
timestamp: event.createdAt,
})}\n\n`
)
);
}
lastEventTime = event.createdAt;
}
}
} catch {
// Connection might be closed
clearInterval(pollInterval);
}
}, 2000); // Poll every 2 seconds
// Cleanup on abort
request.signal.addEventListener('abort', () => {
clearInterval(pollInterval);
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
// Helper to broadcast to all connections (called from actions)
export function broadcastToWeatherSession(sessionId: string, event: object) {
try {
const sessionConnections = connections.get(sessionId);
if (!sessionConnections || sessionConnections.size === 0) {
// No active connections, event will be picked up by polling
console.log(
`[SSE Broadcast] No connections for session ${sessionId}, will be picked up by polling`
);
return;
}
console.log(
`[SSE Broadcast] Broadcasting to ${sessionConnections.size} connections for session ${sessionId}`
);
const encoder = new TextEncoder();
const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
let sentCount = 0;
for (const controller of sessionConnections) {
try {
controller.enqueue(message);
sentCount++;
} catch (error) {
// Connection might be closed, remove it
console.log(`[SSE Broadcast] Failed to send, removing connection:`, error);
sessionConnections.delete(controller);
}
}
console.log(`[SSE Broadcast] Sent to ${sentCount} connections`);
// Clean up empty sets
if (sessionConnections.size === 0) {
connections.delete(sessionId);
}
} catch (error) {
console.error('[SSE Broadcast] Error broadcasting:', error);
}
}

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

View File

@@ -0,0 +1,525 @@
'use client';
import { useState } from 'react';
import {
Avatar,
Badge,
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
CollaboratorDisplay,
DateInput,
Disclosure,
DropdownMenu,
EditableGifMoodTitle,
EditableMotivatorTitle,
EditableSessionTitle,
EditableTitle,
EditableWeatherTitle,
EditableWeeklyCheckInTitle,
EditableYearReviewTitle,
InlineFormActions,
Input,
IconCheck,
IconClose,
IconDuplicate,
IconEdit,
IconButton,
IconPlus,
IconTrash,
Modal,
ModalFooter,
PageHeader,
ParticipantInput,
RocketIcon,
Select,
SegmentedControl,
SessionPageHeader,
Textarea,
ToggleGroup,
FormField,
NumberInput,
} from '@/components/ui';
const BUTTON_VARIANTS = [
'primary',
'secondary',
'outline',
'ghost',
'destructive',
'brand',
] as const;
const BUTTON_SIZES = ['sm', 'md', 'lg'] as const;
const BADGE_VARIANTS = [
'default',
'primary',
'strength',
'weakness',
'opportunity',
'threat',
'success',
'warning',
'destructive',
'accent',
] as const;
const SELECT_OPTIONS = [
{ value: 'editor', label: 'Editeur' },
{ value: 'viewer', label: 'Lecteur' },
{ value: 'admin', label: 'Admin' },
];
const SECTION_LINKS = [
{ id: 'buttons', label: 'Buttons' },
{ id: 'badges', label: 'Badges' },
{ id: 'icon-button', label: 'IconButton' },
{ id: 'form-inputs', label: 'Form Inputs' },
{ id: 'select-toggle', label: 'Select & Toggle' },
{ id: 'form-field', label: 'FormField / Date / Number' },
{ id: 'cards', label: 'Cards' },
{ id: 'avatars', label: 'Avatar & Collaborators' },
{ id: 'disclosure-dropdown', label: 'Disclosure & Dropdown' },
{ id: 'menu', label: 'Menu' },
{ id: 'editable-titles', label: 'Editable Titles' },
{ id: 'session-header', label: 'Session Header' },
{ id: 'participant-input', label: 'ParticipantInput' },
{ id: 'icons', label: 'Icons' },
{ id: 'modal', label: 'Modal' },
] as const;
export default function DesignSystemPage() {
const [modalOpen, setModalOpen] = useState(false);
const [toggleValue, setToggleValue] = useState<'cards' | 'table' | 'list'>('cards');
const [selectMd, setSelectMd] = useState('editor');
const [selectSm, setSelectSm] = useState('viewer');
const [selectXs, setSelectXs] = useState('admin');
const [selectLg, setSelectLg] = useState('editor');
const [menuCount, setMenuCount] = useState(0);
return (
<main className="mx-auto max-w-7xl px-4 py-8">
<PageHeader
emoji="🎨"
title="Design System"
subtitle="Guide visuel des composants UI et de leurs variantes"
actions={
<Button variant="brand" size="sm">
Action principale
</Button>
}
/>
<div className="grid items-start gap-8" style={{ gridTemplateColumns: '240px minmax(0, 1fr)' }}>
<aside>
<Card className="sticky top-20 p-4">
<p className="mb-3 text-sm font-medium text-foreground">Menu de la page</p>
<nav className="flex flex-col gap-1.5">
{SECTION_LINKS.map((section) => (
<a
key={section.id}
href={`#${section.id}`}
className="rounded-md px-2.5 py-1.5 text-sm text-muted transition-colors hover:bg-card-hover hover:text-foreground"
>
{section.label}
</a>
))}
</nav>
</Card>
</aside>
<div className="space-y-8">
<Card id="buttons" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Buttons</h2>
<div className="space-y-4">
{BUTTON_SIZES.map((size) => (
<div key={size} className="flex flex-wrap items-center gap-3">
<span className="w-12 text-xs uppercase tracking-wide text-muted">{size}</span>
{BUTTON_VARIANTS.map((variant) => (
<Button key={`${size}-${variant}`} variant={variant} size={size}>
{variant}
</Button>
))}
</div>
))}
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-4">
<Button loading>Chargement</Button>
<Button disabled>Desactive</Button>
</div>
</div>
</Card>
<Card id="badges" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Badges</h2>
<div className="flex flex-wrap gap-2">
{BADGE_VARIANTS.map((variant) => (
<Badge key={variant} variant={variant}>
{variant}
</Badge>
))}
</div>
</Card>
<Card id="icon-button" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">IconButton</h2>
<div className="flex flex-wrap items-center gap-2">
<IconButton icon={<IconEdit />} label="Edit" />
<IconButton icon={<IconDuplicate />} label="Duplicate" variant="primary" />
<IconButton icon={<IconTrash />} label="Delete" variant="destructive" />
</div>
</Card>
<Card id="form-inputs" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Form Inputs</h2>
<div className="grid gap-4 md:grid-cols-2">
<Input label="Input standard" placeholder="Votre texte" />
<Input label="Input avec erreur" defaultValue="Valeur invalide" error="Champ invalide" />
<Textarea label="Textarea standard" placeholder="Votre description" rows={3} />
<Textarea
label="Textarea avec erreur"
defaultValue="Texte"
rows={3}
error="Description trop courte"
/>
</div>
</Card>
<Card id="select-toggle" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Select & ToggleGroup</h2>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-3">
<Select
label="Select XS"
size="xs"
value={selectXs}
onChange={(e) => setSelectXs(e.target.value)}
options={SELECT_OPTIONS}
/>
<Select
label="Select SM"
size="sm"
value={selectSm}
onChange={(e) => setSelectSm(e.target.value)}
options={SELECT_OPTIONS}
/>
<Select
label="Select MD"
size="md"
value={selectMd}
onChange={(e) => setSelectMd(e.target.value)}
options={SELECT_OPTIONS}
/>
<Select
label="Select LG"
size="lg"
value={selectLg}
onChange={(e) => setSelectLg(e.target.value)}
options={SELECT_OPTIONS}
/>
</div>
<div className="space-y-3">
<p className="text-sm font-medium text-foreground">Toggle group</p>
<ToggleGroup
value={toggleValue}
onChange={setToggleValue}
options={[
{ value: 'cards', label: 'Cards' },
{ value: 'table', label: 'Table' },
{ value: 'list', label: 'List' },
]}
/>
<p className="text-sm text-muted">Valeur active: {toggleValue}</p>
<p className="pt-2 text-sm font-medium text-foreground">Segmented control</p>
<SegmentedControl
value={toggleValue}
onChange={setToggleValue}
options={[
{ value: 'cards', label: 'Cards' },
{ value: 'table', label: 'Table' },
{ value: 'list', label: 'List' },
]}
/>
</div>
</div>
</Card>
<Card id="form-field" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">FormField / Date / Number</h2>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="FormField">
<Input placeholder="Control custom" />
</FormField>
<DateInput label="DateInput" defaultValue="2026-03-03" />
<NumberInput label="NumberInput" defaultValue={42} />
</div>
</Card>
<Card id="cards" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Cards & Header blocks</h2>
<div className="grid gap-4 md:grid-cols-2">
<Card hover>
<CardHeader>
<CardTitle>Card title</CardTitle>
<CardDescription>Description secondaire</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted">Contenu principal de la card.</p>
</CardContent>
<CardFooter className="justify-end">
<Button size="sm" variant="outline">
Annuler
</Button>
<Button size="sm">Valider</Button>
</CardFooter>
</Card>
<Card className="p-4">
<h3 className="mb-3 font-medium text-foreground">Inline actions</h3>
<Input placeholder="Exemple inline" className="mb-2" />
<InlineFormActions
onCancel={() => {}}
onSubmit={() => {}}
isPending={false}
submitLabel="Ajouter"
/>
</Card>
</div>
</Card>
<Card id="avatars" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Avatar & Collaborators</h2>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center gap-3">
<Avatar email="jane.doe@example.com" name="Jane Doe" size={40} />
<Avatar email="john.smith@example.com" name="John Smith" size={32} />
<Avatar email="team@example.com" size={24} />
</div>
<div className="space-y-3">
<CollaboratorDisplay
collaborator={{
raw: 'Jane Doe',
matchedUser: {
id: '1',
email: 'jane.doe@example.com',
name: 'Jane Doe',
},
}}
showEmail
/>
<CollaboratorDisplay
collaborator={{
raw: 'Intervenant externe',
matchedUser: null,
}}
/>
</div>
</div>
</Card>
<Card id="disclosure-dropdown" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Disclosure & Dropdown</h2>
<div className="space-y-4">
<Disclosure icon="" title="Panneau pliable" subtitle="Composant Disclosure">
<p className="text-sm text-muted">Contenu du panneau.</p>
</Disclosure>
<DropdownMenu
panelClassName="mt-2 w-56 rounded-lg border border-border bg-card p-2 shadow-lg"
trigger={({ open, toggle }) => (
<Button type="button" variant="outline" onClick={toggle}>
Menu demo {open ? '▲' : '▼'}
</Button>
)}
>
{({ close }) => (
<Button
size="sm"
variant="secondary"
onClick={() => {
setMenuCount((prev) => prev + 1);
close();
}}
>
Incrementer ({menuCount})
</Button>
)}
</DropdownMenu>
</div>
</Card>
<Card id="menu" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Menu</h2>
<DropdownMenu
panelClassName="mt-2 w-64 overflow-hidden rounded-lg border border-border bg-card py-1 shadow-lg"
trigger={({ open, toggle }) => (
<Button type="button" variant="outline" onClick={toggle}>
Ouvrir le menu {open ? '▲' : '▼'}
</Button>
)}
>
{({ close }) => (
<>
<div className="border-b border-border px-4 py-2">
<p className="text-xs text-muted">MENU DE DEMO</p>
<p className="text-sm font-medium text-foreground">Navigation rapide</p>
</div>
<button
type="button"
onClick={close}
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
>
👤 Mon profil
</button>
<button
type="button"
onClick={close}
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
>
👥 Equipes
</button>
<button
type="button"
onClick={close}
className="block w-full px-4 py-2 text-left text-sm text-destructive hover:bg-card-hover"
>
Se deconnecter
</button>
</>
)}
</DropdownMenu>
</Card>
<Card id="editable-titles" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Editable Titles</h2>
<div className="space-y-6">
<div>
<p className="mb-2 text-sm font-medium text-foreground">EditableTitle (base)</p>
<EditableTitle
sessionId="demo-editable-title"
initialTitle="Titre modifiable (cliquez pour tester)"
canEdit
onUpdate={async () => ({ success: true })}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<EditableSessionTitle
sessionId="demo-session-title"
initialTitle="Session title wrapper"
canEdit={false}
/>
<EditableMotivatorTitle
sessionId="demo-motivator-title"
initialTitle="Motivator title wrapper"
canEdit={false}
/>
<EditableYearReviewTitle
sessionId="demo-year-review-title"
initialTitle="Year review title wrapper"
canEdit={false}
/>
<EditableWeatherTitle
sessionId="demo-weather-title"
initialTitle="Weather title wrapper"
canEdit={false}
/>
<EditableWeeklyCheckInTitle
sessionId="demo-weekly-checkin-title"
initialTitle="Weekly check-in title wrapper"
canEdit={false}
/>
<EditableGifMoodTitle
sessionId="demo-gif-mood-title"
initialTitle="Gif mood title wrapper"
canEdit={false}
/>
</div>
</div>
</Card>
<Card id="session-header" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Session Header</h2>
<SessionPageHeader
workshopType="swot"
sessionId="demo-session"
sessionTitle="Atelier de demonstration"
isOwner={true}
canEdit={false}
ownerUser={{ name: 'Jane Doe', email: 'jane.doe@example.com' }}
date={new Date()}
collaborator={{
raw: 'Jane Doe',
matchedUser: {
id: '1',
email: 'jane.doe@example.com',
name: 'Jane Doe',
},
}}
badges={<Badge variant="primary">DEMO</Badge>}
/>
</Card>
<Card id="participant-input" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">ParticipantInput</h2>
<ParticipantInput name="participant" />
</Card>
<Card id="icons" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Icons</h2>
<div className="flex flex-wrap items-center gap-4 text-foreground">
<div className="flex items-center gap-2">
<IconEdit />
<span className="text-sm text-muted">Edit</span>
</div>
<div className="flex items-center gap-2">
<IconTrash />
<span className="text-sm text-muted">Trash</span>
</div>
<div className="flex items-center gap-2">
<IconDuplicate />
<span className="text-sm text-muted">Duplicate</span>
</div>
<div className="flex items-center gap-2">
<IconPlus />
<span className="text-sm text-muted">Plus</span>
</div>
<div className="flex items-center gap-2">
<IconCheck />
<span className="text-sm text-muted">Check</span>
</div>
<div className="flex items-center gap-2">
<IconClose />
<span className="text-sm text-muted">Close</span>
</div>
<div className="flex items-center gap-2">
<RocketIcon className="h-5 w-5" />
<span className="text-sm text-muted">Rocket</span>
</div>
</div>
</Card>
<Card id="modal" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Modal</h2>
<Button onClick={() => setModalOpen(true)}>Ouvrir la popup</Button>
</Card>
</div>
</div>
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} title="Exemple de popup" size="md">
<p className="text-sm text-muted">
Ceci est un exemple de modal avec ses actions standardisees.
</p>
<ModalFooter>
<Button variant="outline" onClick={() => setModalOpen(false)}>
Annuler
</Button>
<Button onClick={() => setModalOpen(false)}>Confirmer</Button>
</ModalFooter>
</Modal>
</main>
);
}

View File

@@ -0,0 +1,67 @@
import { notFound } from 'next/navigation';
import { auth } from '@/lib/auth';
import { getGifMoodSessionById } from '@/services/gif-mood';
import { getUserTeams } from '@/services/teams';
import { GifMoodBoard, GifMoodLiveWrapper } from '@/components/gif-mood';
import { Badge, SessionPageHeader } from '@/components/ui';
interface GifMoodSessionPageProps {
params: Promise<{ id: string }>;
}
export default async function GifMoodSessionPage({ params }: GifMoodSessionPageProps) {
const { id } = await params;
const authSession = await auth();
if (!authSession?.user?.id) {
return null;
}
const session = await getGifMoodSessionById(id, authSession.user.id);
if (!session) {
notFound();
}
const userTeams = await getUserTeams(authSession.user.id);
return (
<main className="mx-auto max-w-7xl px-4">
<SessionPageHeader
workshopType="gif-mood"
sessionId={session.id}
sessionTitle={session.title}
isOwner={session.isOwner}
canEdit={session.canEdit}
ownerUser={session.user}
date={session.date}
badges={<Badge variant="primary">{session.items.length} GIFs</Badge>}
/>
{/* Live Wrapper + Board */}
<GifMoodLiveWrapper
sessionId={session.id}
sessionTitle={session.title}
currentUserId={authSession.user.id}
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
userTeams={userTeams}
>
<GifMoodBoard
sessionId={session.id}
currentUserId={authSession.user.id}
items={session.items}
shares={session.shares}
owner={{
id: session.user.id,
name: session.user.name ?? null,
email: session.user.email ?? '',
}}
ratings={session.ratings}
canEdit={session.canEdit}
/>
</GifMoodLiveWrapper>
</main>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
DateInput,
Input,
} from '@/components/ui';
import { createGifMoodSession } from '@/actions/gif-mood';
import { GIF_MOOD_MAX_ITEMS } from '@/lib/types';
export default function NewGifMoodPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [title, setTitle] = useState(
() =>
`GIF Mood - ${new Date().toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}`
);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const date = selectedDate ? new Date(selectedDate) : undefined;
if (!title) {
setError('Veuillez remplir le titre');
setLoading(false);
return;
}
const result = await createGifMoodSession({ title, date });
if (!result.success) {
setError(result.error || 'Une erreur est survenue');
setLoading(false);
return;
}
router.push(`/gif-mood/${result.data?.id}`);
}
return (
<main className="mx-auto max-w-2xl px-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>🎞</span>
Nouveau GIF Mood Board
</CardTitle>
<CardDescription>
Créez un tableau de bord GIF pour exprimer et partager votre humeur avec votre équipe
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<Input
label="Titre de la session"
name="title"
placeholder="Ex: GIF Mood - Mars 2026"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
<DateInput
id="date"
name="date"
label="Date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
required
/>
<div className="rounded-lg border border-border bg-card-hover p-4">
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
<ol className="text-sm text-muted space-y-1 list-decimal list-inside">
<li>Partagez la session avec votre équipe</li>
<li>Chaque membre peut ajouter jusqu&apos;à {GIF_MOOD_MAX_ITEMS} GIFs</li>
<li>Ajoutez une note à chaque GIF pour expliquer votre humeur</li>
<li>Les GIFs apparaissent en temps réel pour tous les membres</li>
</ol>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={loading}
>
Annuler
</Button>
<Button type="submit" loading={loading} className="flex-1">
Créer le GIF Mood Board
</Button>
</div>
</form>
</CardContent>
</Card>
</main>
);
}

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

@@ -1,7 +1,8 @@
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { Geist, Geist_Mono, Caveat } from 'next/font/google';
import './globals.css';
import { Providers } from '@/components/Providers';
import { Header } from '@/components/layout/Header';
const geistSans = Geist({
variable: '--font-geist-sans',
@@ -13,9 +14,18 @@ const geistMono = Geist_Mono({
subsets: ['latin'],
});
const caveat = Caveat({
variable: '--font-caveat',
subsets: ['latin'],
});
export const metadata: Metadata = {
title: 'Workshop Manager',
description: "Application de gestion d'ateliers SWOT pour entretiens managériaux",
description: "Application de gestion d'ateliers pour entretiens managériaux",
icons: {
icon: '/icon.svg',
apple: '/rocket_blue_gradient_large_logo.jpg',
},
};
export default function RootLayout({
@@ -25,8 +35,22 @@ export default function RootLayout({
}>) {
return (
<html lang="fr" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<Providers>{children}</Providers>
<head>
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme:dark)').matches)){document.documentElement.classList.add('dark')}else{document.documentElement.classList.add('light')}}catch(e){}})()`,
}}
/>
</head>
<body className={`${geistSans.variable} ${geistMono.variable} ${caveat.variable} antialiased`}>
<Providers>
<div className="min-h-screen bg-background">
<Header />
<div className="py-6">
{children}
</div>
</div>
</Providers>
</body>
</html>
);

25
src/app/manifest.ts Normal file
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,10 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getMotivatorSessionById } from '@/services/moving-motivators';
import { getUserTeams } from '@/services/teams';
import type { ResolvedCollaborator } from '@/services/auth';
import { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators';
import { Badge, CollaboratorDisplay } from '@/components/ui';
import { EditableMotivatorTitle } from './EditableTitle';
import { Badge, SessionPageHeader } from '@/components/ui';
interface MotivatorSessionPageProps {
params: Promise<{ id: string }>;
@@ -18,54 +18,32 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
return null;
}
const session = await getMotivatorSessionById(id, authSession.user.id);
const [session, userTeams] = await Promise.all([
getMotivatorSessionById(id, authSession.user.id),
getUserTeams(authSession.user.id),
]);
if (!session) {
notFound();
}
return (
<main className="mx-auto max-w-7xl px-4 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-muted mb-2">
<Link href="/sessions?tab=motivators" className="hover:text-foreground">
Moving Motivators
</Link>
<span>/</span>
<span className="text-foreground">{session.title}</span>
{!session.isOwner && (
<Badge variant="accent" className="ml-2">
Partagé par {session.user.name || session.user.email}
</Badge>
)}
</div>
<div className="flex items-start justify-between">
<div>
<EditableMotivatorTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
/>
<div className="mt-2">
<CollaboratorDisplay collaborator={session.resolvedParticipant} size="lg" showEmail />
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary">
{session.cards.filter((c) => c.influence !== 0).length} / 10 évalués
</Badge>
<span className="text-sm text-muted">
{new Date(session.date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
<main className="mx-auto max-w-7xl px-4">
<SessionPageHeader
workshopType="motivators"
sessionId={session.id}
sessionTitle={session.title}
isOwner={session.isOwner}
canEdit={session.canEdit}
ownerUser={session.user}
date={session.date}
collaborator={session.resolvedParticipant as ResolvedCollaborator}
badges={
<Badge variant="primary">
{session.cards.filter((c) => c.influence !== 0).length} / 10 évalués
</Badge>
}
/>
{/* Live Wrapper + Board */}
<MotivatorLiveWrapper
@@ -75,6 +53,7 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
userTeams={userTeams}
>
<MotivatorBoard sessionId={session.id} cards={session.cards} canEdit={session.canEdit} />
</MotivatorLiveWrapper>

View File

@@ -10,6 +10,7 @@ import {
CardContent,
Button,
Input,
ParticipantInput,
} from '@/components/ui';
import { createMotivatorSession } from '@/actions/moving-motivators';
@@ -45,7 +46,7 @@ export default function NewMotivatorSessionPage() {
}
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<main className="mx-auto max-w-2xl px-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -72,12 +73,7 @@ export default function NewMotivatorSessionPage() {
required
/>
<Input
label="Nom du participant"
name="participant"
placeholder="Ex: Jean Dupont"
required
/>
<ParticipantInput name="participant" required />
<div className="rounded-lg border border-border bg-card-hover p-4">
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>

View File

@@ -0,0 +1,61 @@
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { getUserOKRs } from '@/services/okrs';
import { Card, PageHeader, getButtonClassName } from '@/components/ui';
import { ObjectivesList } from '@/components/okrs/ObjectivesList';
import { comparePeriods } from '@/lib/okr-utils';
export default async function ObjectivesPage() {
const session = await auth();
if (!session?.user?.id) {
redirect('/login');
}
const okrs = await getUserOKRs(session.user.id);
// Group OKRs by period
const okrsByPeriod = okrs.reduce(
(acc, okr) => {
const period = okr.period;
if (!acc[period]) {
acc[period] = [];
}
acc[period].push(okr);
return acc;
},
{} as Record<string, typeof okrs>
);
const periods = Object.keys(okrsByPeriod).sort(comparePeriods);
return (
<main className="mx-auto max-w-7xl px-4">
<PageHeader
emoji="🎯"
title="Mes Objectifs"
subtitle="Suivez la progression de vos OKRs à travers toutes vos équipes"
/>
{okrs.length === 0 ? (
<Card className="p-12 text-center">
<div className="text-5xl mb-4">🎯</div>
<h3 className="text-xl font-semibold text-foreground mb-2">Aucun OKR défini</h3>
<p className="text-muted mb-6">
Vous n&apos;avez pas encore d&apos;OKR défini. Contactez un administrateur d&apos;équipe
pour en créer.
</p>
<Link
href="/teams"
className={getButtonClassName({ variant: 'brand' })}
>
Voir mes équipes
</Link>
</Card>
) : (
<ObjectivesList okrsByPeriod={okrsByPeriod} periods={periods} />
)}
</main>
);
}

View File

@@ -1,9 +1,11 @@
import Link from 'next/link';
import { getButtonClassName } from '@/components/ui';
import { WORKSHOPS, getSessionsTabUrl } from '@/lib/workshops';
export default function Home() {
return (
<>
<main className="mx-auto max-w-7xl px-4 py-12">
<main className="mx-auto max-w-7xl px-4">
{/* Hero Section */}
<section className="mb-16 text-center">
<h1 className="mb-4 text-5xl font-bold text-foreground">
@@ -20,38 +22,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 +234,560 @@ export default function Home() {
</div>
</section>
{/* Year Review Deep Dive Section */}
<section className="mb-16">
<div className="flex items-center gap-3 mb-8">
<span className="text-4xl">📅</span>
<div>
<h2 className="text-3xl font-bold text-foreground">Year Review</h2>
<p className="text-amber-500 font-medium">Faites le bilan de l&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>
{/* GIF Mood Board Deep Dive Section */}
<section className="mb-16">
<div className="flex items-center gap-3 mb-8">
<span className="text-4xl">🎞</span>
<div>
<h2 className="text-3xl font-bold text-foreground">GIF Mood Board</h2>
<p className="font-medium" style={{ color: '#ec4899' }}>
Exprimez l&apos;humeur de l&apos;équipe en images
</p>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-2">
{/* Why */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">💡</span>
Pourquoi un GIF Mood Board ?
</h3>
<p className="text-muted mb-4">
Les GIFs sont un langage universel pour exprimer ce que les mots peinent parfois à
traduire. Le GIF Mood Board transforme un rituel d&apos;équipe en moment visuel et
ludique, idéal pour les rétrospectives, les stand-ups ou tout point d&apos;équipe
récurrent.
</p>
<ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2">
<span style={{ color: '#ec4899' }}></span>
Rendre les rétrospectives plus vivantes et engageantes
</li>
<li className="flex items-start gap-2">
<span style={{ color: '#ec4899' }}></span>
Libérer l&apos;expression émotionnelle avec humour et créativité
</li>
<li className="flex items-start gap-2">
<span style={{ color: '#ec4899' }}></span>
Voir en un coup d&apos;œil l&apos;humeur collective de l&apos;équipe
</li>
<li className="flex items-start gap-2">
<span style={{ color: '#ec4899' }}></span>
Briser la glace et créer de la cohésion d&apos;équipe
</li>
</ul>
</div>
{/* What's in it */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl"></span>
Ce que chaque membre peut faire
</h3>
<div className="space-y-3">
<FeaturePill
icon="🎞️"
name="Jusqu'à 5 GIFs par session"
color="#ec4899"
description="Choisissez les GIFs qui reflètent le mieux votre humeur du moment"
/>
<FeaturePill
icon="✍️"
name="Notes manuscrites"
color="#8b5cf6"
description="Ajoutez un contexte ou une explication à chaque GIF"
/>
<FeaturePill
icon="⭐"
name="Note de la semaine sur 5"
color="#f59e0b"
description="Résumez votre semaine en une note globale visible par toute l'équipe"
/>
<FeaturePill
icon="⚡"
name="Mise à jour en temps réel"
color="#10b981"
description="Voir les GIFs des collègues apparaître au fur et à mesure"
/>
</div>
</div>
{/* How it works */}
<div className="rounded-xl border border-border bg-card p-6 lg:col-span-2">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl"></span>
Comment ça marche ?
</h3>
<div className="grid md:grid-cols-4 gap-4">
<StepCard
number={1}
title="Créer la session"
description="Le manager crée une session GIF Mood Board et la partage avec son équipe"
/>
<StepCard
number={2}
title="Choisir ses GIFs"
description="Chaque membre ajoute jusqu'à 5 GIFs (Giphy, Tenor, ou toute URL d'image) pour exprimer son humeur"
/>
<StepCard
number={3}
title="Annoter et noter"
description="Ajoutez une note manuscrite à chaque GIF et une note de semaine sur 5 étoiles"
/>
<StepCard
number={4}
title="Partager et discuter"
description="Parcourez le board ensemble, repérez les GIFs qui reflètent le mieux l'humeur de l'équipe et lancez la discussion"
/>
</div>
</div>
</div>
</section>
{/* OKRs Deep Dive Section */}
<section className="mb-16">
<div className="flex items-center gap-3 mb-8">
<span className="text-4xl">🎯</span>
<div>
<h2 className="text-3xl font-bold text-foreground">OKRs & Équipes</h2>
<p className="text-purple-500 font-medium">
Définissez et suivez les objectifs de votre équipe
</p>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-2">
{/* Why */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">💡</span>
Pourquoi utiliser les OKRs ?
</h3>
<p className="text-muted mb-4">
Les OKRs (Objectives and Key Results) sont un cadre de gestion d&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 +841,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"
@@ -351,14 +889,16 @@ function WorkshopCard({
<div className="flex gap-3">
<Link
href={newHref}
className="flex-1 rounded-lg px-4 py-2.5 text-center font-medium text-white transition-colors"
className={getButtonClassName({
className: 'flex-1 border-transparent text-white',
})}
style={{ backgroundColor: accentColor }}
>
Démarrer
</Link>
<Link
href={href}
className="rounded-lg border border-border px-4 py-2.5 font-medium text-foreground transition-colors hover:bg-card-hover"
className={getButtonClassName({ variant: 'outline' })}
>
Mes sessions
</Link>
@@ -420,3 +960,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

@@ -39,29 +39,20 @@ export function PasswordForm() {
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="currentPassword"
className="mb-1.5 block text-sm font-medium text-foreground"
>
Mot de passe actuel
</label>
<Input
id="currentPassword"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
</div>
<Input
id="currentPassword"
type="password"
label="Mot de passe actuel"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
<div>
<label htmlFor="newPassword" className="mb-1.5 block text-sm font-medium text-foreground">
Nouveau mot de passe
</label>
<Input
id="newPassword"
type="password"
label="Nouveau mot de passe"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
@@ -71,15 +62,10 @@ export function PasswordForm() {
</div>
<div>
<label
htmlFor="confirmPassword"
className="mb-1.5 block text-sm font-medium text-foreground"
>
Confirmer le nouveau mot de passe
</label>
<Input
id="confirmPassword"
type="password"
label="Confirmer le nouveau mot de passe"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required

View File

@@ -39,31 +39,23 @@ export function ProfileForm({ initialData }: ProfileFormProps) {
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="mb-1.5 block text-sm font-medium text-foreground">
Nom
</label>
<Input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Votre nom"
/>
</div>
<Input
id="name"
type="text"
label="Nom"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Votre nom"
/>
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-foreground">
Email
</label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<Input
id="email"
type="email"
label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
{message && (
<p

View File

@@ -19,18 +19,19 @@ export default async function ProfilePage() {
}
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<div className="mb-8 flex items-center gap-6">
<main className="mx-auto max-w-2xl px-4">
<div className="mb-8 flex items-center gap-5">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getGravatarUrl(user.email, 160)}
alt={user.name || user.email}
width={80}
height={80}
className="rounded-full border-2 border-border"
width={72}
height={72}
className="rounded-full border-2 border-border shrink-0"
/>
<div>
<h1 className="text-3xl font-bold text-foreground">Mon Profil</h1>
<p className="mt-1 text-muted">Gérez vos informations personnelles</p>
<h1 className="text-3xl font-bold tracking-tight text-foreground">Mon Profil</h1>
<p className="mt-1.5 text-sm text-muted">Gérez vos informations personnelles</p>
</div>
</div>

View File

@@ -0,0 +1,53 @@
'use client';
import Link from 'next/link';
import { Button, DropdownMenu } from '@/components/ui';
import { WORKSHOPS } from '@/lib/workshops';
export function NewWorkshopDropdown() {
return (
<DropdownMenu
panelClassName="absolute right-0 z-20 mt-2 w-60 rounded-xl border border-border bg-card py-1.5 shadow-lg"
trigger={({ open, toggle }) => (
<Button type="button" variant="primary" size="sm" onClick={toggle} className="gap-1.5">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Nouvel atelier
<svg
className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</Button>
)}
>
{({ close }) => (
<>
{WORKSHOPS.map((w) => (
<Link
key={w.id}
href={w.newPath}
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover transition-colors"
onClick={close}
>
<span
className="flex h-8 w-8 items-center justify-center rounded-lg text-base flex-shrink-0"
style={{ backgroundColor: `${w.accentColor}18` }}
>
{w.icon}
</span>
<div>
<div className="font-medium">{w.label}</div>
<div className="text-xs text-muted">{w.description}</div>
</div>
</Link>
))}
</>
)}
</DropdownMenu>
);
}

View File

@@ -0,0 +1,283 @@
'use client';
import { useState, useTransition } from 'react';
import Link from 'next/link';
import {
Button,
Modal,
ModalFooter,
Input,
CollaboratorDisplay,
IconButton,
IconEdit,
IconTrash,
} from '@/components/ui';
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review';
import { deleteWeeklyCheckInSession, updateWeeklyCheckInSession } from '@/actions/weekly-checkin';
import { deleteWeatherSession, updateWeatherSession } from '@/actions/weather';
import { deleteGifMoodSession, updateGifMoodSession } from '@/actions/gif-mood';
import { type WorkshopTypeId, getWorkshop, getSessionPath } from '@/lib/workshops';
import type { Share } from '@/lib/share-utils';
import type {
AnySession, CardView,
SwotSession, MotivatorSession, YearReviewSession,
WeeklyCheckInSession, WeatherSession, GifMoodSession,
} from './workshop-session-types';
import { TABLE_COLS } from './workshop-session-types';
import { getResolvedCollaborator, formatDate, getStatsText } from './workshop-session-helpers';
// ─── RoleBadge ────────────────────────────────────────────────────────────────
export function RoleBadge({ role }: { role: 'OWNER' | 'VIEWER' | 'EDITOR' }) {
return (
<span
className="text-[10px] px-1.5 py-0.5 rounded font-medium flex-shrink-0"
style={{
backgroundColor: role === 'EDITOR' ? 'rgba(6,182,212,0.12)' : 'rgba(234,179,8,0.12)',
color: role === 'EDITOR' ? '#06b6d4' : '#ca8a04',
}}
>
{role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
</span>
);
}
// ─── SharesList ───────────────────────────────────────────────────────────────
export function SharesList({ shares }: { shares: Share[] }) {
if (!shares.length) return null;
return (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[10px] text-muted">Partagé avec</span>
{shares.slice(0, 3).map((s) => (
<span key={s.id} className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
{s.user.name?.split(' ')[0] || s.user.email.split('@')[0]}
</span>
))}
{shares.length > 3 && <span className="text-[10px] text-muted">+{shares.length - 3}</span>}
</div>
);
}
// ─── SessionCard ──────────────────────────────────────────────────────────────
export function SessionCard({
session, isTeamCollab = false, view = 'grid',
}: {
session: AnySession; isTeamCollab?: boolean; view?: CardView;
}) {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [isPending, startTransition] = useTransition();
const isSwot = session.workshopType === 'swot';
const isYearReview = session.workshopType === 'year-review';
const isWeeklyCheckIn = session.workshopType === 'weekly-checkin';
const isWeather = session.workshopType === 'weather';
const isGifMood = session.workshopType === 'gif-mood';
const participant = isSwot ? (session as SwotSession).collaborator
: isYearReview ? (session as YearReviewSession).participant
: isWeeklyCheckIn ? (session as WeeklyCheckInSession).participant
: isWeather ? (session as WeatherSession).user.name || (session as WeatherSession).user.email
: isGifMood ? (session as GifMoodSession).user.name || (session as GifMoodSession).user.email
: (session as MotivatorSession).participant;
const [editTitle, setEditTitle] = useState(session.title);
const [editParticipant, setEditParticipant] = useState(
isSwot ? (session as SwotSession).collaborator
: isYearReview ? (session as YearReviewSession).participant
: isWeather || isGifMood ? ''
: (session as MotivatorSession).participant
);
const workshop = getWorkshop(session.workshopType as WorkshopTypeId);
const href = getSessionPath(session.workshopType as WorkshopTypeId, session.id);
const accentColor = workshop.accentColor;
const resolved = getResolvedCollaborator(session);
const participantName = resolved.matchedUser?.name || resolved.matchedUser?.email?.split('@')[0] || resolved.raw;
const statsText = getStatsText(session);
const handleDelete = () => {
startTransition(async () => {
const result = isSwot ? await deleteSwotSession(session.id)
: isYearReview ? await deleteYearReviewSession(session.id)
: isWeeklyCheckIn ? await deleteWeeklyCheckInSession(session.id)
: isWeather ? await deleteWeatherSession(session.id)
: isGifMood ? await deleteGifMoodSession(session.id)
: await deleteMotivatorSession(session.id);
if (result.success) setShowDeleteModal(false);
else console.error('Error deleting:', result.error);
});
};
const handleEdit = () => {
startTransition(async () => {
const result = isSwot ? await updateSwotSession(session.id, { title: editTitle, collaborator: editParticipant })
: isYearReview ? await updateYearReviewSession(session.id, { title: editTitle, participant: editParticipant })
: isWeeklyCheckIn ? await updateWeeklyCheckInSession(session.id, { title: editTitle, participant: editParticipant })
: isWeather ? await updateWeatherSession(session.id, { title: editTitle })
: isGifMood ? await updateGifMoodSession(session.id, { title: editTitle })
: await updateMotivatorSession(session.id, { title: editTitle, participant: editParticipant });
if (result.success) setShowEditModal(false);
else console.error('Error updating:', result.error);
});
};
const openEditModal = () => { setEditTitle(session.title); setEditParticipant(participant); setShowEditModal(true); };
const hoverCard = !isTeamCollab ? 'hover:-translate-y-0.5 hover:shadow-md' : '';
const opacity = isTeamCollab ? 'opacity-60' : '';
// ── Vue Grille ───────────────────────────────────────────────────────────
const gridCard = (
<div className={`h-full flex rounded-xl bg-card border border-border overflow-hidden transition-all duration-200 ${hoverCard} ${opacity}`}>
<div className="w-1 flex-shrink-0" style={{ backgroundColor: accentColor }} />
<div className="flex flex-col flex-1 px-4 py-4 min-w-0">
<div className="flex items-start justify-between gap-2 mb-2">
<h3 className="font-semibold text-foreground line-clamp-2 leading-snug text-[15px] flex-1">{session.title}</h3>
{!session.isOwner && <RoleBadge role={session.role} />}
</div>
<CollaboratorDisplay collaborator={resolved} size="sm" />
{!session.isOwner && <p className="text-xs text-muted mt-0.5 truncate">par {session.user.name || session.user.email}</p>}
{session.isOwner && session.shares.length > 0 && <div className="mt-2"><SharesList shares={session.shares} /></div>}
<div className="flex-1 min-h-4" />
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border text-xs text-muted">
<div className="flex items-center gap-1.5 min-w-0 truncate">
<span className="text-sm leading-none flex-shrink-0">{workshop.icon}</span>
<span className="font-medium flex-shrink-0" style={{ color: accentColor }}>{workshop.labelShort}</span>
<span className="opacity-30 flex-shrink-0">·</span>
<span className="truncate">{statsText}</span>
</div>
<span className="text-[11px] whitespace-nowrap ml-3 flex-shrink-0">{formatDate(session.updatedAt)}</span>
</div>
</div>
</div>
);
// ── Vue Liste ────────────────────────────────────────────────────────────
const listCard = (
<div className={`flex items-center gap-3 rounded-xl bg-card border border-border overflow-hidden transition-all duration-150 ${!isTeamCollab ? 'hover:shadow-sm' : ''} ${opacity} px-4 py-3`}>
<div className="w-0.5 self-stretch rounded-full flex-shrink-0" style={{ backgroundColor: accentColor }} />
<span className="text-lg leading-none flex-shrink-0">{workshop.icon}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="font-semibold text-foreground text-sm truncate">{session.title}</span>
{!session.isOwner && <RoleBadge role={session.role} />}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted flex-wrap">
<span className="truncate max-w-[140px]">{participantName}</span>
<span className="opacity-30">·</span>
<span className="font-medium flex-shrink-0" style={{ color: accentColor }}>{workshop.labelShort}</span>
<span className="opacity-30">·</span>
<span className="whitespace-nowrap">{statsText}</span>
</div>
</div>
<span className="text-[11px] text-muted whitespace-nowrap flex-shrink-0 ml-2">{formatDate(session.updatedAt)}</span>
<svg className="w-3.5 h-3.5 text-muted/40 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
);
// ── Vue Tableau ──────────────────────────────────────────────────────────
const tableRow = (
<div
className={`grid border-b border-border last:border-0 transition-colors ${!isTeamCollab ? 'hover:bg-card-hover/60' : ''} ${opacity}`}
style={{ gridTemplateColumns: TABLE_COLS }}
>
<div className="px-4 py-3 flex items-center gap-2">
<div className="w-0.5 h-5 rounded-full flex-shrink-0" style={{ backgroundColor: accentColor }} />
<span className="text-base leading-none">{workshop.icon}</span>
<span className="text-xs font-semibold truncate" style={{ color: accentColor }}>{workshop.labelShort}</span>
</div>
<div className="px-4 py-3 flex items-center gap-2 min-w-0">
<span className="font-medium text-foreground text-sm truncate">{session.title}</span>
{!session.isOwner && <RoleBadge role={session.role} />}
</div>
<div className="px-4 py-3 flex items-center">
<CollaboratorDisplay
collaborator={{ raw: session.user.name || session.user.email, matchedUser: { id: session.user.id, email: session.user.email, name: session.user.name } }}
size="sm"
/>
</div>
<div className="px-4 py-3 flex items-center">
<CollaboratorDisplay collaborator={resolved} size="sm" />
</div>
<div className="px-4 py-3 flex items-center text-xs text-muted">
<span className="truncate">{statsText}</span>
</div>
<div className="px-4 py-3 flex items-center text-xs text-muted whitespace-nowrap">
{formatDate(session.updatedAt)}
</div>
</div>
);
const cardContent = view === 'list' ? listCard : view === 'table' ? tableRow : gridCard;
const actionButtons = (
<>
{(session.isOwner || session.role === 'EDITOR' || session.isTeamCollab) && (
<div className={`absolute flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-20 ${view === 'table' ? 'top-1/2 -translate-y-1/2 right-3' : 'top-2.5 right-2.5'}`}>
<IconButton
onClick={(e) => { e.preventDefault(); e.stopPropagation(); openEditModal(); }}
label="Modifier"
icon={<IconEdit />}
variant="primary"
className="bg-card shadow-sm"
/>
{(session.isOwner || session.isTeamCollab) && (
<IconButton
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setShowDeleteModal(true); }}
label="Supprimer"
icon={<IconTrash />}
variant="destructive"
className="bg-card shadow-sm"
/>
)}
</div>
)}
</>
);
return (
<>
<div className="relative group">
<Link href={href} className={view === 'table' ? 'block' : undefined} title={isTeamCollab ? "Atelier de l'équipe" : undefined}>
{cardContent}
</Link>
{actionButtons}
</div>
<Modal isOpen={showEditModal} onClose={() => setShowEditModal(false)} title="Modifier l'atelier" size="sm">
<form onSubmit={(e) => { e.preventDefault(); handleEdit(); }} className="space-y-4">
<Input id="edit-title" label="Titre" value={editTitle} onChange={(e) => setEditTitle(e.target.value)} placeholder="Titre de l'atelier" required />
<div>
{!isWeather && !isGifMood && (
<Input id="edit-participant" label={workshop.participantLabel} value={editParticipant} onChange={(e) => setEditParticipant(e.target.value)}
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'} required />
)}
</div>
<ModalFooter>
<Button type="button" variant="outline" onClick={() => setShowEditModal(false)} disabled={isPending}>Annuler</Button>
<Button type="submit" disabled={isPending || !editTitle.trim() || (!isWeather && !isGifMood && !editParticipant.trim())}>
{isPending ? 'Enregistrement...' : 'Enregistrer'}
</Button>
</ModalFooter>
</form>
</Modal>
<Modal isOpen={showDeleteModal} onClose={() => setShowDeleteModal(false)} title="Supprimer l'atelier" size="sm">
<div className="space-y-4">
<p className="text-muted">Êtes-vous sûr de vouloir supprimer <strong className="text-foreground">&quot;{session.title}&quot;</strong> ?</p>
<p className="text-sm text-destructive">Cette action est irréversible. Toutes les données seront perdues.</p>
<ModalFooter>
<Button variant="outline" onClick={() => setShowDeleteModal(false)} disabled={isPending}>Annuler</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isPending}>{isPending ? 'Suppression...' : 'Supprimer'}</Button>
</ModalFooter>
</div>
</Modal>
</>
);
}

View File

@@ -1,216 +1,387 @@
'use client';
import { useState, useTransition } from 'react';
import Link from 'next/link';
import { useState, useRef } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { CollaboratorDisplay } from '@/components/ui';
import { type WorkshopTabType, WORKSHOPS, VALID_TAB_PARAMS } from '@/lib/workshops';
import { useClickOutside } from '@/hooks/useClickOutside';
import {
Card,
Badge,
Button,
Modal,
ModalFooter,
Input,
CollaboratorDisplay,
} from '@/components/ui';
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
type CardView, type SortCol, type WorkshopTabsProps, type AnySession,
TABLE_COLS, SORT_COLUMNS, TYPE_TABS,
} from './workshop-session-types';
import {
getResolvedCollaborator, groupByPerson, getMonthGroup, sortSessions,
} from './workshop-session-helpers';
import { SessionCard } from './SessionCard';
type WorkshopType = 'all' | 'swot' | 'motivators' | 'byPerson';
// ─── SectionHeader ────────────────────────────────────────────────────────────
const VALID_TABS: WorkshopType[] = ['all', 'swot', 'motivators', 'byPerson'];
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: 'VIEWER' | 'EDITOR';
user: ShareUser;
}
interface ResolvedCollaborator {
raw: string;
matchedUser: {
id: string;
email: string;
name: string | null;
} | null;
}
interface SwotSession {
id: string;
title: string;
collaborator: string;
resolvedCollaborator: ResolvedCollaborator;
updatedAt: Date;
isOwner: boolean;
role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[];
_count: { items: number; actions: number };
workshopType: 'swot';
}
interface MotivatorSession {
id: string;
title: string;
participant: string;
resolvedParticipant: ResolvedCollaborator;
updatedAt: Date;
isOwner: boolean;
role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[];
_count: { cards: number };
workshopType: 'motivators';
}
type AnySession = SwotSession | MotivatorSession;
interface WorkshopTabsProps {
swotSessions: SwotSession[];
motivatorSessions: MotivatorSession[];
}
// Helper to get participant name from any session
function getParticipant(session: AnySession): string {
return session.workshopType === 'swot'
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant;
}
// Helper to get resolved collaborator from any session
function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
return session.workshopType === 'swot'
? (session as SwotSession).resolvedCollaborator
: (session as MotivatorSession).resolvedParticipant;
}
// Get display name for grouping - prefer matched user name
function getDisplayName(session: AnySession): string {
const resolved = getResolvedCollaborator(session);
if (resolved.matchedUser?.name) {
return resolved.matchedUser.name;
}
return resolved.raw;
}
// Get grouping key - use matched user ID if available, otherwise normalized raw string
function getGroupKey(session: AnySession): string {
const resolved = getResolvedCollaborator(session);
// If we have a matched user, use their ID as key (ensures same person = same group)
if (resolved.matchedUser) {
return `user:${resolved.matchedUser.id}`;
}
// Otherwise, normalize the raw string
return `raw:${resolved.raw.trim().toLowerCase()}`;
}
// Group sessions by participant (using matched user ID when available)
function groupByPerson(sessions: AnySession[]): Map<string, AnySession[]> {
const grouped = new Map<string, AnySession[]>();
sessions.forEach((session) => {
const key = getGroupKey(session);
const existing = grouped.get(key);
if (existing) {
existing.push(session);
} else {
grouped.set(key, [session]);
}
});
// Sort sessions within each group by date
grouped.forEach((sessions) => {
sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
});
return grouped;
}
export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsProps) {
const searchParams = useSearchParams();
const router = useRouter();
// Get tab from URL or default to 'all'
const tabParam = searchParams.get('tab');
const activeTab: WorkshopType =
tabParam && VALID_TABS.includes(tabParam as WorkshopType) ? (tabParam as WorkshopType) : 'all';
const setActiveTab = (tab: WorkshopType) => {
const params = new URLSearchParams(searchParams.toString());
if (tab === 'all') {
params.delete('tab');
} else {
params.set('tab', tab);
}
router.push(`/sessions${params.toString() ? `?${params.toString()}` : ''}`);
};
// Combine and sort all sessions
const allSessions: AnySession[] = [...swotSessions, ...motivatorSessions].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
function SectionHeader({ label, count }: { label: string; count: number }) {
return (
<div className="flex items-center gap-3 mb-5">
<div className="w-1 h-5 rounded-full bg-primary flex-shrink-0" />
<h2 className="text-sm font-semibold text-foreground">{label}</h2>
<span className="inline-flex items-center justify-center h-5 min-w-[20px] px-1.5 rounded-full bg-primary/10 text-primary text-[11px] font-semibold">
{count}
</span>
</div>
);
}
// Filter based on active tab (for non-byPerson tabs)
const filteredSessions =
activeTab === 'all' || activeTab === 'byPerson'
? allSessions
: activeTab === 'swot'
? swotSessions
: motivatorSessions;
// ─── SortIcon ─────────────────────────────────────────────────────────────────
// Separate by ownership
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
const sharedSessions = filteredSessions.filter((s) => !s.isOwner);
function SortIcon({ active, dir }: { active: boolean; dir: 'asc' | 'desc' }) {
if (!active) {
return (
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor" className="opacity-30 flex-shrink-0">
<path d="M5 1.5L8 5H2L5 1.5Z" />
<path d="M5 8.5L2 5H8L5 8.5Z" />
</svg>
);
}
if (dir === 'asc') {
return (
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor" className="flex-shrink-0">
<path d="M5 1.5L8 6H2L5 1.5Z" />
</svg>
);
}
return (
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor" className="flex-shrink-0">
<path d="M5 8.5L2 4H8L5 8.5Z" />
</svg>
);
}
// Group by person (all sessions - owned and shared)
const sessionsByPerson = groupByPerson(allSessions);
const sortedPersons = Array.from(sessionsByPerson.entries()).sort((a, b) =>
a[0].localeCompare(b[0], 'fr')
// ─── ViewToggle ───────────────────────────────────────────────────────────────
function ViewToggle({ view, setView }: { view: CardView; setView: (v: CardView) => void }) {
const btn = (v: CardView, label: string, icon: React.ReactNode) => (
<button
key={v}
type="button"
title={label}
onClick={() => setView(v)}
className={`p-1.5 rounded transition-colors ${
view === v ? 'bg-primary text-primary-foreground' : 'text-muted hover:text-foreground'
}`}
>
{icon}
</button>
);
return (
<div className="space-y-6">
{/* Tabs */}
<div className="flex gap-2 border-b border-border pb-4 flex-wrap">
<TabButton
active={activeTab === 'all'}
onClick={() => setActiveTab('all')}
icon="📋"
label="Tous"
count={allSessions.length}
<div className="flex items-center gap-0.5 p-0.5 bg-card border border-border rounded-lg ml-auto flex-shrink-0 shadow-sm">
{btn('grid', 'Grille',
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<rect x="0" y="0" width="4" height="4" rx="0.5" /><rect x="5" y="0" width="4" height="4" rx="0.5" /><rect x="10" y="0" width="4" height="4" rx="0.5" />
<rect x="0" y="5" width="4" height="4" rx="0.5" /><rect x="5" y="5" width="4" height="4" rx="0.5" /><rect x="10" y="5" width="4" height="4" rx="0.5" />
<rect x="0" y="10" width="4" height="4" rx="0.5" /><rect x="5" y="10" width="4" height="4" rx="0.5" /><rect x="10" y="10" width="4" height="4" rx="0.5" />
</svg>
)}
{btn('list', 'Liste',
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<line x1="0" y1="2.5" x2="14" y2="2.5" /><line x1="0" y1="7" x2="14" y2="7" /><line x1="0" y1="11.5" x2="14" y2="11.5" />
</svg>
)}
{btn('table', 'Tableau',
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<rect x="0" y="0" width="14" height="3.5" rx="0.5" />
<rect x="0" y="5" width="6" height="2.5" rx="0.5" /><rect x="8" y="5" width="6" height="2.5" rx="0.5" />
<rect x="0" y="9.5" width="6" height="2.5" rx="0.5" /><rect x="8" y="9.5" width="6" height="2.5" rx="0.5" />
</svg>
)}
{btn('timeline', 'Chronologique',
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<line x1="3" y1="0" x2="3" y2="14" />
<circle cx="3" cy="2.5" r="1.5" fill="currentColor" stroke="none" />
<line x1="5" y1="2.5" x2="14" y2="2.5" />
<circle cx="3" cy="7" r="1.5" fill="currentColor" stroke="none" />
<line x1="5" y1="7" x2="14" y2="7" />
<circle cx="3" cy="11.5" r="1.5" fill="currentColor" stroke="none" />
<line x1="5" y1="11.5" x2="14" y2="11.5" />
</svg>
)}
</div>
);
}
// ─── TabButton ────────────────────────────────────────────────────────────────
function TabButton({ active, onClick, icon, label, count }: {
active: boolean; onClick: () => void; icon: string; label: string; count: number;
}) {
return (
<button type="button" onClick={onClick}
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-full font-medium text-sm transition-all duration-150 shadow-sm ${
active
? 'bg-primary text-primary-foreground shadow-md'
: 'bg-card text-foreground/70 border border-border hover:text-foreground hover:bg-card-hover'
}`}
>
<span>{icon}</span>
<span>{label}</span>
<span className={`text-[11px] font-semibold px-1.5 py-0.5 rounded-full ${active ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}>
{count}
</span>
</button>
);
}
// ─── TypeFilterDropdown ───────────────────────────────────────────────────────
function TypeFilterDropdown({
activeTab, setActiveTab, open, onOpenChange, counts,
}: {
activeTab: WorkshopTabType; setActiveTab: (t: WorkshopTabType) => void;
open: boolean; onOpenChange: (v: boolean) => void; counts: Record<string, number>;
}) {
const typeTabs = TYPE_TABS.filter((t) => t.value !== 'all' && t.value !== 'team');
const current = TYPE_TABS.find((t) => t.value === activeTab) ?? TYPE_TABS[0];
const isTypeSelected = activeTab !== 'all' && activeTab !== 'byPerson' && activeTab !== 'team';
const totalCount = typeTabs.reduce((s, t) => s + (counts[t.value] ?? 0), 0);
const containerRef = useRef<HTMLDivElement>(null);
useClickOutside(containerRef, () => onOpenChange(false), open);
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => onOpenChange(!open)}
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-full font-medium text-sm transition-all duration-150 shadow-sm ${
isTypeSelected
? 'bg-primary text-primary-foreground shadow-md'
: 'bg-card text-foreground/70 border border-border hover:text-foreground hover:bg-card-hover'
}`}
>
<span>{isTypeSelected ? current.icon : '🔖'}</span>
<span>{isTypeSelected ? current.label : 'Type'}</span>
<span className={`text-[11px] font-semibold px-1.5 py-0.5 rounded-full ${isTypeSelected ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}>
{isTypeSelected ? (counts[activeTab] ?? 0) : totalCount}
</span>
<svg className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute left-0 z-20 mt-2 w-48 rounded-xl border border-border bg-card py-1.5 shadow-lg">
<button type="button" onClick={() => { setActiveTab('all'); onOpenChange(false); }}
className="flex w-full items-center justify-between gap-2 px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover border-b border-border transition-colors">
<span className="flex items-center gap-2"><span>📋</span><span>Tous les types</span></span>
<span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">{totalCount}</span>
</button>
{typeTabs.map((t) => (
<button key={t.value} type="button" onClick={() => { setActiveTab(t.value); onOpenChange(false); }}
className="flex w-full items-center justify-between gap-2 px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover transition-colors">
<span className="flex items-center gap-2"><span>{t.icon}</span><span>{t.label}</span></span>
<span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">{counts[t.value] ?? 0}</span>
</button>
))}
</div>
)}
</div>
);
}
// ─── SessionsGrid ─────────────────────────────────────────────────────────────
function SessionsGrid({
sessions, view, isTeamCollab = false,
}: {
sessions: AnySession[]; view: CardView; isTeamCollab?: boolean;
}) {
if (view === 'table') {
return (
<div className="rounded-xl border border-border overflow-hidden overflow-x-auto bg-card">
<div className="grid text-[11px] font-semibold text-muted uppercase tracking-wider bg-card-hover/60 border-b border-border" style={{ gridTemplateColumns: TABLE_COLS }}>
{SORT_COLUMNS.map((col) => (
<div key={col.key} className="px-4 py-2.5">{col.label}</div>
))}
</div>
{sessions.map((s) => (
<SessionCard key={s.id} session={s} isTeamCollab={isTeamCollab} view="table" />
))}
</div>
);
}
return (
<div className={view === 'list' ? 'flex flex-col gap-2' : 'grid gap-4 md:grid-cols-2 lg:grid-cols-3'}>
{sessions.map((s) => (
<SessionCard key={s.id} session={s} isTeamCollab={isTeamCollab} view={view} />
))}
</div>
);
}
// ─── SortableTableView ────────────────────────────────────────────────────────
function SortableTableView({
sessions, sortCol, sortDir, onSort,
}: {
sessions: AnySession[];
sortCol: SortCol;
sortDir: 'asc' | 'desc';
onSort: (col: SortCol) => void;
}) {
if (sessions.length === 0) {
return <div className="text-center py-12 text-muted">Aucun atelier pour le moment</div>;
}
return (
<div className="rounded-xl border border-border overflow-hidden overflow-x-auto bg-card">
<div className="grid bg-card-hover/60 border-b border-border" style={{ gridTemplateColumns: TABLE_COLS }}>
{SORT_COLUMNS.map((col) => (
<button
key={col.key}
type="button"
onClick={() => onSort(col.key)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-left text-[11px] font-semibold uppercase tracking-wider transition-colors hover:text-foreground ${
sortCol === col.key ? 'text-primary' : 'text-muted'
}`}
>
{col.label}
<SortIcon active={sortCol === col.key} dir={sortDir} />
</button>
))}
</div>
{sessions.map((s) => (
<SessionCard
key={s.id}
session={s}
view="table"
isTeamCollab={(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab}
/>
<TabButton
active={activeTab === 'byPerson'}
onClick={() => setActiveTab('byPerson')}
icon="👥"
label="Par personne"
count={sessionsByPerson.size}
/>
<TabButton
active={activeTab === 'swot'}
onClick={() => setActiveTab('swot')}
icon="📊"
label="SWOT"
count={swotSessions.length}
/>
<TabButton
active={activeTab === 'motivators'}
onClick={() => setActiveTab('motivators')}
icon="🎯"
label="Moving Motivators"
count={motivatorSessions.length}
))}
</div>
);
}
// ─── WorkshopTabs ─────────────────────────────────────────────────────────────
export function WorkshopTabs({
swotSessions, motivatorSessions, yearReviewSessions,
weeklyCheckInSessions, weatherSessions, gifMoodSessions,
teamCollabSessions = [],
}: WorkshopTabsProps) {
const searchParams = useSearchParams();
const router = useRouter();
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false);
const [cardView, setCardView] = useState<CardView>('grid');
const [sortCol, setSortCol] = useState<SortCol>('date');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const handleSort = (col: SortCol) => {
if (sortCol === col) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
else { setSortCol(col); setSortDir('asc'); }
};
const tabParam = searchParams.get('tab');
const activeTab: WorkshopTabType =
tabParam && VALID_TAB_PARAMS.includes(tabParam as WorkshopTabType)
? (tabParam as WorkshopTabType)
: 'all';
const setActiveTab = (tab: WorkshopTabType) => {
const params = new URLSearchParams(searchParams.toString());
if (tab === 'all') params.delete('tab');
else params.set('tab', tab);
router.push(`/sessions${params.toString() ? `?${params.toString()}` : ''}`);
};
const allSessions: AnySession[] = [
...swotSessions, ...motivatorSessions, ...yearReviewSessions,
...weeklyCheckInSessions, ...weatherSessions, ...gifMoodSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const filteredSessions: AnySession[] =
activeTab === 'all' || activeTab === 'byPerson' ? allSessions
: activeTab === 'team' ? teamCollabSessions
: activeTab === 'swot' ? swotSessions
: activeTab === 'motivators' ? motivatorSessions
: activeTab === 'year-review' ? yearReviewSessions
: activeTab === 'weekly-checkin' ? weeklyCheckInSessions
: activeTab === 'gif-mood' ? gifMoodSessions
: weatherSessions;
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
const sharedSessions = filteredSessions.filter(
(s) => !s.isOwner && !(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab
);
const teamCollabFiltered = activeTab === 'all' ? teamCollabSessions : [];
const sessionsByPerson = groupByPerson(allSessions);
const sortedPersons = Array.from(sessionsByPerson.entries()).sort((a, b) => a[0].localeCompare(b[0], 'fr'));
// Timeline grouping
const timelineSessions = [...(activeTab === 'all' ? [...filteredSessions, ...teamCollabSessions] : filteredSessions)]
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const byMonth = new Map<string, AnySession[]>();
timelineSessions.forEach((s) => {
const key = getMonthGroup(s.updatedAt);
if (!byMonth.has(key)) byMonth.set(key, []);
byMonth.get(key)!.push(s);
});
// Flat sorted sessions for the sortable table view
const flatTableSessions =
cardView === 'table' && activeTab !== 'byPerson'
? sortSessions(
activeTab === 'all' ? [...filteredSessions, ...teamCollabSessions] : filteredSessions,
sortCol, sortDir,
)
: [];
return (
<div className="space-y-8">
{/* Tabs + vue toggle */}
<div className="flex gap-1.5 items-center flex-wrap">
<TabButton active={activeTab === 'all'} onClick={() => setActiveTab('all')} icon="📋" label="Tous" count={allSessions.length} />
<TabButton active={activeTab === 'byPerson'} onClick={() => setActiveTab('byPerson')} icon="👥" label="Par personne" count={sessionsByPerson.size} />
{teamCollabSessions.length > 0 && (
<TabButton active={activeTab === 'team'} onClick={() => setActiveTab('team')} icon="🏢" label="Équipe" count={teamCollabSessions.length} />
)}
<div className="h-5 w-px bg-border mx-0.5 self-center" />
<TypeFilterDropdown
activeTab={activeTab} setActiveTab={setActiveTab}
open={typeDropdownOpen} onOpenChange={setTypeDropdownOpen}
counts={{
swot: swotSessions.length, motivators: motivatorSessions.length,
'year-review': yearReviewSessions.length, 'weekly-checkin': weeklyCheckInSessions.length,
weather: weatherSessions.length, 'gif-mood': gifMoodSessions.length,
team: teamCollabSessions.length,
}}
/>
<ViewToggle view={cardView} setView={setCardView} />
</div>
{/* Sessions */}
{activeTab === 'byPerson' ? (
// By Person View
{/* ── Vue Tableau flat (colonnes triables) ──────────────────── */}
{cardView === 'table' && activeTab !== 'byPerson' ? (
<SortableTableView sessions={flatTableSessions} sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
) : cardView === 'timeline' && activeTab !== 'byPerson' ? (
/* ── Vue Timeline ────────────────────────────────────────── */
byMonth.size === 0 ? (
<div className="text-center py-12 text-muted">Aucun atelier pour le moment</div>
) : (
<div className="space-y-8">
{Array.from(byMonth.entries()).map(([period, sessions]) => (
<section key={period}>
<div className="flex items-center gap-3 mb-4">
<div className="h-px flex-1 bg-border" />
<span className="text-xs font-semibold text-muted uppercase tracking-widest px-2 capitalize">{period}</span>
<div className="h-px flex-1 bg-border" />
</div>
<div className="flex flex-col gap-2">
{sessions.map((s) => (
<SessionCard key={s.id} session={s} isTeamCollab={(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab} view="list" />
))}
</div>
</section>
))}
</div>
)
) : activeTab === 'byPerson' ? (
/* ── Vue Par personne ───────────────────────────────────── */
sortedPersons.length === 0 ? (
<div className="text-center py-12 text-muted">Aucun atelier pour le moment</div>
) : (
@@ -219,51 +390,58 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
const resolved = getResolvedCollaborator(sessions[0]);
return (
<section key={personKey}>
<h2 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-3">
<div className="flex items-center gap-3 mb-5">
<CollaboratorDisplay collaborator={resolved} size="md" />
<Badge variant="primary">
<span className="inline-flex items-center justify-center h-5 min-w-[20px] px-1.5 rounded-full bg-primary/10 text-primary text-[11px] font-semibold">
{sessions.length} atelier{sessions.length > 1 ? 's' : ''}
</Badge>
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</span>
</div>
<SessionsGrid sessions={sessions} view={cardView === 'timeline' ? 'list' : cardView} />
</section>
);
})}
</div>
)
) : activeTab === 'team' ? (
/* ── Vue Équipe ─────────────────────────────────────────── */
teamCollabSessions.length === 0 ? (
<div className="text-center py-12 text-muted">Aucun atelier de vos collaborateurs (non partagés)</div>
) : (
<div className="space-y-8">
<section>
<SectionHeader label="Ateliers de l'équipe non partagés" count={teamCollabSessions.length} />
<p className="text-sm text-muted mb-5 -mt-2">
En tant qu&apos;admin d&apos;équipe, vous voyez les ateliers de vos collaborateurs
qui ne vous sont pas encore partagés.
</p>
<SessionsGrid sessions={teamCollabSessions} view={cardView} isTeamCollab />
</section>
</div>
)
) : filteredSessions.length === 0 ? (
<div className="text-center py-12 text-muted">Aucun atelier de ce type pour le moment</div>
) : (
<div className="space-y-8">
{/* My Sessions */}
/* ── Vue normale (tous / par type) ─────────────────────── */
<div className="space-y-10">
{ownedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
📁 Mes ateliers ({ownedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{ownedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
<SectionHeader label="Mes ateliers" count={ownedSessions.length} />
<SessionsGrid sessions={ownedSessions} view={cardView} />
</section>
)}
{/* Shared Sessions */}
{sharedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
🤝 Partagés avec moi ({sharedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sharedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
<SectionHeader label="Partagés avec moi" count={sharedSessions.length} />
<SessionsGrid sessions={sharedSessions} view={cardView} />
</section>
)}
{activeTab === 'all' && teamCollabFiltered.length > 0 && (
<section>
<SectionHeader label="Équipe non partagés" count={teamCollabFiltered.length} />
<SessionsGrid sessions={teamCollabFiltered} view={cardView} isTeamCollab />
</section>
)}
</div>
@@ -271,317 +449,3 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
</div>
);
}
function TabButton({
active,
onClick,
icon,
label,
count,
}: {
active: boolean;
onClick: () => void;
icon: string;
label: string;
count: number;
}) {
return (
<button
onClick={onClick}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors
${
active
? 'bg-primary text-primary-foreground'
: 'text-muted hover:bg-card-hover hover:text-foreground'
}
`}
>
<span>{icon}</span>
<span>{label}</span>
<Badge variant={active ? 'default' : 'primary'} className="ml-1">
{count}
</Badge>
</button>
);
}
function SessionCard({ session }: { session: AnySession }) {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [isPending, startTransition] = useTransition();
// Edit form state
const [editTitle, setEditTitle] = useState(session.title);
const [editParticipant, setEditParticipant] = useState(
session.workshopType === 'swot'
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant
);
const isSwot = session.workshopType === 'swot';
const href = isSwot ? `/sessions/${session.id}` : `/motivators/${session.id}`;
const icon = isSwot ? '📊' : '🎯';
const participant = isSwot
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant;
const accentColor = isSwot ? '#06b6d4' : '#8b5cf6';
const handleDelete = () => {
startTransition(async () => {
const result = isSwot
? await deleteSwotSession(session.id)
: await deleteMotivatorSession(session.id);
if (result.success) {
setShowDeleteModal(false);
} else {
console.error('Error deleting session:', result.error);
}
});
};
const handleEdit = () => {
startTransition(async () => {
const result = isSwot
? await updateSwotSession(session.id, { title: editTitle, collaborator: editParticipant })
: await updateMotivatorSession(session.id, {
title: editTitle,
participant: editParticipant,
});
if (result.success) {
setShowEditModal(false);
} else {
console.error('Error updating session:', result.error);
}
});
};
const openEditModal = () => {
// Reset form values when opening
setEditTitle(session.title);
setEditParticipant(participant);
setShowEditModal(true);
};
return (
<>
<div className="relative group">
<Link href={href}>
<Card hover className="h-full p-4 relative overflow-hidden">
{/* Accent bar */}
<div
className="absolute top-0 left-0 right-0 h-1"
style={{ backgroundColor: accentColor }}
/>
{/* Header: Icon + Title + Role badge */}
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">{icon}</span>
<h3 className="font-semibold text-foreground line-clamp-1 flex-1">{session.title}</h3>
{!session.isOwner && (
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor:
session.role === 'EDITOR' ? 'rgba(6,182,212,0.1)' : 'rgba(234,179,8,0.1)',
color: session.role === 'EDITOR' ? '#06b6d4' : '#eab308',
}}
>
{session.role === 'EDITOR' ? '✏️' : '👁️'}
</span>
)}
</div>
{/* Participant + Owner info */}
<div className="mb-3 flex items-center gap-2">
<CollaboratorDisplay collaborator={getResolvedCollaborator(session)} size="sm" />
{!session.isOwner && (
<span className="text-xs text-muted">
· par {session.user.name || session.user.email}
</span>
)}
</div>
{/* Footer: Stats + Avatars + Date */}
<div className="flex items-center justify-between text-xs">
{/* Stats */}
<div className="flex items-center gap-2 text-muted">
{isSwot ? (
<>
<span>{(session as SwotSession)._count.items} items</span>
<span>·</span>
<span>{(session as SwotSession)._count.actions} actions</span>
</>
) : (
<span>{(session as MotivatorSession)._count.cards}/10</span>
)}
</div>
{/* Date */}
<span className="text-muted">
{new Date(session.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</div>
{/* Shared with */}
{session.isOwner && session.shares.length > 0 && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
<span className="text-[10px] text-muted uppercase tracking-wide">Partagé</span>
<div className="flex flex-wrap gap-1.5">
{session.shares.slice(0, 3).map((share) => (
<div
key={share.id}
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-primary/10 text-[10px] text-primary"
title={share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
>
<span className="font-medium">
{share.user.name?.split(' ')[0] || share.user.email.split('@')[0]}
</span>
<span>{share.role === 'EDITOR' ? '✏️' : '👁️'}</span>
</div>
))}
{session.shares.length > 3 && (
<span className="text-[10px] text-muted">+{session.shares.length - 3}</span>
)}
</div>
</div>
)}
</Card>
</Link>
{/* Action buttons - only for owner */}
{session.isOwner && (
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openEditModal();
}}
className="p-1.5 rounded-lg bg-primary/10 text-primary hover:bg-primary/20"
title="Modifier"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowDeleteModal(true);
}}
className="p-1.5 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive/20"
title="Supprimer"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
)}
</div>
{/* Edit modal */}
<Modal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
title="Modifier l'atelier"
size="sm"
>
<form
onSubmit={(e) => {
e.preventDefault();
handleEdit();
}}
className="space-y-4"
>
<div>
<label htmlFor="edit-title" className="block text-sm font-medium text-foreground mb-1">
Titre
</label>
<Input
id="edit-title"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="Titre de l'atelier"
required
/>
</div>
<div>
<label
htmlFor="edit-participant"
className="block text-sm font-medium text-foreground mb-1"
>
{isSwot ? 'Collaborateur' : 'Participant'}
</label>
<Input
id="edit-participant"
value={editParticipant}
onChange={(e) => setEditParticipant(e.target.value)}
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'}
required
/>
</div>
<ModalFooter>
<Button
type="button"
variant="ghost"
onClick={() => setShowEditModal(false)}
disabled={isPending}
>
Annuler
</Button>
<Button
type="submit"
disabled={isPending || !editTitle.trim() || !editParticipant.trim()}
>
{isPending ? 'Enregistrement...' : 'Enregistrer'}
</Button>
</ModalFooter>
</form>
</Modal>
{/* Delete confirmation modal */}
<Modal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
title="Supprimer l'atelier"
size="sm"
>
<div className="space-y-4">
<p className="text-muted">
Êtes-vous sûr de vouloir supprimer l&apos;atelier{' '}
<strong className="text-foreground">&quot;{session.title}&quot;</strong> ?
</p>
<p className="text-sm text-destructive">
Cette action est irréversible. Toutes les données seront perdues.
</p>
<ModalFooter>
<Button variant="ghost" onClick={() => setShowDeleteModal(false)} disabled={isPending}>
Annuler
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isPending}>
{isPending ? 'Suppression...' : 'Supprimer'}
</Button>
</ModalFooter>
</div>
</Modal>
</>
);
}

View File

@@ -1,11 +1,10 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getSessionById } from '@/services/sessions';
import { getUserTeams } from '@/services/teams';
import { SwotBoard } from '@/components/swot/SwotBoard';
import { SessionLiveWrapper } from '@/components/collaboration';
import { EditableTitle } from '@/components/session';
import { Badge, CollaboratorDisplay } from '@/components/ui';
import { Badge, SessionPageHeader } from '@/components/ui';
interface SessionPageProps {
params: Promise<{ id: string }>;
@@ -19,57 +18,31 @@ export default async function SessionPage({ params }: SessionPageProps) {
return null;
}
const session = await getSessionById(id, authSession.user.id);
const [session, userTeams] = await Promise.all([
getSessionById(id, authSession.user.id),
getUserTeams(authSession.user.id),
]);
if (!session) {
notFound();
}
return (
<main className="mx-auto max-w-7xl px-4 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-muted mb-2">
<Link href="/sessions?tab=swot" className="hover:text-foreground">
SWOT
</Link>
<span>/</span>
<span className="text-foreground">{session.title}</span>
{!session.isOwner && (
<Badge variant="accent" className="ml-2">
Partagé par {session.user.name || session.user.email}
</Badge>
)}
</div>
<div className="flex items-start justify-between">
<div>
<EditableTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
/>
<div className="mt-2">
<CollaboratorDisplay
collaborator={session.resolvedCollaborator}
size="lg"
showEmail
/>
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary">{session.items.length} items</Badge>
<Badge variant="success">{session.actions.length} actions</Badge>
<span className="text-sm text-muted">
{new Date(session.date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
<main className="mx-auto max-w-7xl px-4">
<SessionPageHeader
workshopType="swot"
sessionId={session.id}
sessionTitle={session.title}
isOwner={session.isOwner}
canEdit={session.canEdit}
ownerUser={session.user}
date={session.date}
collaborator={session.resolvedCollaborator}
badges={<>
<Badge variant="primary">{session.items.length} items</Badge>
<Badge variant="success">{session.actions.length} actions</Badge>
</>}
/>
{/* Live Session Wrapper */}
<SessionLiveWrapper
@@ -79,6 +52,7 @@ export default async function SessionPage({ params }: SessionPageProps) {
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
userTeams={userTeams}
>
<SwotBoard sessionId={session.id} items={session.items} actions={session.actions} />
</SessionLiveWrapper>

View File

@@ -10,6 +10,7 @@ import {
CardContent,
Button,
Input,
ParticipantInput,
} from '@/components/ui';
export default function NewSessionPage() {
@@ -55,7 +56,7 @@ export default function NewSessionPage() {
}
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<main className="mx-auto max-w-2xl px-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -82,12 +83,7 @@ export default function NewSessionPage() {
required
/>
<Input
label="Nom du collaborateur"
name="collaborator"
placeholder="Ex: Jean Dupont"
required
/>
<ParticipantInput name="collaborator" required />
<div className="flex gap-3 pt-4">
<Button

View File

@@ -1,24 +1,47 @@
import { Suspense } from 'react';
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getSessionsByUserId } from '@/services/sessions';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
import { Card, Button } from '@/components/ui';
import {
getSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamSwotSessions,
} from '@/services/sessions';
import {
getMotivatorSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamMotivatorSessions,
} from '@/services/moving-motivators';
import {
getYearReviewSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamYearReviewSessions,
} from '@/services/year-review';
import {
getWeeklyCheckInSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamWeeklyCheckInSessions,
} from '@/services/weekly-checkin';
import {
getWeatherSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamWeatherSessions,
} from '@/services/weather';
import {
getGifMoodSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamGifMoodSessions,
} from '@/services/gif-mood';
import { Card, PageHeader } from '@/components/ui';
import { withWorkshopType } from '@/lib/workshops';
import { WorkshopTabs } from './WorkshopTabs';
import { NewWorkshopDropdown } from './NewWorkshopDropdown';
function WorkshopTabsSkeleton() {
return (
<div className="space-y-6">
{/* Tabs skeleton */}
<div className="flex gap-2 border-b border-border pb-4">
<div className="flex gap-2 pb-2">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-10 w-32 bg-card animate-pulse rounded-lg" />
<div key={i} className="h-9 w-28 bg-card animate-pulse rounded-full" />
))}
</div>
{/* Cards skeleton */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-40 bg-card animate-pulse rounded-xl" />
<div key={i} className="h-44 bg-card animate-pulse rounded-xl" />
))}
</div>
</div>
@@ -32,53 +55,75 @@ export default async function SessionsPage() {
return null;
}
// Fetch both SWOT and Moving Motivators sessions
const [swotSessions, motivatorSessions] = await Promise.all([
// Fetch sessions (owned + shared) and team collab sessions (for team admins, non-shared)
const [
swotSessions,
motivatorSessions,
yearReviewSessions,
weeklyCheckInSessions,
weatherSessions,
gifMoodSessions,
teamSwotSessions,
teamMotivatorSessions,
teamYearReviewSessions,
teamWeeklyCheckInSessions,
teamWeatherSessions,
teamGifMoodSessions,
] = await Promise.all([
getSessionsByUserId(session.user.id),
getMotivatorSessionsByUserId(session.user.id),
getYearReviewSessionsByUserId(session.user.id),
getWeeklyCheckInSessionsByUserId(session.user.id),
getWeatherSessionsByUserId(session.user.id),
getGifMoodSessionsByUserId(session.user.id),
getTeamSwotSessions(session.user.id),
getTeamMotivatorSessions(session.user.id),
getTeamYearReviewSessions(session.user.id),
getTeamWeeklyCheckInSessions(session.user.id),
getTeamWeatherSessions(session.user.id),
getTeamGifMoodSessions(session.user.id),
]);
// Add type to each session for unified display
const allSwotSessions = swotSessions.map((s) => ({
...s,
workshopType: 'swot' as const,
}));
// Add workshopType to each session for unified display
const allSwotSessions = withWorkshopType(swotSessions, 'swot');
const allMotivatorSessions = withWorkshopType(motivatorSessions, 'motivators');
const allYearReviewSessions = withWorkshopType(yearReviewSessions, 'year-review');
const allWeeklyCheckInSessions = withWorkshopType(weeklyCheckInSessions, 'weekly-checkin');
const allWeatherSessions = withWorkshopType(weatherSessions, 'weather');
const allGifMoodSessions = withWorkshopType(gifMoodSessions, 'gif-mood');
const allMotivatorSessions = motivatorSessions.map((s) => ({
...s,
workshopType: 'motivators' as const,
}));
const teamSwotWithType = withWorkshopType(teamSwotSessions, 'swot');
const teamMotivatorWithType = withWorkshopType(teamMotivatorSessions, 'motivators');
const teamYearReviewWithType = withWorkshopType(teamYearReviewSessions, 'year-review');
const teamWeeklyCheckInWithType = withWorkshopType(teamWeeklyCheckInSessions, 'weekly-checkin');
const teamWeatherWithType = withWorkshopType(teamWeatherSessions, 'weather');
const teamGifMoodWithType = withWorkshopType(teamGifMoodSessions, 'gif-mood');
// Combine and sort by updatedAt
const allSessions = [...allSwotSessions, ...allMotivatorSessions].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
const allSessions = [
...allSwotSessions,
...allMotivatorSessions,
...allYearReviewSessions,
...allWeeklyCheckInSessions,
...allWeatherSessions,
...allGifMoodSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const hasNoSessions = allSessions.length === 0;
const totalCount = allSessions.length;
return (
<main className="mx-auto max-w-7xl px-4 py-8">
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-foreground">Mes Ateliers</h1>
<p className="mt-1 text-muted">Tous vos ateliers en un seul endroit</p>
</div>
<div className="flex gap-2">
<Link href="/sessions/new">
<Button variant="outline">
<span>📊</span>
Nouveau SWOT
</Button>
</Link>
<Link href="/motivators/new">
<Button>
<span>🎯</span>
Nouveau Motivators
</Button>
</Link>
</div>
</div>
<main className="mx-auto max-w-7xl px-4">
<PageHeader
emoji="🗂️"
title="Mes Ateliers"
subtitle={
totalCount > 0
? `${totalCount} atelier${totalCount > 1 ? 's' : ''} · Tous vos ateliers en un seul endroit`
: 'Tous vos ateliers en un seul endroit'
}
actions={<NewWorkshopDropdown />}
/>
{/* Content */}
{hasNoSessions ? (
@@ -88,27 +133,32 @@ export default async function SessionsPage() {
Commencez votre premier atelier
</h2>
<p className="text-muted mb-6 max-w-md mx-auto">
Créez un atelier SWOT pour analyser les forces et faiblesses, ou un Moving Motivators
pour découvrir les motivations de vos collaborateurs.
Créez un atelier SWOT pour analyser les forces et faiblesses, un Moving Motivators pour
découvrir les motivations, un Year Review pour faire le bilan de l&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}
gifMoodSessions={allGifMoodSessions}
teamCollabSessions={[
...teamSwotWithType,
...teamMotivatorWithType,
...teamYearReviewWithType,
...teamWeeklyCheckInWithType,
...teamWeatherWithType,
...teamGifMoodWithType,
]}
/>
</Suspense>
)}
</main>

View File

@@ -0,0 +1,93 @@
import type {
AnySession, SortCol, ResolvedCollaborator,
SwotSession, MotivatorSession, YearReviewSession,
WeeklyCheckInSession, WeatherSession, GifMoodSession,
} from './workshop-session-types';
export function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
if (session.workshopType === 'swot') return (session as SwotSession).resolvedCollaborator;
if (session.workshopType === 'year-review') return (session as YearReviewSession).resolvedParticipant;
if (session.workshopType === 'weekly-checkin') return (session as WeeklyCheckInSession).resolvedParticipant;
if (session.workshopType === 'weather') {
const s = session as WeatherSession;
return { raw: s.user.name || s.user.email, matchedUser: { id: s.user.id, email: s.user.email, name: s.user.name } };
}
if (session.workshopType === 'gif-mood') {
const s = session as GifMoodSession;
return { raw: s.user.name || s.user.email, matchedUser: { id: s.user.id, email: s.user.email, name: s.user.name } };
}
return (session as MotivatorSession).resolvedParticipant;
}
export function getGroupKey(session: AnySession): string {
const r = getResolvedCollaborator(session);
return r.matchedUser ? `user:${r.matchedUser.id}` : `raw:${r.raw.trim().toLowerCase()}`;
}
export function groupByPerson(sessions: AnySession[]): Map<string, AnySession[]> {
const grouped = new Map<string, AnySession[]>();
sessions.forEach((s) => {
const key = getGroupKey(s);
const existing = grouped.get(key);
if (existing) existing.push(s);
else grouped.set(key, [s]);
});
grouped.forEach((arr) => arr.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()));
return grouped;
}
export function formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
}
export function getMonthGroup(date: Date | string): string {
return new Date(date).toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
}
export function getStatsText(session: AnySession): string {
const isSwot = session.workshopType === 'swot';
const isYearReview = session.workshopType === 'year-review';
const isWeeklyCheckIn = session.workshopType === 'weekly-checkin';
const isWeather = session.workshopType === 'weather';
const isGifMood = session.workshopType === 'gif-mood';
if (isSwot) return `${(session as SwotSession)._count.items} items · ${(session as SwotSession)._count.actions} actions`;
if (isYearReview) return `${(session as YearReviewSession)._count.items} items · ${(session as YearReviewSession).year}`;
if (isWeeklyCheckIn) return `${(session as WeeklyCheckInSession)._count.items} items · ${formatDate((session as WeeklyCheckInSession).date)}`;
if (isWeather) return `${(session as WeatherSession)._count.entries} membres · ${formatDate((session as WeatherSession).date)}`;
if (isGifMood) return `${(session as GifMoodSession)._count.items} GIFs · ${formatDate((session as GifMoodSession).date)}`;
return `${(session as MotivatorSession)._count.cards}/10 motivateurs`;
}
function getStatsSortValue(session: AnySession): number {
if (session.workshopType === 'swot') return (session as SwotSession)._count.items;
if (session.workshopType === 'year-review') return (session as YearReviewSession)._count.items;
if (session.workshopType === 'weekly-checkin') return (session as WeeklyCheckInSession)._count.items;
if (session.workshopType === 'weather') return (session as WeatherSession)._count.entries;
if (session.workshopType === 'gif-mood') return (session as GifMoodSession)._count.items;
return (session as MotivatorSession)._count.cards;
}
function getParticipantSortName(session: AnySession): string {
const r = getResolvedCollaborator(session);
return (r.matchedUser?.name || r.matchedUser?.email?.split('@')[0] || r.raw).toLowerCase();
}
function getCreatorName(session: AnySession): string {
return (session.user.name || session.user.email).toLowerCase();
}
export function sortSessions(sessions: AnySession[], col: SortCol, dir: 'asc' | 'desc'): AnySession[] {
return [...sessions].sort((a, b) => {
let cmp = 0;
switch (col) {
case 'type': cmp = a.workshopType.localeCompare(b.workshopType); break;
case 'titre': cmp = a.title.localeCompare(b.title, 'fr'); break;
case 'createur': cmp = getCreatorName(a).localeCompare(getCreatorName(b), 'fr'); break;
case 'participant': cmp = getParticipantSortName(a).localeCompare(getParticipantSortName(b), 'fr'); break;
case 'stats': cmp = getStatsSortValue(a) - getStatsSortValue(b); break;
case 'date': cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(); break;
}
return dir === 'asc' ? cmp : -cmp;
});
}

View File

@@ -0,0 +1,94 @@
import { WORKSHOPS } from '@/lib/workshops';
import type { Share } from '@/lib/share-utils';
export type CardView = 'grid' | 'list' | 'table' | 'timeline';
export type SortCol = 'type' | 'titre' | 'createur' | 'participant' | 'stats' | 'date';
// Colonnes tableau : type | titre | créateur | participant | stats | date
export const TABLE_COLS = '160px 1fr 160px 160px 160px 76px';
export const SORT_COLUMNS: { key: SortCol; label: string }[] = [
{ key: 'type', label: 'Type' },
{ key: 'titre', label: 'Titre' },
{ key: 'createur', label: 'Créateur' },
{ key: 'participant', label: 'Participant' },
{ key: 'stats', label: 'Stats' },
{ key: 'date', label: 'Date' },
];
export const TYPE_TABS = [
{ value: 'all' as const, icon: '📋', label: 'Tous' },
{ value: 'team' as const, icon: '🏢', label: 'Équipe' },
...WORKSHOPS.map((w) => ({ value: w.id, icon: w.icon, label: w.labelShort })),
];
export interface ResolvedCollaborator {
raw: string;
matchedUser: { id: string; email: string; name: string | null } | null;
}
export interface SwotSession {
id: string; title: string; collaborator: string;
resolvedCollaborator: ResolvedCollaborator; updatedAt: Date;
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[]; _count: { items: number; actions: number };
workshopType: 'swot'; isTeamCollab?: true; canEdit?: boolean;
}
export interface MotivatorSession {
id: string; title: string; participant: string;
resolvedParticipant: ResolvedCollaborator; updatedAt: Date;
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[]; _count: { cards: number };
workshopType: 'motivators'; isTeamCollab?: true; canEdit?: boolean;
}
export interface YearReviewSession {
id: string; title: string; participant: string;
resolvedParticipant: ResolvedCollaborator; year: number; updatedAt: Date;
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[]; _count: { items: number };
workshopType: 'year-review'; isTeamCollab?: true; canEdit?: boolean;
}
export interface WeeklyCheckInSession {
id: string; title: string; participant: string;
resolvedParticipant: ResolvedCollaborator; date: Date; updatedAt: Date;
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[]; _count: { items: number };
workshopType: 'weekly-checkin'; isTeamCollab?: true; canEdit?: boolean;
}
export interface WeatherSession {
id: string; title: string; date: Date; updatedAt: Date;
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[]; _count: { entries: number };
workshopType: 'weather'; isTeamCollab?: true; canEdit?: boolean;
}
export interface GifMoodSession {
id: string; title: string; date: Date; updatedAt: Date;
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[]; _count: { items: number };
workshopType: 'gif-mood'; isTeamCollab?: true; canEdit?: boolean;
}
export type AnySession =
| SwotSession | MotivatorSession | YearReviewSession
| WeeklyCheckInSession | WeatherSession | GifMoodSession;
export interface WorkshopTabsProps {
swotSessions: SwotSession[];
motivatorSessions: MotivatorSession[];
yearReviewSessions: YearReviewSession[];
weeklyCheckInSessions: WeeklyCheckInSession[];
weatherSessions: WeatherSession[];
gifMoodSessions: GifMoodSession[];
teamCollabSessions?: (AnySession & { isTeamCollab?: true })[];
}

View File

@@ -0,0 +1,168 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import { OKRForm } from '@/components/okrs';
import { Card } from '@/components/ui';
import type { CreateOKRInput, CreateKeyResultInput, TeamMember, OKR, KeyResult } from '@/lib/types';
type OKRWithTeamMember = OKR & {
teamMember: {
user: {
id: string;
email: string;
name: string | null;
};
userId: string;
team: {
id: string;
name: string;
};
};
};
export default function EditOKRPage() {
const router = useRouter();
const params = useParams();
const teamId = params.id as string;
const okrId = params.okrId as string;
const [okr, setOkr] = useState<OKRWithTeamMember | null>(null);
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch OKR and team members in parallel
Promise.all([
fetch(`/api/okrs/${okrId}`).then((res) => {
if (!res.ok) {
throw new Error('OKR not found');
}
return res.json();
}),
fetch(`/api/teams/${teamId}`).then((res) => res.json()),
])
.then(([okrData, teamData]) => {
setOkr(okrData);
setTeamMembers(teamData.members || []);
})
.catch((error) => {
console.error('Error fetching data:', error);
})
.finally(() => {
setLoading(false);
});
}, [okrId, teamId]);
type KeyResultUpdate = {
id: string;
title?: string;
targetValue?: number;
unit?: string;
order?: number;
};
const handleSubmit = async (
data: CreateOKRInput & {
startDate: Date | string;
endDate: Date | string;
keyResultsUpdates?: {
create?: CreateKeyResultInput[];
update?: KeyResultUpdate[];
delete?: string[];
};
}
) => {
// Convert to UpdateOKRInput format
const updateData = {
objective: data.objective,
description: data.description || undefined,
period: data.period,
startDate: typeof data.startDate === 'string' ? new Date(data.startDate) : data.startDate,
endDate: typeof data.endDate === 'string' ? new Date(data.endDate) : data.endDate,
};
const payload: {
objective: string;
description?: string;
period: string;
startDate: string;
endDate: string;
keyResultsUpdates?: {
create?: CreateKeyResultInput[];
update?: KeyResultUpdate[];
delete?: string[];
};
} = {
...updateData,
startDate: updateData.startDate.toISOString(),
endDate: updateData.endDate.toISOString(),
};
// Add Key Results updates if in edit mode
if (data.keyResultsUpdates) {
payload.keyResultsUpdates = data.keyResultsUpdates;
}
const response = await fetch(`/api/okrs/${okrId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erreur lors de la mise à jour de l&apos;OKR');
}
router.push(`/teams/${teamId}/okrs/${okrId}`);
router.refresh();
};
if (loading) {
return (
<main className="mx-auto max-w-4xl px-4">
<div className="text-center">Chargement...</div>
</main>
);
}
if (!okr) {
return (
<main className="mx-auto max-w-4xl px-4">
<div className="text-center">OKR non trouvé</div>
</main>
);
}
// Prepare initial data for the form
const initialData: Partial<CreateOKRInput> & { keyResults?: KeyResult[] } = {
teamMemberId: okr.teamMemberId,
objective: okr.objective,
description: okr.description || undefined,
period: okr.period,
startDate: okr.startDate,
endDate: okr.endDate,
keyResults: okr.keyResults || [],
};
return (
<main className="mx-auto max-w-4xl px-4">
<div className="mb-6">
<Link href={`/teams/${teamId}/okrs/${okrId}`} className="text-muted hover:text-foreground">
Retour à l&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,276 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import { KeyResultItem } from '@/components/okrs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
import { Button } from '@/components/ui';
import { Badge } from '@/components/ui';
import { getGravatarUrl } from '@/lib/gravatar';
import type { OKR, OKRStatus } from '@/lib/types';
import { OKR_STATUS_LABELS } from '@/lib/types';
// Helper function for OKR status colors
function getOKRStatusColor(status: OKRStatus): { bg: string; color: string } {
switch (status) {
case 'NOT_STARTED':
return {
bg: 'color-mix(in srgb, #6b7280 15%, transparent)', // gray-500
color: '#6b7280',
};
case 'IN_PROGRESS':
return {
bg: 'color-mix(in srgb, #3b82f6 15%, transparent)', // blue-500
color: '#3b82f6',
};
case 'COMPLETED':
return {
bg: 'color-mix(in srgb, #10b981 15%, transparent)', // green-500
color: '#10b981',
};
case 'CANCELLED':
return {
bg: 'color-mix(in srgb, #ef4444 15%, transparent)', // red-500
color: '#ef4444',
};
default:
return {
bg: 'color-mix(in srgb, #6b7280 15%, transparent)',
color: '#6b7280',
};
}
}
type OKRWithTeamMember = OKR & {
teamMember: {
user: {
id: string;
email: string;
name: string | null;
};
userId: string;
team: {
id: string;
name: string;
};
};
permissions?: {
isAdmin: boolean;
isConcernedMember: boolean;
canEdit: boolean;
canDelete: boolean;
};
};
export default function OKRDetailPage() {
const router = useRouter();
const params = useParams();
const teamId = params.id as string;
const okrId = params.okrId as string;
const [okr, setOkr] = useState<OKRWithTeamMember | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch OKR
fetch(`/api/okrs/${okrId}`)
.then((res) => {
if (!res.ok) {
throw new Error('OKR not found');
}
return res.json();
})
.then((data) => {
setOkr(data);
})
.catch((error) => {
console.error('Error fetching OKR:', error);
})
.finally(() => {
setLoading(false);
});
}, [okrId]);
const handleKeyResultUpdate = () => {
// Refresh OKR data
fetch(`/api/okrs/${okrId}`)
.then((res) => res.json())
.then((data) => {
setOkr(data);
})
.catch((error) => {
console.error('Error refreshing OKR:', error);
});
};
const handleDelete = async () => {
if (!confirm('Êtes-vous sûr de vouloir supprimer cet OKR ?')) {
return;
}
const response = await fetch(`/api/okrs/${okrId}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await response.json();
alert(error.error || 'Erreur lors de la suppression');
return;
}
router.push(`/teams/${teamId}`);
router.refresh();
};
if (loading) {
return (
<main className="mx-auto max-w-4xl px-4">
<div className="text-center">Chargement...</div>
</main>
);
}
if (!okr) {
return (
<main className="mx-auto max-w-4xl px-4">
<div className="text-center">OKR non trouvé</div>
</main>
);
}
const progress = okr.progress || 0;
const progressColor =
progress >= 75 ? 'var(--success)' : progress >= 25 ? 'var(--accent)' : 'var(--destructive)';
const canEdit = okr.permissions?.canEdit ?? false;
const canDelete = okr.permissions?.canDelete ?? false;
return (
<main className="mx-auto max-w-4xl px-4">
<div className="mb-6">
<Link href={`/teams/${teamId}`} className="text-muted hover:text-foreground">
Retour à l&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,81 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import { OKRForm } from '@/components/okrs';
import { Card } from '@/components/ui';
import type { CreateOKRInput, TeamMember } from '@/lib/types';
export default function NewOKRPage() {
const router = useRouter();
const params = useParams();
const teamId = params.id as string;
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch team members
fetch(`/api/teams/${teamId}`)
.then((res) => res.json())
.then((data) => {
setTeamMembers(data.members || []);
})
.catch((error) => {
console.error('Error fetching team:', error);
})
.finally(() => {
setLoading(false);
});
}, [teamId]);
const handleSubmit = async (data: CreateOKRInput) => {
// Ensure dates are properly serialized
const payload = {
...data,
startDate: typeof data.startDate === 'string' ? data.startDate : data.startDate.toISOString(),
endDate: typeof data.endDate === 'string' ? data.endDate : data.endDate.toISOString(),
};
const response = await fetch('/api/okrs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erreur lors de la création de l&apos;OKR');
}
router.push(`/teams/${teamId}`);
router.refresh();
};
if (loading) {
return (
<main className="mx-auto max-w-4xl px-4">
<div className="text-center">Chargement...</div>
</main>
);
}
return (
<main className="mx-auto max-w-4xl px-4">
<div className="mb-6">
<Link href={`/teams/${teamId}`} className="text-muted hover:text-foreground">
Retour à l&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,78 @@
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { getTeam, isTeamAdmin } from '@/services/teams';
import { getTeamOKRs } from '@/services/okrs';
import { TeamDetailClient } from '@/components/teams/TeamDetailClient';
import { DeleteTeamButton } from '@/components/teams/DeleteTeamButton';
import { OKRsList } from '@/components/okrs';
import { Button, Card, PageHeader } from '@/components/ui';
import { notFound } from 'next/navigation';
import type { TeamMember } from '@/lib/types';
interface TeamDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function TeamDetailPage({ params }: TeamDetailPageProps) {
const { id } = await params;
const session = await auth();
if (!session?.user?.id) {
redirect('/login');
}
const team = await getTeam(id);
if (!team) {
notFound();
}
// Check if user is a member
const isMember = team.members.some((m) => m.userId === session.user?.id);
if (!isMember) {
redirect('/teams');
}
const isAdmin = await isTeamAdmin(id, session.user.id);
const okrsData = await getTeamOKRs(id);
return (
<main className="mx-auto max-w-7xl px-4">
<div className="mb-2">
<Link href="/teams" className="text-sm text-muted hover:text-foreground">
Retour aux équipes
</Link>
</div>
<PageHeader
emoji="👥"
title={team.name}
subtitle={team.description ?? undefined}
actions={
isAdmin ? (
<div className="flex items-center gap-3">
<Link href={`/teams/${id}/okrs/new`}>
<Button variant="brand" size="sm">
Définir un OKR
</Button>
</Link>
<DeleteTeamButton teamId={id} teamName={team.name} />
</div>
) : undefined
}
/>
{/* Members Section */}
<Card className="mb-8 p-6">
<TeamDetailClient
members={team.members as unknown as TeamMember[]}
teamId={id}
isAdmin={isAdmin}
/>
</Card>
{/* OKRs Section */}
<OKRsList okrsData={okrsData} teamId={id} isAdmin={isAdmin} />
</main>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Input } from '@/components/ui';
import { Textarea } from '@/components/ui';
import { Button } from '@/components/ui';
import { Card } from '@/components/ui';
export default function NewTeamPage() {
const router = useRouter();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
alert("Le nom de l'équipe est requis");
return;
}
setSubmitting(true);
try {
const response = await fetch('/api/teams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), description: description.trim() || null }),
});
if (!response.ok) {
const error = await response.json();
alert(error.error || "Erreur lors de la création de l'équipe");
return;
}
const team = await response.json();
router.push(`/teams/${team.id}`);
router.refresh();
} catch (error) {
console.error('Error creating team:', error);
alert("Erreur lors de la création de l'équipe");
} finally {
setSubmitting(false);
}
};
return (
<main className="mx-auto max-w-2xl px-4">
<div className="mb-6">
<Link href="/teams" className="text-muted hover:text-foreground">
Retour aux équipes
</Link>
</div>
<Card className="p-6">
<h1 className="text-2xl font-bold text-foreground mb-6">Créer une équipe</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Nom de l'équipe *"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ex: Équipe Produit"
required
/>
<Textarea
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description de l'équipe..."
rows={3}
/>
<div className="flex justify-end gap-3">
<Button type="button" onClick={() => router.back()} variant="outline">
Annuler
</Button>
<Button
type="submit"
disabled={submitting}
variant="brand"
>
{submitting ? 'Création...' : "Créer l'équipe"}
</Button>
</div>
</form>
</Card>
</main>
);
}

55
src/app/teams/page.tsx Normal file
View File

@@ -0,0 +1,55 @@
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { TeamCard } from '@/components/teams';
import { Button, PageHeader } from '@/components/ui';
import { getUserTeams } from '@/services/teams';
export default async function TeamsPage() {
const session = await auth();
if (!session?.user?.id) {
redirect('/login');
}
const teams = await getUserTeams(session.user.id);
return (
<main className="mx-auto max-w-7xl px-4">
<PageHeader
emoji="👥"
title="Équipes"
subtitle={`${teams.length} équipe${teams.length !== 1 ? 's' : ''} · Collaborez et définissez vos OKRs`}
actions={
<Link href="/teams/new">
<Button variant="brand" size="sm">
Créer une équipe
</Button>
</Link>
}
/>
{/* Teams Grid */}
{teams.length > 0 ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{teams.map((team: (typeof teams)[number]) => (
<TeamCard key={team.id} team={team as Parameters<typeof TeamCard>[0]['team']} />
))}
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-xl border border-border bg-card py-16">
<div className="text-4xl">👥</div>
<div className="mt-4 text-lg font-medium text-foreground">Aucune équipe</div>
<div className="mt-1 text-sm text-muted">
Créez votre première équipe pour commencer à définir des OKRs
</div>
<Link href="/teams/new" className="mt-6">
<Button variant="brand">
Créer une équipe
</Button>
</Link>
</div>
)}
</main>
);
}

View File

@@ -2,6 +2,7 @@ import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { getAllUsersWithStats } from '@/services/auth';
import { getGravatarUrl } from '@/lib/gravatar';
import { PageHeader } from '@/components/ui';
function formatRelativeTime(date: Date): string {
const now = new Date();
@@ -33,15 +34,12 @@ export default async function UsersPage() {
const avgSessionsPerUser = users.length > 0 ? totalSessions / users.length : 0;
return (
<main className="mx-auto max-w-6xl px-4 py-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Utilisateurs</h1>
<p className="mt-1 text-muted">
{users.length} utilisateur{users.length > 1 ? 's' : ''} inscrit
{users.length > 1 ? 's' : ''}
</p>
</div>
<main className="mx-auto max-w-6xl px-4">
<PageHeader
emoji="🧑‍💻"
title="Utilisateurs"
subtitle={`${users.length} utilisateur${users.length > 1 ? 's' : ''} inscrit${users.length > 1 ? 's' : ''} · Vue d'ensemble de la communauté`}
/>
{/* Global Stats */}
<div className="mb-8 grid grid-cols-2 gap-4 sm:grid-cols-4">

View File

@@ -0,0 +1,96 @@
import { notFound } from 'next/navigation';
import { auth } from '@/lib/auth';
import {
getWeatherSessionById,
getPreviousWeatherEntriesForUsers,
getWeatherSessionsHistory,
} from '@/services/weather';
import { getUserTeams } from '@/services/teams';
import {
WeatherBoard,
WeatherLiveWrapper,
WeatherInfoPanel,
WeatherAverageBar,
WeatherTrendChart,
} from '@/components/weather';
import { Badge, SessionPageHeader } from '@/components/ui';
interface WeatherSessionPageProps {
params: Promise<{ id: string }>;
}
export default async function WeatherSessionPage({ params }: WeatherSessionPageProps) {
const { id } = await params;
const authSession = await auth();
if (!authSession?.user?.id) {
return null;
}
const session = await getWeatherSessionById(id, authSession.user.id);
if (!session) {
notFound();
}
const allUserIds = [session.user.id, ...session.shares.map((s: { userId: string }) => s.userId)];
const [previousEntries, userTeams, history] = await Promise.all([
getPreviousWeatherEntriesForUsers(session.id, session.date, allUserIds),
getUserTeams(authSession.user.id),
getWeatherSessionsHistory(authSession.user.id),
]);
const currentHistoryIndex = history.findIndex((point) => point.sessionId === session.id);
const previousTeamAverages =
currentHistoryIndex > 0
? {
performance: history[currentHistoryIndex - 1].performance,
moral: history[currentHistoryIndex - 1].moral,
flux: history[currentHistoryIndex - 1].flux,
valueCreation: history[currentHistoryIndex - 1].valueCreation,
}
: null;
return (
<main className="mx-auto max-w-7xl px-4">
<SessionPageHeader
workshopType="weather"
sessionId={session.id}
sessionTitle={session.title}
isOwner={session.isOwner}
canEdit={session.canEdit}
ownerUser={session.user}
date={session.date}
badges={<Badge variant="primary">{session.entries.length} membres</Badge>}
/>
{/* 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} previousAverages={previousTeamAverages} />
<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)}
/>
<WeatherInfoPanel className="mt-6 mb-6" />
<WeatherTrendChart data={history} currentSessionId={session.id} />
</WeatherLiveWrapper>
</main>
);
}

View File

@@ -0,0 +1,148 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
DateInput,
Input,
} from '@/components/ui';
import { createWeatherSession } from '@/actions/weather';
import { getWeekYearLabel } from '@/lib/date-utils';
export default function NewWeatherPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [title, setTitle] = useState(() =>
getWeekYearLabel(new Date(new Date().toISOString().split('T')[0]))
);
const [isTitleManuallyEdited, setIsTitleManuallyEdited] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const date = selectedDate ? new Date(selectedDate) : undefined;
if (!title) {
setError('Veuillez remplir le titre');
setLoading(false);
return;
}
const result = await createWeatherSession({ title, date });
if (!result.success) {
setError(result.error || 'Une erreur est survenue');
setLoading(false);
return;
}
router.push(`/weather/${result.data?.id}`);
}
function handleDateChange(e: React.ChangeEvent<HTMLInputElement>) {
const newDate = e.target.value;
setSelectedDate(newDate);
// Only update title if user hasn't manually modified it
if (!isTitleManuallyEdited) {
setTitle(getWeekYearLabel(new Date(newDate)));
}
}
function handleTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
setTitle(e.target.value);
setIsTitleManuallyEdited(true);
}
return (
<main className="mx-auto max-w-2xl px-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>🌤</span>
Nouvelle Météo
</CardTitle>
<CardDescription>
Créez une météo personnelle pour faire le point sur 4 axes clés et partagez-la avec
votre équipe
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<Input
label="Titre de la météo"
name="title"
placeholder="Ex: Météo S05 - 2026"
value={title}
onChange={handleTitleChange}
required
/>
<DateInput
id="date"
name="date"
label="Date de la météo"
value={selectedDate}
onChange={handleDateChange}
required
/>
<div className="rounded-lg border border-border bg-card-hover p-4">
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
<ol className="text-sm text-muted space-y-1 list-decimal list-inside">
<li>
<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,93 @@
import { notFound } from 'next/navigation';
import { auth } from '@/lib/auth';
import { getWeeklyCheckInSessionById } from '@/services/weekly-checkin';
import { getUserTeams } from '@/services/teams';
import type { ResolvedCollaborator } from '@/services/auth';
import { getUserOKRsForPeriod } from '@/services/okrs';
import { getCurrentQuarterPeriod } from '@/lib/okr-utils';
import { WeeklyCheckInBoard, WeeklyCheckInLiveWrapper } from '@/components/weekly-checkin';
import { CurrentQuarterOKRs } from '@/components/weekly-checkin/CurrentQuarterOKRs';
import { Badge, SessionPageHeader } from '@/components/ui';
interface WeeklyCheckInSessionPageProps {
params: Promise<{ id: string }>;
}
export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckInSessionPageProps) {
const { id } = await params;
const authSession = await auth();
if (!authSession?.user?.id) {
return null;
}
const [session, userTeams] = await Promise.all([
getWeeklyCheckInSessionById(id, authSession.user.id),
getUserTeams(authSession.user.id),
]);
if (!session) {
notFound();
}
// Get current quarter OKRs for the participant (NOT the creator)
// We use session.resolvedParticipant.matchedUser.id which is the participant's user ID
const currentQuarterPeriod = getCurrentQuarterPeriod(session.date);
let currentQuarterOKRs: Awaited<ReturnType<typeof getUserOKRsForPeriod>> = [];
// Only fetch OKRs if the participant is a recognized user (has matchedUser)
const resolvedParticipant = session.resolvedParticipant as ResolvedCollaborator;
if (resolvedParticipant.matchedUser) {
// Use participant's ID, not session.userId (which is the creator's ID)
const participantUserId = resolvedParticipant.matchedUser.id;
currentQuarterOKRs = await getUserOKRsForPeriod(participantUserId, currentQuarterPeriod);
}
return (
<main className="mx-auto max-w-7xl px-4">
<SessionPageHeader
workshopType="weekly-checkin"
sessionId={session.id}
sessionTitle={session.title}
isOwner={session.isOwner}
canEdit={session.canEdit}
ownerUser={session.user}
date={session.date}
collaborator={resolvedParticipant}
badges={<Badge variant="primary">{session.items.length} items</Badge>}
/>
{/* Current Quarter OKRs - editable by participant or team admin */}
{currentQuarterOKRs.length > 0 && (
<CurrentQuarterOKRs
okrs={currentQuarterOKRs}
period={currentQuarterPeriod}
canEdit={
(!!resolvedParticipant.matchedUser &&
authSession.user.id === resolvedParticipant.matchedUser.id) ||
(() => {
const participantTeamIds = new Set(
currentQuarterOKRs.map((okr) => okr.team?.id).filter(Boolean) as string[]
);
const adminTeamIds = userTeams.filter((t) => t.userRole === 'ADMIN').map((t) => t.id);
return adminTeamIds.some((tid) => participantTeamIds.has(tid));
})()
}
/>
)}
{/* Live Wrapper + Board */}
<WeeklyCheckInLiveWrapper
sessionId={session.id}
sessionTitle={session.title}
currentUserId={authSession.user.id}
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
userTeams={userTeams}
>
<WeeklyCheckInBoard sessionId={session.id} items={session.items} />
</WeeklyCheckInLiveWrapper>
</main>
);
}

View File

@@ -0,0 +1,155 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
DateInput,
Input,
ParticipantInput,
} from '@/components/ui';
import { createWeeklyCheckInSession } from '@/actions/weekly-checkin';
import { getWeekYearLabel } from '@/lib/date-utils';
export default function NewWeeklyCheckInPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [title, setTitle] = useState(() =>
getWeekYearLabel(new Date(new Date().toISOString().split('T')[0]))
);
const [isTitleManuallyEdited, setIsTitleManuallyEdited] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const formData = new FormData(e.currentTarget);
const participant = formData.get('participant') as string;
const date = selectedDate ? new Date(selectedDate) : undefined;
if (!title || !participant) {
setError('Veuillez remplir tous les champs');
setLoading(false);
return;
}
const result = await createWeeklyCheckInSession({ title, participant, date });
if (!result.success) {
setError(result.error || 'Une erreur est survenue');
setLoading(false);
return;
}
router.push(`/weekly-checkin/${result.data?.id}`);
}
function handleDateChange(e: React.ChangeEvent<HTMLInputElement>) {
const newDate = e.target.value;
setSelectedDate(newDate);
// Only update title if user hasn't manually modified it
if (!isTitleManuallyEdited) {
setTitle(getWeekYearLabel(new Date(newDate)));
}
}
function handleTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
setTitle(e.target.value);
setIsTitleManuallyEdited(true);
}
return (
<main className="mx-auto max-w-2xl px-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>📝</span>
Nouveau Check-in Hebdomadaire
</CardTitle>
<CardDescription>
Créez un check-in hebdomadaire pour faire le point sur la semaine avec votre
collaborateur
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<Input
label="Titre du check-in"
name="title"
placeholder="Ex: Check-in semaine du 15 janvier"
value={title}
onChange={handleTitleChange}
required
/>
<ParticipantInput name="participant" required />
<DateInput
id="date"
name="date"
label="Date du check-in"
value={selectedDate}
onChange={handleDateChange}
required
/>
<div className="rounded-lg border border-border bg-card-hover p-4">
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
<ol className="text-sm text-muted space-y-1 list-decimal list-inside">
<li>
<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,61 @@
import { notFound } from 'next/navigation';
import { auth } from '@/lib/auth';
import { getYearReviewSessionById } from '@/services/year-review';
import { getUserTeams } from '@/services/teams';
import type { ResolvedCollaborator } from '@/services/auth';
import { YearReviewBoard, YearReviewLiveWrapper } from '@/components/year-review';
import { Badge, SessionPageHeader } from '@/components/ui';
interface YearReviewSessionPageProps {
params: Promise<{ id: string }>;
}
export default async function YearReviewSessionPage({ params }: YearReviewSessionPageProps) {
const { id } = await params;
const authSession = await auth();
if (!authSession?.user?.id) {
return null;
}
const [session, userTeams] = await Promise.all([
getYearReviewSessionById(id, authSession.user.id),
getUserTeams(authSession.user.id),
]);
if (!session) {
notFound();
}
return (
<main className="mx-auto max-w-7xl px-4">
<SessionPageHeader
workshopType="year-review"
sessionId={session.id}
sessionTitle={session.title}
isOwner={session.isOwner}
canEdit={session.canEdit}
ownerUser={session.user}
date={session.updatedAt}
collaborator={session.resolvedParticipant as ResolvedCollaborator}
badges={<>
<Badge variant="primary">{session.items.length} items</Badge>
<Badge variant="default">Année {session.year}</Badge>
</>}
/>
{/* Live Wrapper + Board */}
<YearReviewLiveWrapper
sessionId={session.id}
sessionTitle={session.title}
currentUserId={authSession.user.id}
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
userTeams={userTeams}
>
<YearReviewBoard sessionId={session.id} items={session.items} />
</YearReviewLiveWrapper>
</main>
);
}

View File

@@ -0,0 +1,132 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
NumberInput,
Input,
ParticipantInput,
} from '@/components/ui';
import { createYearReviewSession } from '@/actions/year-review';
export default function NewYearReviewPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const currentYear = new Date().getFullYear();
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const formData = new FormData(e.currentTarget);
const title = formData.get('title') as string;
const participant = formData.get('participant') as string;
const year = parseInt(formData.get('year') as string, 10);
if (!title || !participant || !year) {
setError('Veuillez remplir tous les champs');
setLoading(false);
return;
}
const result = await createYearReviewSession({ title, participant, year });
if (!result.success) {
setError(result.error || 'Une erreur est survenue');
setLoading(false);
return;
}
router.push(`/year-review/${result.data?.id}`);
}
return (
<main className="mx-auto max-w-2xl px-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>📅</span>
Nouveau Bilan Annuel
</CardTitle>
<CardDescription>
Créez un bilan de l&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 />
<NumberInput
id="year"
name="year"
label="Année du bilan"
min="2000"
max="2100"
defaultValue={currentYear}
required
/>
<div className="rounded-lg border border-border bg-card-hover p-4">
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
<ol className="text-sm text-muted space-y-1 list-decimal list-inside">
<li>
<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

@@ -2,7 +2,6 @@
import { SessionProvider } from 'next-auth/react';
import { ThemeProvider } from '@/contexts/ThemeContext';
import { Header } from '@/components/layout/Header';
import { ReactNode } from 'react';
interface ProvidersProps {
@@ -13,10 +12,7 @@ export function Providers({ children }: ProvidersProps) {
return (
<SessionProvider>
<ThemeProvider>
<div className="min-h-screen bg-background">
<Header />
{children}
</div>
{children}
</ThemeProvider>
</SessionProvider>
);

View File

@@ -0,0 +1,106 @@
'use client';
import { useState, useCallback } from 'react';
import { useLive, type LiveEvent } from '@/hooks/useLive';
import { CollaborationToolbar } from './CollaborationToolbar';
import { ShareModal } from './ShareModal';
import type { ShareRole } from '@prisma/client';
import type { TeamWithMembers, Share } from '@/lib/share-utils';
export type LiveApiPath = 'sessions' | 'motivators' | 'weather' | 'year-review' | 'weekly-checkin' | 'gif-mood';
interface ShareModalConfig {
title: string;
sessionSubtitle: string;
helpText: React.ReactNode;
}
interface BaseSessionLiveWrapperConfig {
apiPath: LiveApiPath;
shareModal: ShareModalConfig;
onShareWithEmail: (
email: string,
role: ShareRole
) => Promise<{ success: boolean; error?: string }>;
onRemoveShare: (userId: string) => Promise<unknown>;
onShareWithTeam?: (
teamId: string,
role: ShareRole
) => Promise<{ success: boolean; error?: string }>;
}
interface BaseSessionLiveWrapperProps {
sessionId: string;
sessionTitle: string;
currentUserId: string;
shares: Share[];
isOwner: boolean;
canEdit: boolean;
userTeams?: TeamWithMembers[];
children: React.ReactNode;
config: BaseSessionLiveWrapperConfig;
}
export function BaseSessionLiveWrapper({
sessionId,
sessionTitle,
currentUserId,
shares,
isOwner,
canEdit,
userTeams = [],
children,
config,
}: BaseSessionLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: LiveEvent) => {
// Show who made the last change
if (event.user?.name || event.user?.email) {
setLastEventUser(event.user.name || event.user.email);
// Clear after 3 seconds
setTimeout(() => setLastEventUser(null), 3000);
}
}, []);
const { isConnected, error } = useLive({
sessionId,
apiPath: config.apiPath,
currentUserId,
onEvent: handleEvent,
});
return (
<>
<CollaborationToolbar
isConnected={isConnected}
error={error}
lastEventUser={lastEventUser}
canEdit={canEdit}
shares={shares}
onShareClick={() => setShareModalOpen(true)}
/>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<ShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
title={config.shareModal.title}
sessionSubtitle={config.shareModal.sessionSubtitle}
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
userTeams={userTeams}
currentUserId={currentUserId}
onShareWithEmail={config.onShareWithEmail}
onShareWithTeam={config.onShareWithTeam}
onRemoveShare={config.onRemoveShare}
helpText={config.shareModal.helpText}
/>
</>
);
}

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,99 @@
'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 { shareSessionAction, removeShareAction } from '@/actions/share';
import Link from 'next/link';
import {
Modal,
Input,
Button,
Badge,
Avatar,
Select,
SegmentedControl,
IconButton,
IconTrash,
} from '@/components/ui';
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 +102,153 @@ 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
<div className="border-b border-border pb-3">
<SegmentedControl
value={shareType}
onChange={(value) => {
setShareType(value);
resetForm();
}}
fullWidth
className="flex w-full gap-2 border-0 bg-transparent p-0"
options={tabs.map((tab) => ({
value: tab.value,
label: `${tab.icon} ${tab.label}`,
}))}
/>
<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>
{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,31 +267,18 @@ 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'}
</Badge>
{isOwner && (
<button
<IconButton
icon={<IconTrash className="h-4 w-4" />}
label="Retirer l'accès"
variant="destructive"
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>
@@ -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

@@ -0,0 +1,126 @@
'use client';
import { useState, useTransition } from 'react';
import { Button, Input } from '@/components/ui';
import { addGifMoodItem } from '@/actions/gif-mood';
import { GIF_MOOD_MAX_ITEMS } from '@/lib/types';
interface GifMoodAddFormProps {
sessionId: string;
currentCount: number;
}
export function GifMoodAddForm({ sessionId, currentCount }: GifMoodAddFormProps) {
const [open, setOpen] = useState(false);
const [gifUrl, setGifUrl] = useState('');
const [note, setNote] = useState('');
const [previewUrl, setPreviewUrl] = useState('');
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const remaining = GIF_MOOD_MAX_ITEMS - currentCount;
function handleUrlBlur() {
const trimmed = gifUrl.trim();
if (!trimmed) { setPreviewUrl(''); return; }
try { new URL(trimmed); setPreviewUrl(trimmed); setError(null); }
catch { setPreviewUrl(''); }
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
const trimmed = gifUrl.trim();
if (!trimmed) { setError("L'URL est requise"); return; }
try { new URL(trimmed); } catch { setError('URL invalide'); return; }
startTransition(async () => {
const result = await addGifMoodItem(sessionId, {
gifUrl: trimmed,
note: note.trim() || undefined,
});
if (result.success) {
setGifUrl(''); setNote(''); setPreviewUrl(''); setOpen(false);
} else {
setError(result.error || "Erreur lors de l'ajout");
}
});
}
// Collapsed state — placeholder card
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="group flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-border/50 hover:border-primary/40 hover:bg-primary/5 min-h-[120px] transition-all duration-200 text-muted hover:text-primary w-full"
>
<span className="text-2xl opacity-40 group-hover:opacity-70 transition-opacity"></span>
<span className="text-xs font-medium">{remaining} slot{remaining !== 1 ? 's' : ''} restant{remaining !== 1 ? 's' : ''}</span>
</button>
);
}
// Expanded form
return (
<form
onSubmit={handleSubmit}
className="rounded-2xl border border-border bg-card shadow-sm p-4 space-y-3"
>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-foreground">Ajouter un GIF</span>
<button
type="button"
onClick={() => { setOpen(false); setError(null); setPreviewUrl(''); }}
className="text-muted hover:text-foreground transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
<Input
label="URL du GIF"
value={gifUrl}
onChange={(e) => setGifUrl(e.target.value)}
onBlur={handleUrlBlur}
placeholder="https://media.giphy.com/…"
disabled={isPending}
/>
{previewUrl && (
<div className="rounded-xl overflow-hidden border border-border/50">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewUrl}
alt="Aperçu"
className="w-full object-contain max-h-40"
onError={() => setPreviewUrl('')}
/>
</div>
)}
<div>
<label className="block text-xs font-medium text-muted mb-1">
Note <span className="font-normal opacity-60">(optionnelle)</span>
</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Ce que ce GIF exprime…"
rows={2}
disabled={isPending}
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"
/>
</div>
<Button type="submit" loading={isPending} className="w-full" size="sm">
Ajouter
</Button>
</form>
);
}

View File

@@ -0,0 +1,273 @@
'use client';
import { useMemo, useState, useTransition } from 'react';
import { setGifMoodUserRating } from '@/actions/gif-mood';
import { Avatar } from '@/components/ui/Avatar';
import { GifMoodCard } from './GifMoodCard';
import { GifMoodAddForm } from './GifMoodAddForm';
import { GIF_MOOD_MAX_ITEMS } from '@/lib/types';
interface GifMoodItem {
id: string;
gifUrl: string;
note: string | null;
order: number;
userId: string;
user: {
id: string;
name: string | null;
email: string;
};
}
interface Share {
id: string;
userId: string;
user: {
id: string;
name: string | null;
email: string;
};
}
interface GifMoodBoardProps {
sessionId: string;
currentUserId: string;
items: GifMoodItem[];
shares: Share[];
owner: {
id: string;
name: string | null;
email: string;
};
ratings: { userId: string; rating: number }[];
canEdit: boolean;
}
function WeekRating({
sessionId,
isCurrentUser,
canEdit,
initialRating,
}: {
sessionId: string;
isCurrentUser: boolean;
canEdit: boolean;
initialRating: number | null;
}) {
const [prevInitialRating, setPrevInitialRating] = useState(initialRating);
const [rating, setRating] = useState<number | null>(initialRating);
const [hovered, setHovered] = useState<number | null>(null);
const [isPending, startTransition] = useTransition();
if (prevInitialRating !== initialRating) {
setPrevInitialRating(initialRating);
setRating(initialRating);
}
const interactive = isCurrentUser && canEdit;
const display = hovered ?? rating;
function handleClick(n: number) {
if (!interactive) return;
setRating(n);
startTransition(async () => {
await setGifMoodUserRating(sessionId, n);
});
}
return (
<div
className="flex items-center gap-0.5"
onMouseLeave={() => setHovered(null)}
>
{[1, 2, 3, 4, 5].map((n) => {
const filled = display !== null && n <= display;
return (
<button
key={n}
type="button"
onClick={() => handleClick(n)}
onMouseEnter={() => interactive && setHovered(n)}
disabled={!interactive || isPending}
className={`transition-all duration-100 ${interactive ? 'cursor-pointer hover:scale-125' : 'cursor-default'}`}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
className={`transition-colors duration-100 ${
filled ? 'text-amber-400' : 'text-border'
}`}
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
fill="currentColor"
stroke="currentColor"
strokeWidth="1"
strokeLinejoin="round"
/>
</svg>
</button>
);
})}
</div>
);
}
// Subtle accent colors for each user section
const SECTION_COLORS = ['#ec4899', '#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
const GRID_COLS: Record<number, string> = {
4: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
5: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5',
6: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6',
};
function GridIcon({ cols }: { cols: number }) {
return (
<svg width="20" height="14" viewBox="0 0 20 14" fill="none" aria-hidden>
{Array.from({ length: cols }).map((_, i) => {
const w = (20 - (cols - 1) * 2) / cols;
const x = i * (w + 2);
return (
<g key={i}>
<rect x={x} y={0} width={w} height={6} rx={1} fill="currentColor" opacity={0.7} />
<rect x={x} y={8} width={w} height={6} rx={1} fill="currentColor" opacity={0.4} />
</g>
);
})}
</svg>
);
}
export function GifMoodBoard({
sessionId,
currentUserId,
items,
shares,
owner,
ratings,
canEdit,
}: GifMoodBoardProps) {
const [cols, setCols] = useState(5);
const allUsers = useMemo(() => {
const map = new Map<string, { id: string; name: string | null; email: string }>();
map.set(owner.id, owner);
shares.forEach((s) => map.set(s.userId, s.user));
return Array.from(map.values());
}, [owner, shares]);
const itemsByUser = useMemo(() => {
const map = new Map<string, GifMoodItem[]>();
items.forEach((item) => {
const existing = map.get(item.userId) ?? [];
existing.push(item);
map.set(item.userId, existing);
});
return map;
}, [items]);
const sortedUsers = useMemo(() => {
return [...allUsers].sort((a, b) => {
if (a.id === currentUserId) return -1;
if (b.id === currentUserId) return 1;
if (a.id === owner.id) return -1;
if (b.id === owner.id) return 1;
return (a.name || a.email).localeCompare(b.name || b.email, 'fr');
});
}, [allUsers, currentUserId, owner.id]);
return (
<div className="space-y-10">
{/* Column size control */}
<div className="flex justify-end">
<div className="inline-flex items-center gap-1 rounded-xl border border-border bg-card p-1">
{[4, 5, 6].map((n) => (
<button
key={n}
onClick={() => setCols(n)}
className={`flex items-center justify-center rounded-lg px-2.5 py-1.5 transition-all ${
cols === n
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted hover:text-foreground hover:bg-card-hover'
}`}
title={`${n} colonnes`}
>
<GridIcon cols={n} />
</button>
))}
</div>
</div>
{sortedUsers.map((user, index) => {
const userItems = itemsByUser.get(user.id) ?? [];
const isCurrentUser = user.id === currentUserId;
const canAdd = canEdit && isCurrentUser && userItems.length < GIF_MOOD_MAX_ITEMS;
const accentColor = SECTION_COLORS[index % SECTION_COLORS.length];
const userRating = ratings.find((r) => r.userId === user.id)?.rating ?? null;
return (
<section key={user.id}>
{/* Section header */}
<div className="flex items-center gap-4 mb-5">
{/* Colored accent bar */}
<div className="w-1 h-8 rounded-full shrink-0" style={{ backgroundColor: accentColor }} />
<Avatar email={user.email} name={user.name} size={36} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-foreground truncate">
{user.name || user.email}
</span>
{isCurrentUser && (
<span className="text-xs text-muted bg-card-hover px-2 py-0.5 rounded-full border border-border">
vous
</span>
)}
</div>
<div className="flex items-center gap-3 mt-0.5">
<p className="text-xs text-muted">
{userItems.length} / {GIF_MOOD_MAX_ITEMS} GIF{userItems.length !== 1 ? 's' : ''}
</p>
<WeekRating
sessionId={sessionId}
isCurrentUser={isCurrentUser}
canEdit={canEdit}
initialRating={userRating}
/>
</div>
</div>
</div>
{/* Grid */}
<div className={`grid ${GRID_COLS[cols]} gap-4 items-start`}>
{userItems.map((item) => (
<GifMoodCard
key={item.id}
sessionId={sessionId}
item={item}
currentUserId={currentUserId}
canEdit={canEdit}
/>
))}
{/* Add form slot */}
{canAdd && (
<GifMoodAddForm sessionId={sessionId} currentCount={userItems.length} />
)}
{/* Empty state */}
{!canAdd && userItems.length === 0 && (
<div className="col-span-full flex items-center justify-center rounded-2xl border border-dashed border-border/60 py-10">
<p className="text-sm text-muted/60">Aucun GIF pour le moment</p>
</div>
)}
</div>
</section>
);
})}
</div>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import { memo, useState, useTransition } from 'react';
import { updateGifMoodItem, deleteGifMoodItem } from '@/actions/gif-mood';
import { IconClose } from '@/components/ui';
interface GifMoodCardProps {
sessionId: string;
item: {
id: string;
gifUrl: string;
note: string | null;
userId: string;
};
currentUserId: string;
canEdit: boolean;
}
export const GifMoodCard = memo(function GifMoodCard({
sessionId,
item,
currentUserId,
canEdit,
}: GifMoodCardProps) {
const [note, setNote] = useState(item.note || '');
const [itemVersion, setItemVersion] = useState(item);
const [isPending, startTransition] = useTransition();
const [imgError, setImgError] = useState(false);
if (itemVersion !== item) {
setItemVersion(item);
setNote(item.note || '');
}
const isOwner = item.userId === currentUserId;
const canEditThis = canEdit && isOwner;
function handleNoteBlur() {
if (!canEditThis) return;
startTransition(async () => {
await updateGifMoodItem(sessionId, item.id, { note: note.trim() || undefined });
});
}
function handleDelete() {
if (!canEditThis) return;
startTransition(async () => {
await deleteGifMoodItem(sessionId, item.id);
});
}
return (
<div
className={`group relative rounded-2xl overflow-hidden bg-card shadow-sm hover:shadow-md transition-all duration-200 ${
isPending ? 'opacity-50 scale-95' : ''
}`}
>
{/* GIF */}
{imgError ? (
<div className="flex flex-col items-center justify-center gap-2 min-h-[120px] bg-card-hover">
<span className="text-3xl opacity-40">🖼</span>
<p className="text-xs text-muted">Image non disponible</p>
</div>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.gifUrl}
alt="GIF"
className="w-full block"
onError={() => setImgError(true)}
/>
)}
{/* Gradient overlay on hover (for delete affordance) */}
{canEditThis && (
<div className="absolute inset-0 bg-gradient-to-b from-black/20 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
)}
{/* Delete button — visible on hover */}
{canEditThis && (
<button
onClick={handleDelete}
disabled={isPending}
className="absolute top-2 right-2 p-1.5 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 hover:bg-black/70 transition-all backdrop-blur-sm"
title="Supprimer ce GIF"
>
<IconClose className="w-3 h-3" />
</button>
)}
{/* Note */}
{canEditThis ? (
<div className="px-3 pt-2 pb-3 bg-card">
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
onBlur={handleNoteBlur}
placeholder="Ajouter une note…"
rows={1}
className="w-full text-foreground/70 bg-transparent resize-none outline-none placeholder:text-muted/40 leading-relaxed text-center"
style={{ fontFamily: 'var(--font-caveat)', fontSize: '1.2rem' }}
/>
</div>
) : note ? (
<div className="px-3 py-2.5 bg-card">
<p className="text-foreground/70 leading-relaxed text-center" style={{ fontFamily: 'var(--font-caveat)', fontSize: '1.2rem' }}>{note}</p>
</div>
) : null}
</div>
);
});

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