refactor: improve team management, OKRs, and session components

This commit is contained in:
2026-02-25 17:29:40 +01:00
parent c828ab1a48
commit a10205994c
74 changed files with 3771 additions and 1889 deletions

View File

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

View File

@@ -28,6 +28,7 @@ pnpm prisma studio # Open DB GUI
## Architecture Overview ## Architecture Overview
### Workshop Types ### Workshop Types
There are 5 workshop types: `swot` (sessions), `motivators`, `year-review`, `weekly-checkin`, `weather`. 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. **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.
@@ -58,6 +59,7 @@ src/
``` ```
### Real-Time Collaboration (SSE) ### 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. 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. 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.
@@ -65,21 +67,26 @@ Client side: `useLive` hook (`src/hooks/useLive.ts`) connects to the subscribe e
`BaseSessionLiveWrapper` (`src/components/collaboration/`) is the shared wrapper component that wires `useLive`, `CollaborationToolbar`, and `ShareModal` for all workshop session pages. `BaseSessionLiveWrapper` (`src/components/collaboration/`) is the shared wrapper component that wires `useLive`, `CollaborationToolbar`, and `ShareModal` for all workshop session pages.
### Shared Permission System ### 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. `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. `createShareAndEventHandlers(...)` in `src/services/session-share-events.ts` returns `share`, `removeShare`, `getShares`, `createEvent`, `getEvents` — used by all workshop services.
### Auth ### Auth
- `src/lib/auth.ts` — NextAuth config (signIn, signOut, auth exports) - `src/lib/auth.ts` — NextAuth config (signIn, signOut, auth exports)
- `src/lib/auth.config.ts` — config object (used separately for Edge middleware) - `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` - `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 - Session user ID is available via `auth()` call server-side; token includes `id` field
### Database ### 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`. 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 ### Adding a New Workshop
Pattern followed by all existing workshops: Pattern followed by all existing workshops:
1. Add entry to `WORKSHOPS` in `src/lib/workshops.ts` 1. Add entry to `WORKSHOPS` in `src/lib/workshops.ts`
2. Add Prisma models (Session, Item, Share, Event) following the existing pattern 2. Add Prisma models (Session, Item, Share, Event) following the existing pattern
3. Create service in `src/services/` using `createSessionPermissionChecks` and `createShareAndEventHandlers` 3. Create service in `src/services/` using `createSessionPermissionChecks` and `createShareAndEventHandlers`

View File

@@ -3,18 +3,21 @@
## Requêtes DB (impact critique) ## Requêtes DB (impact critique)
### resolveCollaborator — suppression du scan complet de la table User ### resolveCollaborator — suppression du scan complet de la table User
**Fichier:** `src/services/auth.ts` **Fichier:** `src/services/auth.ts`
Avant : `findMany` sur tous les users puis `find()` en JS pour un match case-insensitive par nom. 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). Après : `findFirst` avec `contains` + vérification exacte. O(1) au lieu de O(N users).
### getAllUsersWithStats — suppression du N+1 ### getAllUsersWithStats — suppression du N+1
**Fichier:** `src/services/auth.ts` **Fichier:** `src/services/auth.ts`
Avant : 2 queries `count` par utilisateur (`Promise.all` avec map). 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. Après : 2 `groupBy` en bulk + construction d'une Map. 3 queries au lieu de 2N+1.
### React.cache sur les fonctions teams ### React.cache sur les fonctions teams
**Fichier:** `src/services/teams.ts` **Fichier:** `src/services/teams.ts`
`getTeamMemberIdsForAdminTeams` et `isAdminOfUser` wrappées avec `React.cache()`. `getTeamMemberIdsForAdminTeams` et `isAdminOfUser` wrappées avec `React.cache()`.
@@ -23,11 +26,13 @@ Sur la page `/sessions`, ces fonctions étaient appelées ~10 fois par requête
## SSE / Temps réel (impact haut) ## SSE / Temps réel (impact haut)
### Polling interval 1s → 2s ### Polling interval 1s → 2s
**Fichiers:** 5 routes `src/app/api/*/[id]/subscribe/route.ts` **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). 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 ### Nettoyage des events
**Fichier:** `src/services/session-share-events.ts` **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. 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.
@@ -35,7 +40,9 @@ Ajout de `cleanupOldEvents(maxAgeHours)` pour purger les events périmés. Les t
## Rendu client (impact haut) ## Rendu client (impact haut)
### React.memo sur les composants de cartes ### React.memo sur les composants de cartes
**Fichiers:** **Fichiers:**
- `src/components/swot/SwotCard.tsx` - `src/components/swot/SwotCard.tsx`
- `src/components/moving-motivators/MotivatorCard.tsx` (+ `MotivatorCardStatic`) - `src/components/moving-motivators/MotivatorCard.tsx` (+ `MotivatorCardStatic`)
- `src/components/weather/WeatherCard.tsx` - `src/components/weather/WeatherCard.tsx`
@@ -45,6 +52,7 @@ Ajout de `cleanupOldEvents(maxAgeHours)` pour purger les events périmés. Les t
Ces composants sont rendus en liste et re-rendaient tous à chaque drag, changement d'état, ou `router.refresh()` SSE. 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 ### WeatherCard — fix du pattern useEffect + setState
**Fichier:** `src/components/weather/WeatherCard.tsx` **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). 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).
@@ -52,12 +60,14 @@ Remplacé le `useEffect` qui appelait 5 `setState` (cascading renders, erreur li
## Configuration Next.js (impact moyen) ## Configuration Next.js (impact moyen)
### next.config.ts ### next.config.ts
**Fichier:** `next.config.ts` **Fichier:** `next.config.ts`
- `poweredByHeader: false` — supprime le header `X-Powered-By` (sécurité) - `poweredByHeader: false` — supprime le header `X-Powered-By` (sécurité)
- `optimizePackageImports` — tree-shaking amélioré pour `@dnd-kit/*` et `@hello-pangea/dnd` - `optimizePackageImports` — tree-shaking amélioré pour `@dnd-kit/*` et `@hello-pangea/dnd`
### Fix FOUC dark mode ### Fix FOUC dark mode
**Fichier:** `src/app/layout.tsx` **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. 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.

View File

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

View File

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

View File

@@ -13,7 +13,8 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"prettier": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",

3507
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -115,7 +115,11 @@ export async function createOrUpdateWeatherEntry(
} }
try { try {
const entry = await weatherService.createOrUpdateWeatherEntry(sessionId, authSession.user.id, data); const entry = await weatherService.createOrUpdateWeatherEntry(
sessionId,
authSession.user.id,
data
);
// Get user info for broadcast // Get user info for broadcast
const user = await getUserById(authSession.user.id); const user = await getUserById(authSession.user.id);
@@ -124,7 +128,8 @@ export async function createOrUpdateWeatherEntry(
} }
// Emit event for real-time sync // Emit event for real-time sync
const eventType = entry.createdAt.getTime() === entry.updatedAt.getTime() ? 'ENTRY_CREATED' : 'ENTRY_UPDATED'; const eventType =
entry.createdAt.getTime() === entry.updatedAt.getTime() ? 'ENTRY_CREATED' : 'ENTRY_UPDATED';
const event = await weatherService.createWeatherSessionEvent( const event = await weatherService.createWeatherSessionEvent(
sessionId, sessionId,
authSession.user.id, authSession.user.id,
@@ -254,7 +259,7 @@ export async function shareWeatherSessionToTeam(
return { success: true, data: shares }; return { success: true, data: shares };
} catch (error) { } catch (error) {
console.error('Error sharing weather session to team:', error); console.error('Error sharing weather session to team:', error);
const message = error instanceof Error ? error.message : 'Erreur lors du partage à l\'équipe'; const message = error instanceof Error ? error.message : "Erreur lors du partage à l'équipe";
return { success: false, error: message }; return { success: false, error: message };
} }
} }

View File

@@ -104,10 +104,7 @@ export async function createYearReviewItem(
} }
// Check edit permission // Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession( const canEdit = await yearReviewService.canEditYearReviewSession(sessionId, authSession.user.id);
sessionId,
authSession.user.id
);
if (!canEdit) { if (!canEdit) {
return { success: false, error: 'Permission refusée' }; return { success: false, error: 'Permission refusée' };
} }
@@ -146,10 +143,7 @@ export async function updateYearReviewItem(
} }
// Check edit permission // Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession( const canEdit = await yearReviewService.canEditYearReviewSession(sessionId, authSession.user.id);
sessionId,
authSession.user.id
);
if (!canEdit) { if (!canEdit) {
return { success: false, error: 'Permission refusée' }; return { success: false, error: 'Permission refusée' };
} }
@@ -183,10 +177,7 @@ export async function deleteYearReviewItem(itemId: string, sessionId: string) {
} }
// Check edit permission // Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession( const canEdit = await yearReviewService.canEditYearReviewSession(sessionId, authSession.user.id);
sessionId,
authSession.user.id
);
if (!canEdit) { if (!canEdit) {
return { success: false, error: 'Permission refusée' }; return { success: false, error: 'Permission refusée' };
} }
@@ -222,10 +213,7 @@ export async function moveYearReviewItem(
} }
// Check edit permission // Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession( const canEdit = await yearReviewService.canEditYearReviewSession(sessionId, authSession.user.id);
sessionId,
authSession.user.id
);
if (!canEdit) { if (!canEdit) {
return { success: false, error: 'Permission refusée' }; return { success: false, error: 'Permission refusée' };
} }
@@ -264,10 +252,7 @@ export async function reorderYearReviewItems(
} }
// Check edit permission // Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession( const canEdit = await yearReviewService.canEditYearReviewSession(sessionId, authSession.user.id);
sessionId,
authSession.user.id
);
if (!canEdit) { if (!canEdit) {
return { success: false, error: 'Permission refusée' }; return { success: false, error: 'Permission refusée' };
} }
@@ -336,4 +321,3 @@ export async function removeYearReviewShare(sessionId: string, shareUserId: stri
return { success: false, error: 'Erreur lors de la suppression du partage' }; return { success: false, error: 'Erreur lors de la suppression du partage' };
} }
} }

View File

@@ -34,7 +34,10 @@ export async function PATCH(
if (!isAdmin && !isConcernedMember) { if (!isAdmin && !isConcernedMember) {
return NextResponse.json( return NextResponse.json(
{ error: 'Seuls les administrateurs et le membre concerné peuvent mettre à jour les Key Results' }, {
error:
'Seuls les administrateurs et le membre concerné peuvent mettre à jour les Key Results',
},
{ status: 403 } { status: 403 }
); );
} }
@@ -51,10 +54,8 @@ export async function PATCH(
return NextResponse.json(updated); return NextResponse.json(updated);
} catch (error) { } catch (error) {
console.error('Error updating key result:', error); console.error('Error updating key result:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de la mise à jour du Key Result'; const errorMessage =
return NextResponse.json( error instanceof Error ? error.message : 'Erreur lors de la mise à jour du Key Result';
{ error: errorMessage }, return NextResponse.json({ error: errorMessage }, { status: 500 });
{ status: 500 }
);
} }
} }

View File

@@ -40,10 +40,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
}); });
} catch (error) { } catch (error) {
console.error('Error fetching OKR:', error); console.error('Error fetching OKR:', error);
return NextResponse.json( return NextResponse.json({ error: "Erreur lors de la récupération de l'OKR" }, { status: 500 });
{ error: 'Erreur lors de la récupération de l\'OKR' },
{ status: 500 }
);
} }
} }
@@ -65,7 +62,10 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id); const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
const isConcernedMember = okr.teamMember.userId === session.user.id; const isConcernedMember = okr.teamMember.userId === session.user.id;
if (!isAdmin && !isConcernedMember) { if (!isAdmin && !isConcernedMember) {
return NextResponse.json({ error: 'Seuls les administrateurs et le membre concerné peuvent modifier les OKRs' }, { status: 403 }); return NextResponse.json(
{ error: 'Seuls les administrateurs et le membre concerné peuvent modifier les OKRs' },
{ status: 403 }
);
} }
const body: UpdateOKRInput & { const body: UpdateOKRInput & {
@@ -73,7 +73,13 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
endDate?: string; endDate?: string;
keyResultsUpdates?: { keyResultsUpdates?: {
create?: Array<{ title: string; targetValue: number; unit: string; order: number }>; create?: Array<{ title: string; targetValue: number; unit: string; order: number }>;
update?: Array<{ id: string; title?: string; targetValue?: number; unit?: string; order?: number }>; update?: Array<{
id: string;
title?: string;
targetValue?: number;
unit?: string;
order?: number;
}>;
delete?: string[]; delete?: string[];
}; };
} = await request.json(); } = await request.json();
@@ -102,11 +108,9 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
return NextResponse.json(updated); return NextResponse.json(updated);
} catch (error) { } catch (error) {
console.error('Error updating OKR:', error); console.error('Error updating OKR:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de la mise à jour de l\'OKR'; const errorMessage =
return NextResponse.json( error instanceof Error ? error.message : "Erreur lors de la mise à jour de l'OKR";
{ error: errorMessage }, return NextResponse.json({ error: errorMessage }, { status: 500 });
{ status: 500 }
);
} }
} }
@@ -127,7 +131,10 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
// Check if user is admin of the team // Check if user is admin of the team
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id); const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
if (!isAdmin) { if (!isAdmin) {
return NextResponse.json({ error: 'Seuls les administrateurs peuvent supprimer les OKRs' }, { status: 403 }); return NextResponse.json(
{ error: 'Seuls les administrateurs peuvent supprimer les OKRs' },
{ status: 403 }
);
} }
await deleteOKR(id); await deleteOKR(id);
@@ -135,10 +142,8 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error('Error deleting OKR:', error); console.error('Error deleting OKR:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de la suppression de l\'OKR'; const errorMessage =
return NextResponse.json( error instanceof Error ? error.message : "Erreur lors de la suppression de l'OKR";
{ error: errorMessage }, return NextResponse.json({ error: errorMessage }, { status: 500 });
{ status: 500 }
);
} }
} }

View File

@@ -15,7 +15,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
// Check if user is admin // Check if user is admin
const isAdmin = await isTeamAdmin(id, session.user.id); const isAdmin = await isTeamAdmin(id, session.user.id);
if (!isAdmin) { if (!isAdmin) {
return NextResponse.json({ error: 'Seuls les administrateurs peuvent ajouter des membres' }, { status: 403 }); return NextResponse.json(
{ error: 'Seuls les administrateurs peuvent ajouter des membres' },
{ status: 403 }
);
} }
const body: AddTeamMemberInput = await request.json(); const body: AddTeamMemberInput = await request.json();
@@ -30,11 +33,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json(member, { status: 201 }); return NextResponse.json(member, { status: 201 });
} catch (error) { } catch (error) {
console.error('Error adding team member:', error); console.error('Error adding team member:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de l\'ajout du membre'; const errorMessage =
return NextResponse.json( error instanceof Error ? error.message : "Erreur lors de l'ajout du membre";
{ error: errorMessage }, return NextResponse.json({ error: errorMessage }, { status: 500 });
{ status: 500 }
);
} }
} }
@@ -50,7 +51,10 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
// Check if user is admin // Check if user is admin
const isAdmin = await isTeamAdmin(id, session.user.id); const isAdmin = await isTeamAdmin(id, session.user.id);
if (!isAdmin) { if (!isAdmin) {
return NextResponse.json({ error: 'Seuls les administrateurs peuvent modifier les rôles' }, { status: 403 }); return NextResponse.json(
{ error: 'Seuls les administrateurs peuvent modifier les rôles' },
{ status: 403 }
);
} }
const body: UpdateMemberRoleInput & { userId: string } = await request.json(); const body: UpdateMemberRoleInput & { userId: string } = await request.json();
@@ -65,10 +69,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
return NextResponse.json(member); return NextResponse.json(member);
} catch (error) { } catch (error) {
console.error('Error updating member role:', error); console.error('Error updating member role:', error);
return NextResponse.json( return NextResponse.json({ error: 'Erreur lors de la mise à jour du rôle' }, { status: 500 });
{ error: 'Erreur lors de la mise à jour du rôle' },
{ status: 500 }
);
} }
} }
@@ -84,7 +85,10 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
// Check if user is admin // Check if user is admin
const isAdmin = await isTeamAdmin(id, session.user.id); const isAdmin = await isTeamAdmin(id, session.user.id);
if (!isAdmin) { if (!isAdmin) {
return NextResponse.json({ error: 'Seuls les administrateurs peuvent retirer des membres' }, { status: 403 }); return NextResponse.json(
{ error: 'Seuls les administrateurs peuvent retirer des membres' },
{ status: 403 }
);
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@@ -99,10 +103,6 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error('Error removing team member:', error); console.error('Error removing team member:', error);
return NextResponse.json( return NextResponse.json({ error: 'Erreur lors de la suppression du membre' }, { status: 500 });
{ error: 'Erreur lors de la suppression du membre' },
{ status: 500 }
);
} }
} }

View File

@@ -28,7 +28,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
} catch (error) { } catch (error) {
console.error('Error fetching team:', error); console.error('Error fetching team:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Erreur lors de la récupération de l\'équipe' }, { error: "Erreur lors de la récupération de l'équipe" },
{ status: 500 } { status: 500 }
); );
} }
@@ -46,7 +46,10 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
// Check if user is admin // Check if user is admin
const isAdmin = await isTeamAdmin(id, session.user.id); const isAdmin = await isTeamAdmin(id, session.user.id);
if (!isAdmin) { if (!isAdmin) {
return NextResponse.json({ error: 'Seuls les administrateurs peuvent modifier l\'équipe' }, { status: 403 }); return NextResponse.json(
{ error: "Seuls les administrateurs peuvent modifier l'équipe" },
{ status: 403 }
);
} }
const body: UpdateTeamInput = await request.json(); const body: UpdateTeamInput = await request.json();
@@ -56,7 +59,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
} catch (error) { } catch (error) {
console.error('Error updating team:', error); console.error('Error updating team:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Erreur lors de la mise à jour de l\'équipe' }, { error: "Erreur lors de la mise à jour de l'équipe" },
{ status: 500 } { status: 500 }
); );
} }
@@ -74,7 +77,10 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
// Check if user is admin // Check if user is admin
const isAdmin = await isTeamAdmin(id, session.user.id); const isAdmin = await isTeamAdmin(id, session.user.id);
if (!isAdmin) { if (!isAdmin) {
return NextResponse.json({ error: 'Seuls les administrateurs peuvent supprimer l\'équipe' }, { status: 403 }); return NextResponse.json(
{ error: "Seuls les administrateurs peuvent supprimer l'équipe" },
{ status: 403 }
);
} }
await deleteTeam(id); await deleteTeam(id);
@@ -83,9 +89,8 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
} catch (error) { } catch (error) {
console.error('Error deleting team:', error); console.error('Error deleting team:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Erreur lors de la suppression de l\'équipe' }, { error: "Erreur lors de la suppression de l'équipe" },
{ status: 500 } { status: 500 }
); );
} }
} }

View File

@@ -35,7 +35,7 @@ export async function POST(request: Request) {
const { name, description } = body; const { name, description } = body;
if (!name) { if (!name) {
return NextResponse.json({ error: 'Le nom de l\'équipe est requis' }, { status: 400 }); return NextResponse.json({ error: "Le nom de l'équipe est requis" }, { status: 400 });
} }
const team = await createTeam(name, description || null, session.user.id); const team = await createTeam(name, description || null, session.user.id);
@@ -43,10 +43,6 @@ export async function POST(request: Request) {
return NextResponse.json(team, { status: 201 }); return NextResponse.json(team, { status: 201 });
} catch (error) { } catch (error) {
console.error('Error creating team:', error); console.error('Error creating team:', error);
return NextResponse.json( return NextResponse.json({ error: "Erreur lors de la création de l'équipe" }, { status: 500 });
{ error: 'Erreur lors de la création de l\'équipe' },
{ status: 500 }
);
} }
} }

View File

@@ -30,4 +30,3 @@ export async function GET() {
); );
} }
} }

View File

@@ -1,8 +1,5 @@
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { import { canAccessWeatherSession, getWeatherSessionEvents } from '@/services/weather';
canAccessWeatherSession,
getWeatherSessionEvents,
} from '@/services/weather';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -102,11 +99,15 @@ export function broadcastToWeatherSession(sessionId: string, event: object) {
const sessionConnections = connections.get(sessionId); const sessionConnections = connections.get(sessionId);
if (!sessionConnections || sessionConnections.size === 0) { if (!sessionConnections || sessionConnections.size === 0) {
// No active connections, event will be picked up by polling // 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`); console.log(
`[SSE Broadcast] No connections for session ${sessionId}, will be picked up by polling`
);
return; return;
} }
console.log(`[SSE Broadcast] Broadcasting to ${sessionConnections.size} connections for session ${sessionId}`); console.log(
`[SSE Broadcast] Broadcasting to ${sessionConnections.size} connections for session ${sessionId}`
);
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`); const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`);

View File

@@ -1,8 +1,5 @@
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { import { canAccessYearReviewSession, getYearReviewSessionEvents } from '@/services/year-review';
canAccessYearReviewSession,
getYearReviewSessionEvents,
} from '@/services/year-review';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -120,4 +117,3 @@ export function broadcastToYearReviewSession(sessionId: string, event: object) {
connections.delete(sessionId); connections.delete(sessionId);
} }
} }

View File

@@ -282,11 +282,36 @@ export default function Home() {
Les 5 catégories du bilan Les 5 catégories du bilan
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
<CategoryPill icon="🏆" name="Réalisations" color="#22c55e" description="Ce que vous avez accompli" /> <CategoryPill
<CategoryPill icon="⚔️" name="Défis" color="#ef4444" description="Les difficultés rencontrées" /> icon="🏆"
<CategoryPill icon="📚" name="Apprentissages" color="#3b82f6" description="Ce que vous avez appris" /> name="Réalisations"
<CategoryPill icon="🎯" name="Objectifs" color="#8b5cf6" description="Vos ambitions pour l'année prochaine" /> color="#22c55e"
<CategoryPill icon="⭐" name="Moments" color="#f59e0b" description="Les moments forts et marquants" /> 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>
</div> </div>
@@ -328,7 +353,9 @@ export default function Home() {
<span className="text-4xl">📝</span> <span className="text-4xl">📝</span>
<div> <div>
<h2 className="text-3xl font-bold text-foreground">Weekly Check-in</h2> <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> <p className="text-green-500 font-medium">
Le point hebdomadaire avec vos collaborateurs
</p>
</div> </div>
</div> </div>
@@ -340,9 +367,9 @@ export default function Home() {
Pourquoi faire un check-in hebdomadaire ? Pourquoi faire un check-in hebdomadaire ?
</h3> </h3>
<p className="text-muted mb-4"> <p className="text-muted mb-4">
Le Weekly Check-in est un rituel de management qui permet de maintenir un lien régulier Le Weekly Check-in est un rituel de management qui permet de maintenir un lien
avec vos collaborateurs. Il favorise la communication, l&apos;alignement et la détection régulier avec vos collaborateurs. Il favorise la communication, l&apos;alignement et
précoce des problèmes ou opportunités. la détection précoce des problèmes ou opportunités.
</p> </p>
<ul className="space-y-2 text-sm text-muted"> <ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
@@ -371,10 +398,30 @@ export default function Home() {
Les 4 catégories du check-in Les 4 catégories du check-in
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
<CategoryPill icon="✅" name="Ce qui s'est bien passé" color="#22c55e" description="Les réussites et points positifs" /> <CategoryPill
<CategoryPill icon="⚠️" name="Ce qui s'est mal passé" color="#ef4444" description="Les difficultés et points d'amélioration" /> icon="✅"
<CategoryPill icon="🎯" name="Enjeux du moment" color="#3b82f6" description="Sur quoi je me concentre actuellement" /> name="Ce qui s'est bien passé"
<CategoryPill icon="🚀" name="Prochains enjeux" color="#8b5cf6" description="Ce sur quoi je vais me concentrer prochainement" /> 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>
</div> </div>
@@ -428,9 +475,9 @@ export default function Home() {
Pourquoi créer une météo personnelle ? Pourquoi créer une météo personnelle ?
</h3> </h3>
<p className="text-muted mb-4"> <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. La météo est un outil simple et visuel pour exprimer rapidement votre état sur 4
En la partageant avec votre équipe, vous créez de la transparence et facilitez la communication axes clés. En la partageant avec votre équipe, vous créez de la transparence et
sur votre bien-être et votre performance. facilitez la communication sur votre bien-être et votre performance.
</p> </p>
<ul className="space-y-2 text-sm text-muted"> <ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
@@ -459,10 +506,30 @@ export default function Home() {
Les 4 axes de la météo Les 4 axes de la météo
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
<CategoryPill icon="☀️" name="Performance" color="#f59e0b" description="Votre performance personnelle et l'atteinte de vos objectifs" /> <CategoryPill
<CategoryPill icon="😊" name="Moral" color="#22c55e" description="Votre moral actuel et votre ressenti" /> icon="☀️"
<CategoryPill icon="🌊" name="Flux" color="#3b82f6" description="Votre flux de travail personnel et les blocages éventuels" /> name="Performance"
<CategoryPill icon="💎" name="Création de valeur" color="#8b5cf6" description="Votre création de valeur et votre apport" /> 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>
</div> </div>
@@ -504,7 +571,9 @@ export default function Home() {
<span className="text-4xl">🎯</span> <span className="text-4xl">🎯</span>
<div> <div>
<h2 className="text-3xl font-bold text-foreground">OKRs & Équipes</h2> <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> <p className="text-purple-500 font-medium">
Définissez et suivez les objectifs de votre équipe
</p>
</div> </div>
</div> </div>
@@ -516,9 +585,10 @@ export default function Home() {
Pourquoi utiliser les OKRs ? Pourquoi utiliser les OKRs ?
</h3> </h3>
<p className="text-muted mb-4"> <p className="text-muted mb-4">
Les OKRs (Objectives and Key Results) sont un cadre de gestion d&apos;objectifs qui permet Les OKRs (Objectives and Key Results) sont un cadre de gestion d&apos;objectifs qui
d&apos;aligner les efforts de l&apos;équipe autour d&apos;objectifs communs et mesurables. permet d&apos;aligner les efforts de l&apos;équipe autour d&apos;objectifs communs
Cette méthode favorise la transparence, la responsabilisation et la performance collective. et mesurables. Cette méthode favorise la transparence, la responsabilisation et la
performance collective.
</p> </p>
<ul className="space-y-2 text-sm text-muted"> <ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">

View File

@@ -125,7 +125,12 @@ interface WeatherSession {
canEdit?: boolean; canEdit?: boolean;
} }
type AnySession = SwotSession | MotivatorSession | YearReviewSession | WeeklyCheckInSession | WeatherSession; type AnySession =
| SwotSession
| MotivatorSession
| YearReviewSession
| WeeklyCheckInSession
| WeatherSession;
interface WorkshopTabsProps { interface WorkshopTabsProps {
swotSessions: SwotSession[]; swotSessions: SwotSession[];
@@ -250,7 +255,9 @@ export function WorkshopTabs({
// Separate by ownership (for non-team tab: owned, shared, teamCollab) // Separate by ownership (for non-team tab: owned, shared, teamCollab)
const ownedSessions = filteredSessions.filter((s) => s.isOwner); const ownedSessions = filteredSessions.filter((s) => s.isOwner);
const sharedSessions = filteredSessions.filter((s) => !s.isOwner && !(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab); const sharedSessions = filteredSessions.filter(
(s) => !s.isOwner && !(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab
);
const teamCollabFiltered = const teamCollabFiltered =
activeTab === 'all' ? teamCollabSessions : activeTab === 'team' ? teamCollabSessions : []; activeTab === 'all' ? teamCollabSessions : activeTab === 'team' ? teamCollabSessions : [];
@@ -342,7 +349,8 @@ export function WorkshopTabs({
🏢 Ateliers de l&apos;équipe non partagés ({teamCollabSessions.length}) 🏢 Ateliers de l&apos;équipe non partagés ({teamCollabSessions.length})
</h2> </h2>
<p className="text-sm text-muted mb-4"> <p className="text-sm text-muted mb-4">
En tant qu&apos;admin d&apos;équipe, vous voyez les ateliers de vos collaborateurs qui ne vous sont pas encore partagés. En tant qu&apos;admin d&apos;équipe, vous voyez les ateliers de vos collaborateurs
qui ne vous sont pas encore partagés.
</p> </p>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{teamCollabSessions.map((s) => ( {teamCollabSessions.map((s) => (
@@ -436,7 +444,7 @@ function TypeFilterDropdown({
<span>{current.icon}</span> <span>{current.icon}</span>
<span>{current.label}</span> <span>{current.label}</span>
<Badge variant={isTypeSelected ? 'default' : 'primary'} className="ml-1 text-xs"> <Badge variant={isTypeSelected ? 'default' : 'primary'} className="ml-1 text-xs">
{isTypeSelected ? counts[activeTab] ?? 0 : totalCount} {isTypeSelected ? (counts[activeTab] ?? 0) : totalCount}
</Badge> </Badge>
<svg <svg
className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`} className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`}
@@ -525,7 +533,13 @@ function TabButton({
); );
} }
function SessionCard({ session, isTeamCollab = false }: { session: AnySession; isTeamCollab?: boolean }) { function SessionCard({
session,
isTeamCollab = false,
}: {
session: AnySession;
isTeamCollab?: boolean;
}) {
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -618,12 +632,12 @@ function SessionCard({ session, isTeamCollab = false }: { session: AnySession; i
const editParticipantLabel = workshop.participantLabel; const editParticipantLabel = workshop.participantLabel;
const cardContent = ( const cardContent = (
<Card hover={!isTeamCollab} className={`h-full p-4 relative overflow-hidden ${isTeamCollab ? 'opacity-60' : ''}`}> <Card
hover={!isTeamCollab}
className={`h-full p-4 relative overflow-hidden ${isTeamCollab ? 'opacity-60' : ''}`}
>
{/* Accent bar */} {/* Accent bar */}
<div <div className="absolute top-0 left-0 right-0 h-1" style={{ backgroundColor: accentColor }} />
className="absolute top-0 left-0 right-0 h-1"
style={{ backgroundColor: accentColor }}
/>
{/* Header: Icon + Title + Role badge */} {/* Header: Icon + Title + Role badge */}
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">

View File

@@ -62,15 +62,17 @@ export default function EditOKRPage() {
order?: number; order?: number;
}; };
const handleSubmit = async (data: CreateOKRInput & { const handleSubmit = async (
data: CreateOKRInput & {
startDate: Date | string; startDate: Date | string;
endDate: Date | string; endDate: Date | string;
keyResultsUpdates?: { keyResultsUpdates?: {
create?: CreateKeyResultInput[]; create?: CreateKeyResultInput[];
update?: KeyResultUpdate[]; update?: KeyResultUpdate[];
delete?: string[] delete?: string[];
};
} }
}) => { ) => {
// Convert to UpdateOKRInput format // Convert to UpdateOKRInput format
const updateData = { const updateData = {
objective: data.objective, objective: data.objective,
@@ -164,4 +166,3 @@ export default function EditOKRPage() {
</main> </main>
); );
} }

View File

@@ -186,9 +186,7 @@ export default function OKRDetailPage() {
> >
{okr.period} {okr.period}
</Badge> </Badge>
<Badge style={getOKRStatusColor(okr.status)}> <Badge style={getOKRStatusColor(okr.status)}>{OKR_STATUS_LABELS[okr.status]}</Badge>
{OKR_STATUS_LABELS[okr.status]}
</Badge>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@@ -269,13 +267,10 @@ export default function OKRDetailPage() {
/> />
)) ))
) : ( ) : (
<Card className="p-8 text-center text-muted"> <Card className="p-8 text-center text-muted">Aucun Key Result défini</Card>
Aucun Key Result défini
</Card>
)} )}
</div> </div>
</div> </div>
</main> </main>
); );
} }

View File

@@ -79,4 +79,3 @@ export default function NewOKRPage() {
</main> </main>
); );
} }

View File

@@ -82,4 +82,3 @@ export default async function TeamDetailPage({ params }: TeamDetailPageProps) {
</main> </main>
); );
} }

View File

@@ -18,7 +18,7 @@ export default function NewTeamPage() {
e.preventDefault(); e.preventDefault();
if (!name.trim()) { if (!name.trim()) {
alert('Le nom de l\'équipe est requis'); alert("Le nom de l'équipe est requis");
return; return;
} }
@@ -32,7 +32,7 @@ export default function NewTeamPage() {
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
alert(error.error || 'Erreur lors de la création de l\'équipe'); alert(error.error || "Erreur lors de la création de l'équipe");
return; return;
} }
@@ -41,7 +41,7 @@ export default function NewTeamPage() {
router.refresh(); router.refresh();
} catch (error) { } catch (error) {
console.error('Error creating team:', error); console.error('Error creating team:', error);
alert('Erreur lors de la création de l\'équipe'); alert("Erreur lors de la création de l'équipe");
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -81,7 +81,7 @@ export default function NewTeamPage() {
disabled={submitting} disabled={submitting}
className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent" className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"
> >
{submitting ? 'Création...' : 'Créer l\'équipe'} {submitting ? 'Création...' : "Créer l'équipe"}
</Button> </Button>
</div> </div>
</form> </form>
@@ -89,4 +89,3 @@ export default function NewTeamPage() {
</main> </main>
); );
} }

View File

@@ -4,7 +4,12 @@ import { auth } from '@/lib/auth';
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops'; import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
import { getWeatherSessionById, getPreviousWeatherEntriesForUsers } from '@/services/weather'; import { getWeatherSessionById, getPreviousWeatherEntriesForUsers } from '@/services/weather';
import { getUserTeams } from '@/services/teams'; import { getUserTeams } from '@/services/teams';
import { WeatherBoard, WeatherLiveWrapper, WeatherInfoPanel, WeatherAverageBar } from '@/components/weather'; import {
WeatherBoard,
WeatherLiveWrapper,
WeatherInfoPanel,
WeatherAverageBar,
} from '@/components/weather';
import { Badge } from '@/components/ui'; import { Badge } from '@/components/ui';
import { EditableWeatherTitle } from '@/components/ui/EditableWeatherTitle'; import { EditableWeatherTitle } from '@/components/ui/EditableWeatherTitle';
@@ -26,10 +31,7 @@ export default async function WeatherSessionPage({ params }: WeatherSessionPageP
notFound(); notFound();
} }
const allUserIds = [ const allUserIds = [session.user.id, ...session.shares.map((s: { userId: string }) => s.userId)];
session.user.id,
...session.shares.map((s: { userId: string }) => s.userId),
];
const [previousEntries, userTeams] = await Promise.all([ const [previousEntries, userTeams] = await Promise.all([
getPreviousWeatherEntriesForUsers(session.id, session.date, allUserIds), getPreviousWeatherEntriesForUsers(session.id, session.date, allUserIds),

View File

@@ -19,7 +19,9 @@ export default function NewWeatherPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]); const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [title, setTitle] = useState(() => getWeekYearLabel(new Date(new Date().toISOString().split('T')[0]))); const [title, setTitle] = useState(() =>
getWeekYearLabel(new Date(new Date().toISOString().split('T')[0]))
);
const [isTitleManuallyEdited, setIsTitleManuallyEdited] = useState(false); const [isTitleManuallyEdited, setIsTitleManuallyEdited] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
@@ -69,7 +71,8 @@ export default function NewWeatherPage() {
Nouvelle Météo Nouvelle Météo
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Créez une météo personnelle pour faire le point sur 4 axes clés et partagez-la avec votre équipe Créez une météo personnelle pour faire le point sur 4 axes clés et partagez-la avec
votre équipe
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -109,7 +112,8 @@ export default function NewWeatherPage() {
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3> <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"> <ol className="text-sm text-muted space-y-1 list-decimal list-inside">
<li> <li>
<strong>Performance</strong> : Comment évaluez-vous votre performance personnelle ? <strong>Performance</strong> : Comment évaluez-vous votre performance personnelle
?
</li> </li>
<li> <li>
<strong>Moral</strong> : Quel est votre moral actuel ? <strong>Moral</strong> : Quel est votre moral actuel ?
@@ -118,11 +122,13 @@ export default function NewWeatherPage() {
<strong>Flux</strong> : Comment se passe votre flux de travail personnel ? <strong>Flux</strong> : Comment se passe votre flux de travail personnel ?
</li> </li>
<li> <li>
<strong>Création de valeur</strong> : Comment évaluez-vous votre création de valeur ? <strong>Création de valeur</strong> : Comment évaluez-vous votre création de
valeur ?
</li> </li>
</ol> </ol>
<p className="text-sm text-muted mt-2"> <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 ! 💡 <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> </p>
</div> </div>

View File

@@ -99,9 +99,7 @@ export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckIn
const participantTeamIds = new Set( const participantTeamIds = new Set(
currentQuarterOKRs.map((okr) => okr.team?.id).filter(Boolean) as string[] currentQuarterOKRs.map((okr) => okr.team?.id).filter(Boolean) as string[]
); );
const adminTeamIds = userTeams const adminTeamIds = userTeams.filter((t) => t.userRole === 'ADMIN').map((t) => t.id);
.filter((t) => t.userRole === 'ADMIN')
.map((t) => t.id);
return adminTeamIds.some((tid) => participantTeamIds.has(tid)); return adminTeamIds.some((tid) => participantTeamIds.has(tid));
})() })()
} }

View File

@@ -20,7 +20,9 @@ export default function NewWeeklyCheckInPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]); const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [title, setTitle] = useState(() => getWeekYearLabel(new Date(new Date().toISOString().split('T')[0]))); const [title, setTitle] = useState(() =>
getWeekYearLabel(new Date(new Date().toISOString().split('T')[0]))
);
const [isTitleManuallyEdited, setIsTitleManuallyEdited] = useState(false); const [isTitleManuallyEdited, setIsTitleManuallyEdited] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {

View File

@@ -135,4 +135,3 @@ export default function NewYearReviewPage() {
</main> </main>
); );
} }

View File

@@ -18,9 +18,15 @@ interface ShareModalConfig {
interface BaseSessionLiveWrapperConfig { interface BaseSessionLiveWrapperConfig {
apiPath: LiveApiPath; apiPath: LiveApiPath;
shareModal: ShareModalConfig; shareModal: ShareModalConfig;
onShareWithEmail: (email: string, role: ShareRole) => Promise<{ success: boolean; error?: string }>; onShareWithEmail: (
email: string,
role: ShareRole
) => Promise<{ success: boolean; error?: string }>;
onRemoveShare: (userId: string) => Promise<unknown>; onRemoveShare: (userId: string) => Promise<unknown>;
onShareWithTeam?: (teamId: string, role: ShareRole) => Promise<{ success: boolean; error?: string }>; onShareWithTeam?: (
teamId: string,
role: ShareRole
) => Promise<{ success: boolean; error?: string }>;
} }
interface BaseSessionLiveWrapperProps { interface BaseSessionLiveWrapperProps {

View File

@@ -23,8 +23,14 @@ interface ShareModalProps {
isOwner: boolean; isOwner: boolean;
userTeams?: TeamWithMembers[]; userTeams?: TeamWithMembers[];
currentUserId?: string; currentUserId?: string;
onShareWithEmail: (email: string, role: ShareRole) => Promise<{ success: boolean; error?: string }>; onShareWithEmail: (
onShareWithTeam?: (teamId: string, role: ShareRole) => Promise<{ success: boolean; error?: string }>; email: string,
role: ShareRole
) => Promise<{ success: boolean; error?: string }>;
onShareWithTeam?: (
teamId: string,
role: ShareRole
) => Promise<{ success: boolean; error?: string }>;
onRemoveShare: (userId: string) => Promise<unknown>; onRemoveShare: (userId: string) => Promise<unknown>;
helpText?: React.ReactNode; helpText?: React.ReactNode;
} }
@@ -76,7 +82,7 @@ export function ShareModal({
} else { } else {
const targetEmail = const targetEmail =
shareType === 'teamMember' shareType === 'teamMember'
? teamMembers.find((m) => m.id === selectedMemberId)?.email ?? '' ? (teamMembers.find((m) => m.id === selectedMemberId)?.email ?? '')
: email; : email;
result = await onShareWithEmail(targetEmail, role); result = await onShareWithEmail(targetEmail, role);
} }
@@ -154,8 +160,8 @@ export function ShareModal({
<div className="space-y-2"> <div className="space-y-2">
{teamMembers.length === 0 ? ( {teamMembers.length === 0 ? (
<p className="text-sm text-muted"> <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. Vous n&apos;êtes membre d&apos;aucune équipe ou vos équipes n&apos;ont pas
Créez une équipe depuis la page{' '} d&apos;autres membres. Créez une équipe depuis la page{' '}
<Link href="/teams" className="text-primary hover:underline"> <Link href="/teams" className="text-primary hover:underline">
Équipes Équipes
</Link> </Link>
@@ -271,7 +277,12 @@ export function ShareModal({
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive" className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
title="Retirer l'accès" title="Retirer l'accès"
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4"> <svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path <path
fillRule="evenodd" fillRule="evenodd"
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z" d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"

View File

@@ -183,4 +183,3 @@ export function KeyResultItem({ keyResult, okrId, canEdit, onUpdate }: KeyResult
</div> </div>
); );
} }

View File

@@ -82,7 +82,9 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
onClick={() => setShowAllPeriods(!showAllPeriods)} onClick={() => setShowAllPeriods(!showAllPeriods)}
className="text-sm" className="text-sm"
> >
{showAllPeriods ? `Afficher ${currentQuarterPeriod} uniquement` : 'Afficher tous les OKR'} {showAllPeriods
? `Afficher ${currentQuarterPeriod} uniquement`
: 'Afficher tous les OKR'}
</Button> </Button>
</div> </div>
</div> </div>
@@ -107,9 +109,7 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
<div className="mb-6 flex items-center justify-between gap-4"> <div className="mb-6 flex items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="text-2xl font-bold text-foreground">OKRs</h2> <h2 className="text-2xl font-bold text-foreground">OKRs</h2>
{!showAllPeriods && ( {!showAllPeriods && <span className="text-sm text-muted">({currentQuarterPeriod})</span>}
<span className="text-sm text-muted">({currentQuarterPeriod})</span>
)}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button <Button
@@ -117,7 +117,9 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
onClick={() => setShowAllPeriods(!showAllPeriods)} onClick={() => setShowAllPeriods(!showAllPeriods)}
className="text-sm" className="text-sm"
> >
{showAllPeriods ? `Afficher ${currentQuarterPeriod} uniquement` : 'Afficher tous les OKR'} {showAllPeriods
? `Afficher ${currentQuarterPeriod} uniquement`
: 'Afficher tous les OKR'}
</Button> </Button>
<ToggleGroup <ToggleGroup
value={cardViewMode} value={cardViewMode}

View File

@@ -351,9 +351,7 @@ export function ActionPanel({
})} })}
</div> </div>
{editingSelectedItems.length < 2 && ( {editingSelectedItems.length < 2 && (
<p className="mt-2 text-xs text-destructive"> <p className="mt-2 text-xs text-destructive">Sélectionnez au moins 2 items SWOT</p>
Sélectionnez au moins 2 items SWOT
</p>
)} )}
</div> </div>
)} )}

View File

@@ -21,7 +21,8 @@ const categoryStyles: Record<SwotCategory, { ring: string; text: string }> = {
THREAT: { ring: 'ring-threat', text: 'text-threat' }, THREAT: { ring: 'ring-threat', text: 'text-threat' },
}; };
export const SwotCard = memo(forwardRef<HTMLDivElement, SwotCardProps>( export const SwotCard = memo(
forwardRef<HTMLDivElement, SwotCardProps>(
( (
{ item, sessionId, isSelected, isHighlighted, isDragging, linkMode, onSelect, ...props }, { item, sessionId, isSelected, isHighlighted, isDragging, linkMode, onSelect, ...props },
ref ref
@@ -196,5 +197,6 @@ export const SwotCard = memo(forwardRef<HTMLDivElement, SwotCardProps>(
</div> </div>
); );
} }
)); )
);
SwotCard.displayName = 'SwotCard'; SwotCard.displayName = 'SwotCard';

View File

@@ -21,7 +21,12 @@ interface AddMemberModalProps {
onSuccess: () => void; onSuccess: () => void;
} }
export function AddMemberModal({ teamId, existingMemberIds, onClose, onSuccess }: AddMemberModalProps) { export function AddMemberModal({
teamId,
existingMemberIds,
onClose,
onSuccess,
}: AddMemberModalProps) {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [selectedUserId, setSelectedUserId] = useState<string | null>(null); const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
@@ -71,7 +76,7 @@ export function AddMemberModal({ teamId, existingMemberIds, onClose, onSuccess }
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
alert(error.error || 'Erreur lors de l\'ajout du membre'); alert(error.error || "Erreur lors de l'ajout du membre");
return; return;
} }
@@ -79,7 +84,7 @@ export function AddMemberModal({ teamId, existingMemberIds, onClose, onSuccess }
onClose(); onClose();
} catch (error) { } catch (error) {
console.error('Error adding member:', error); console.error('Error adding member:', error);
alert('Erreur lors de l\'ajout du membre'); alert("Erreur lors de l'ajout du membre");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -157,4 +162,3 @@ export function AddMemberModal({ teamId, existingMemberIds, onClose, onSuccess }
</Modal> </Modal>
); );
} }

View File

@@ -45,7 +45,7 @@ export function MembersList({ members, teamId, isAdmin, onMemberUpdate }: Member
}; };
const handleRemoveMember = async (userId: string) => { const handleRemoveMember = async (userId: string) => {
if (!confirm('Êtes-vous sûr de vouloir retirer ce membre de l\'équipe ?')) { if (!confirm("Êtes-vous sûr de vouloir retirer ce membre de l'équipe ?")) {
return; return;
} }
@@ -172,4 +172,3 @@ export function MembersList({ members, teamId, isAdmin, onMemberUpdate }: Member
</div> </div>
); );
} }

View File

@@ -24,7 +24,9 @@ export function TeamCard({ team }: TeamCardProps) {
<span className="text-2xl">👥</span> <span className="text-2xl">👥</span>
<CardTitle>{team.name}</CardTitle> <CardTitle>{team.name}</CardTitle>
</div> </div>
{team.description && <CardDescription className="mt-2">{team.description}</CardDescription>} {team.description && (
<CardDescription className="mt-2">{team.description}</CardDescription>
)}
</div> </div>
{isAdmin && ( {isAdmin && (
<Badge <Badge
@@ -49,11 +51,15 @@ export function TeamCard({ team }: TeamCardProps) {
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
/> />
</svg> </svg>
<span>{memberCount} membre{memberCount !== 1 ? 's' : ''}</span> <span>
{memberCount} membre{memberCount !== 1 ? 's' : ''}
</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-lg">🎯</span> <span className="text-lg">🎯</span>
<span>{okrCount} OKR{okrCount !== 1 ? 's' : ''}</span> <span>
{okrCount} OKR{okrCount !== 1 ? 's' : ''}
</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -61,4 +67,3 @@ export function TeamCard({ team }: TeamCardProps) {
</Link> </Link>
); );
} }

View File

@@ -17,6 +17,12 @@ export function TeamDetailClient({ members, teamId, isAdmin }: TeamDetailClientP
router.refresh(); router.refresh();
}; };
return <MembersList members={members} teamId={teamId} isAdmin={isAdmin} onMemberUpdate={handleMemberUpdate} />; return (
<MembersList
members={members}
teamId={teamId}
isAdmin={isAdmin}
onMemberUpdate={handleMemberUpdate}
/>
);
} }

View File

@@ -26,4 +26,3 @@ export function EditableMotivatorTitle({
/> />
); );
} }

View File

@@ -26,4 +26,3 @@ export function EditableSessionTitle({
/> />
); );
} }

View File

@@ -9,19 +9,17 @@ interface EditableTitleProps {
onUpdate: (sessionId: string, title: string) => Promise<{ success: boolean; error?: string }>; onUpdate: (sessionId: string, title: string) => Promise<{ success: boolean; error?: string }>;
} }
export function EditableTitle({ export function EditableTitle({ sessionId, initialTitle, canEdit, onUpdate }: EditableTitleProps) {
sessionId,
initialTitle,
canEdit,
onUpdate,
}: EditableTitleProps) {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editingTitle, setEditingTitle] = useState(''); const [editingTitle, setEditingTitle] = useState('');
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Use editingTitle when editing, otherwise use initialTitle (synced from SSE) // Use editingTitle when editing, otherwise use initialTitle (synced from SSE)
const title = useMemo(() => (isEditing ? editingTitle : initialTitle), [isEditing, editingTitle, initialTitle]); const title = useMemo(
() => (isEditing ? editingTitle : initialTitle),
[isEditing, editingTitle, initialTitle]
);
useEffect(() => { useEffect(() => {
if (isEditing && inputRef.current) { if (isEditing && inputRef.current) {
@@ -110,4 +108,3 @@ export function EditableTitle({
</button> </button>
); );
} }

View File

@@ -26,4 +26,3 @@ export function EditableYearReviewTitle({
/> />
); );
} }

View File

@@ -90,9 +90,16 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
</option> </option>
))} ))}
</select> </select>
<div className={`pointer-events-none absolute ${iconPosition} top-1/2 -translate-y-1/2 text-muted-foreground`}> <div
className={`pointer-events-none absolute ${iconPosition} top-1/2 -translate-y-1/2 text-muted-foreground`}
>
<svg className={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg> </svg>
</div> </div>
</div> </div>

View File

@@ -22,7 +22,9 @@ export function ToggleGroup<T extends string>({
className = '', className = '',
}: ToggleGroupProps<T>) { }: ToggleGroupProps<T>) {
return ( return (
<div className={`flex items-center gap-2 rounded-lg border border-border bg-card p-1 ${className}`}> <div
className={`flex items-center gap-2 rounded-lg border border-border bg-card p-1 ${className}`}
>
{options.map((option) => ( {options.map((option) => (
<button <button
key={option.value} key={option.value}
@@ -30,7 +32,8 @@ export function ToggleGroup<T extends string>({
onClick={() => onChange(option.value)} onClick={() => onChange(option.value)}
className={` className={`
flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors
${value === option.value ${
value === option.value
? 'bg-[#8b5cf6] text-white shadow-sm' ? 'bg-[#8b5cf6] text-white shadow-sm'
: 'text-muted hover:text-foreground hover:bg-card-hover' : 'text-muted hover:text-foreground hover:bg-card-hover'
} }
@@ -43,4 +46,3 @@ export function ToggleGroup<T extends string>({
</div> </div>
); );
} }

View File

@@ -24,9 +24,7 @@ export function WeatherAverageBar({ entries }: WeatherAverageBarProps) {
return ( return (
<div className="flex flex-wrap items-center gap-3 mb-4"> <div className="flex flex-wrap items-center gap-3 mb-4">
<span className="text-xs font-medium text-muted uppercase tracking-wide"> <span className="text-xs font-medium text-muted uppercase tracking-wide">Moyenne équipe</span>
Moyenne équipe
</span>
{AXES.map(({ key, label }) => { {AXES.map(({ key, label }) => {
const avg = getAverageEmoji(entries.map((e) => e[key])); const avg = getAverageEmoji(entries.map((e) => e[key]));
return ( return (

View File

@@ -89,7 +89,13 @@ function EvolutionIndicator({
); );
} }
export const WeatherCard = memo(function WeatherCard({ sessionId, currentUserId, entry, canEdit, previousEntry }: WeatherCardProps) { export const WeatherCard = memo(function WeatherCard({
sessionId,
currentUserId,
entry,
canEdit,
previousEntry,
}: WeatherCardProps) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
// Track entry version to reset local state when props change (SSE refresh) // Track entry version to reset local state when props change (SSE refresh)
const [entryVersion, setEntryVersion] = useState(entry); const [entryVersion, setEntryVersion] = useState(entry);
@@ -112,7 +118,10 @@ export const WeatherCard = memo(function WeatherCard({ sessionId, currentUserId,
const isCurrentUser = entry.userId === currentUserId; const isCurrentUser = entry.userId === currentUserId;
const canEditThis = canEdit && isCurrentUser; const canEditThis = canEdit && isCurrentUser;
function handleEmojiChange(axis: 'performance' | 'moral' | 'flux' | 'valueCreation', emoji: string | null) { function handleEmojiChange(
axis: 'performance' | 'moral' | 'flux' | 'valueCreation',
emoji: string | null
) {
if (!canEditThis) return; if (!canEditThis) return;
// Calculate new values // Calculate new values
@@ -190,12 +199,18 @@ export const WeatherCard = memo(function WeatherCard({ sessionId, currentUserId,
wrapperClassName="!w-fit" wrapperClassName="!w-fit"
className="!w-16 min-w-16 text-center text-lg py-2.5" className="!w-16 min-w-16 text-center text-lg py-2.5"
/> />
<EvolutionIndicator current={performanceEmoji} previous={previousEntry?.performanceEmoji ?? null} /> <EvolutionIndicator
current={performanceEmoji}
previous={previousEntry?.performanceEmoji ?? null}
/>
</div> </div>
) : ( ) : (
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
<span className="text-2xl">{performanceEmoji || '-'}</span> <span className="text-2xl">{performanceEmoji || '-'}</span>
<EvolutionIndicator current={performanceEmoji} previous={previousEntry?.performanceEmoji ?? null} /> <EvolutionIndicator
current={performanceEmoji}
previous={previousEntry?.performanceEmoji ?? null}
/>
</div> </div>
)} )}
</td> </td>
@@ -256,12 +271,18 @@ export const WeatherCard = memo(function WeatherCard({ sessionId, currentUserId,
wrapperClassName="!w-fit" wrapperClassName="!w-fit"
className="!w-16 min-w-16 text-center text-lg py-2.5" className="!w-16 min-w-16 text-center text-lg py-2.5"
/> />
<EvolutionIndicator current={valueCreationEmoji} previous={previousEntry?.valueCreationEmoji ?? null} /> <EvolutionIndicator
current={valueCreationEmoji}
previous={previousEntry?.valueCreationEmoji ?? null}
/>
</div> </div>
) : ( ) : (
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
<span className="text-2xl">{valueCreationEmoji || '-'}</span> <span className="text-2xl">{valueCreationEmoji || '-'}</span>
<EvolutionIndicator current={valueCreationEmoji} previous={previousEntry?.valueCreationEmoji ?? null} /> <EvolutionIndicator
current={valueCreationEmoji}
previous={previousEntry?.valueCreationEmoji ?? null}
/>
</div> </div>
)} )}
</td> </td>

View File

@@ -11,7 +11,9 @@ export function WeatherInfoPanel() {
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between px-4 py-2.5 text-left transition-colors hover:bg-card" className="w-full flex items-center justify-between px-4 py-2.5 text-left transition-colors hover:bg-card"
> >
<h3 className="text-sm font-semibold text-foreground">Les 4 axes de la météo personnelle</h3> <h3 className="text-sm font-semibold text-foreground">
Les 4 axes de la météo personnelle
</h3>
<svg <svg
className={`h-4 w-4 text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`} className={`h-4 w-4 text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none" fill="none"

View File

@@ -1,7 +1,11 @@
'use client'; 'use client';
import { BaseSessionLiveWrapper } from '@/components/collaboration/BaseSessionLiveWrapper'; import { BaseSessionLiveWrapper } from '@/components/collaboration/BaseSessionLiveWrapper';
import { shareWeatherSession, shareWeatherSessionToTeam, removeWeatherShare } from '@/actions/weather'; import {
shareWeatherSession,
shareWeatherSessionToTeam,
removeWeatherShare,
} from '@/actions/weather';
import type { TeamWithMembers, Share } from '@/lib/share-utils'; import type { TeamWithMembers, Share } from '@/lib/share-utils';
interface WeatherLiveWrapperProps { interface WeatherLiveWrapperProps {

View File

@@ -47,7 +47,12 @@ export function CurrentQuarterOKRs({ okrs, period, canEdit = false }: CurrentQua
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg> </svg>
</button> </button>
</CardTitle> </CardTitle>
@@ -81,7 +86,9 @@ export function CurrentQuarterOKRs({ okrs, period, canEdit = false }: CurrentQua
{OKR_STATUS_LABELS[okr.status]} {OKR_STATUS_LABELS[okr.status]}
</Badge> </Badge>
{okr.progress !== undefined && ( {okr.progress !== undefined && (
<span className="text-xs text-muted whitespace-nowrap">{okr.progress}%</span> <span className="text-xs text-muted whitespace-nowrap">
{okr.progress}%
</span>
)} )}
</div> </div>
{okr.description && ( {okr.description && (
@@ -100,7 +107,8 @@ export function CurrentQuarterOKRs({ okrs, period, canEdit = false }: CurrentQua
))} ))}
{okr.keyResults.length > 5 && ( {okr.keyResults.length > 5 && (
<li className="text-xs text-muted pl-3.5"> <li className="text-xs text-muted pl-3.5">
+{okr.keyResults.length - 5} autre{okr.keyResults.length - 5 > 1 ? 's' : ''} +{okr.keyResults.length - 5} autre
{okr.keyResults.length - 5 > 1 ? 's' : ''}
</li> </li>
)} )}
</ul> </ul>
@@ -123,7 +131,12 @@ export function CurrentQuarterOKRs({ okrs, period, canEdit = false }: CurrentQua
> >
Voir tous les objectifs Voir tous les objectifs
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg> </svg>
</Link> </Link>
</div> </div>
@@ -222,8 +235,7 @@ function EditableKeyResultRow({
const [currentValue, setCurrentValue] = useState(kr.currentValue); const [currentValue, setCurrentValue] = useState(kr.currentValue);
const [updating, setUpdating] = useState(false); const [updating, setUpdating] = useState(false);
const krProgress = const krProgress = kr.targetValue > 0 ? Math.round((kr.currentValue / kr.targetValue) * 100) : 0;
kr.targetValue > 0 ? Math.round((kr.currentValue / kr.targetValue) * 100) : 0;
const handleSave = async () => { const handleSave = async () => {
setUpdating(true); setUpdating(true);
@@ -263,7 +275,9 @@ function EditableKeyResultRow({
step="0.1" step="0.1"
className="h-6 w-16 text-xs" className="h-6 w-16 text-xs"
/> />
<span className="text-muted">/ {kr.targetValue} {kr.unit}</span> <span className="text-muted">
/ {kr.targetValue} {kr.unit}
</span>
</div> </div>
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
<Button size="sm" onClick={handleSave} disabled={updating} className="h-6 text-xs"> <Button size="sm" onClick={handleSave} disabled={updating} className="h-6 text-xs">

View File

@@ -12,7 +12,8 @@ interface WeeklyCheckInCardProps {
isDragging: boolean; isDragging: boolean;
} }
export const WeeklyCheckInCard = memo(forwardRef<HTMLDivElement, WeeklyCheckInCardProps>( export const WeeklyCheckInCard = memo(
forwardRef<HTMLDivElement, WeeklyCheckInCardProps>(
({ item, sessionId, isDragging, ...props }, ref) => { ({ item, sessionId, isDragging, ...props }, ref) => {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(item.content); const [content, setContent] = useState(item.content);
@@ -163,7 +164,12 @@ export const WeeklyCheckInCard = memo(forwardRef<HTMLDivElement, WeeklyCheckInCa
className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground" className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
aria-label="Modifier" aria-label="Modifier"
> >
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -180,7 +186,12 @@ export const WeeklyCheckInCard = memo(forwardRef<HTMLDivElement, WeeklyCheckInCa
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive" className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
aria-label="Supprimer" aria-label="Supprimer"
> >
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -195,5 +206,6 @@ export const WeeklyCheckInCard = memo(forwardRef<HTMLDivElement, WeeklyCheckInCa
</div> </div>
); );
} }
)); )
);
WeeklyCheckInCard.displayName = 'WeeklyCheckInCard'; WeeklyCheckInCard.displayName = 'WeeklyCheckInCard';

View File

@@ -11,7 +11,8 @@ interface YearReviewCardProps {
isDragging: boolean; isDragging: boolean;
} }
export const YearReviewCard = memo(forwardRef<HTMLDivElement, YearReviewCardProps>( export const YearReviewCard = memo(
forwardRef<HTMLDivElement, YearReviewCardProps>(
({ item, sessionId, isDragging, ...props }, ref) => { ({ item, sessionId, isDragging, ...props }, ref) => {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(item.content); const [content, setContent] = useState(item.content);
@@ -94,7 +95,12 @@ export const YearReviewCard = memo(forwardRef<HTMLDivElement, YearReviewCardProp
className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground" className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
aria-label="Modifier" aria-label="Modifier"
> >
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -111,7 +117,12 @@ export const YearReviewCard = memo(forwardRef<HTMLDivElement, YearReviewCardProp
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive" className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
aria-label="Supprimer" aria-label="Supprimer"
> >
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -126,5 +137,6 @@ export const YearReviewCard = memo(forwardRef<HTMLDivElement, YearReviewCardProp
</div> </div>
); );
} }
)); )
);
YearReviewCard.displayName = 'YearReviewCard'; YearReviewCard.displayName = 'YearReviewCard';

View File

@@ -55,4 +55,3 @@ export function YearReviewLiveWrapper({
</BaseSessionLiveWrapper> </BaseSessionLiveWrapper>
); );
} }

View File

@@ -131,4 +131,3 @@ export function useLive({
return { isConnected, lastEvent, error }; return { isConnected, lastEvent, error };
} }

View File

@@ -39,14 +39,12 @@ export function getTeamMembersForShare(
currentUserId: string currentUserId: string
): TeamMemberUser[] { ): TeamMemberUser[] {
const seen = new Set<string>(); const seen = new Set<string>();
return ( return userTeams
userTeams
.flatMap((t) => t.members ?? []) .flatMap((t) => t.members ?? [])
.map((m) => m.user) .map((m) => m.user)
.filter((u) => { .filter((u) => {
if (u.id === currentUserId || seen.has(u.id)) return false; if (u.id === currentUserId || seen.has(u.id)) return false;
seen.add(u.id); seen.add(u.id);
return true; return true;
}) });
);
} }

View File

@@ -393,28 +393,26 @@ export const YEAR_REVIEW_SECTIONS: YearReviewSectionConfig[] = [
category: 'GOALS', category: 'GOALS',
title: 'Objectifs', title: 'Objectifs',
icon: '🎯', icon: '🎯',
description: 'Ce que vous souhaitez accomplir l\'année prochaine', description: "Ce que vous souhaitez accomplir l'année prochaine",
color: '#8b5cf6', // purple color: '#8b5cf6', // purple
}, },
{ {
category: 'MOMENTS', category: 'MOMENTS',
title: 'Moments', title: 'Moments',
icon: '⭐', icon: '⭐',
description: 'Les moments forts et marquants de l\'année', description: "Les moments forts et marquants de l'année",
color: '#f59e0b', // amber color: '#f59e0b', // amber
}, },
]; ];
export const YEAR_REVIEW_BY_CATEGORY: Record< export const YEAR_REVIEW_BY_CATEGORY: Record<YearReviewCategory, YearReviewSectionConfig> =
YearReviewCategory, YEAR_REVIEW_SECTIONS.reduce(
YearReviewSectionConfig
> = YEAR_REVIEW_SECTIONS.reduce(
(acc, config) => { (acc, config) => {
acc[config.category] = config; acc[config.category] = config;
return acc; return acc;
}, },
{} as Record<YearReviewCategory, YearReviewSectionConfig> {} as Record<YearReviewCategory, YearReviewSectionConfig>
); );
// ============================================ // ============================================
// Teams & OKRs - Type Definitions // Teams & OKRs - Type Definitions
@@ -659,16 +657,16 @@ export interface WeeklyCheckInSectionConfig {
export const WEEKLY_CHECK_IN_SECTIONS: WeeklyCheckInSectionConfig[] = [ export const WEEKLY_CHECK_IN_SECTIONS: WeeklyCheckInSectionConfig[] = [
{ {
category: 'WENT_WELL', category: 'WENT_WELL',
title: 'Ce qui s\'est bien passé', title: "Ce qui s'est bien passé",
icon: '✅', icon: '✅',
description: 'Les réussites et points positifs de la semaine', description: 'Les réussites et points positifs de la semaine',
color: '#22c55e', // green color: '#22c55e', // green
}, },
{ {
category: 'WENT_WRONG', category: 'WENT_WRONG',
title: 'Ce qui s\'est mal passé', title: "Ce qui s'est mal passé",
icon: '⚠️', icon: '⚠️',
description: 'Les difficultés et points d\'amélioration', description: "Les difficultés et points d'amélioration",
color: '#ef4444', // red color: '#ef4444', // red
}, },
{ {

View File

@@ -58,7 +58,7 @@ export const WORKSHOPS: WorkshopConfig[] = [
home: { home: {
tagline: 'Analysez. Planifiez. Progressez.', tagline: 'Analysez. Planifiez. Progressez.',
description: description:
"Cartographiez les forces et faiblesses de vos collaborateurs. Identifiez opportunités et menaces pour définir des actions concrètes.", 'Cartographiez les forces et faiblesses de vos collaborateurs. Identifiez opportunités et menaces pour définir des actions concrètes.',
features: [ features: [
'Matrice interactive Forces/Faiblesses/Opportunités/Menaces', 'Matrice interactive Forces/Faiblesses/Opportunités/Menaces',
'Actions croisées et plan de développement', 'Actions croisées et plan de développement',
@@ -81,7 +81,7 @@ export const WORKSHOPS: WorkshopConfig[] = [
home: { home: {
tagline: 'Révélez ce qui motive vraiment', tagline: 'Révélez ce qui motive vraiment',
description: description:
"Explorez les 10 motivations intrinsèques de vos collaborateurs. Comprenez leur impact et alignez aspirations et missions.", 'Explorez les 10 motivations intrinsèques de vos collaborateurs. Comprenez leur impact et alignez aspirations et missions.',
features: [ features: [
'10 cartes de motivation à classer', '10 cartes de motivation à classer',
"Évaluation de l'influence positive/négative", "Évaluation de l'influence positive/négative",
@@ -106,7 +106,7 @@ export const WORKSHOPS: WorkshopConfig[] = [
description: description:
"Réalisez un bilan complet de l'année écoulée. Identifiez réalisations, défis, apprentissages et définissez vos objectifs pour l'année à venir.", "Réalisez un bilan complet de l'année écoulée. Identifiez réalisations, défis, apprentissages et définissez vos objectifs pour l'année à venir.",
features: [ features: [
"5 catégories : Réalisations, Défis, Apprentissages, Objectifs, Moments", '5 catégories : Réalisations, Défis, Apprentissages, Objectifs, Moments',
'Organisation par drag & drop', 'Organisation par drag & drop',
"Vue d'ensemble de l'année", "Vue d'ensemble de l'année",
], ],
@@ -129,7 +129,7 @@ export const WORKSHOPS: WorkshopConfig[] = [
description: description:
"Chaque semaine, faites le point avec vos collaborateurs sur ce qui s'est bien passé, ce qui s'est mal passé, les enjeux du moment et les prochains enjeux.", "Chaque semaine, faites le point avec vos collaborateurs sur ce qui s'est bien passé, ce qui s'est mal passé, les enjeux du moment et les prochains enjeux.",
features: [ features: [
"4 catégories : Bien passé, Mal passé, Enjeux du moment, Prochains enjeux", '4 catégories : Bien passé, Mal passé, Enjeux du moment, Prochains enjeux',
"Ajout d'émotions à chaque item (fierté, joie, frustration, etc.)", "Ajout d'émotions à chaque item (fierté, joie, frustration, etc.)",
'Suivi hebdomadaire régulier', 'Suivi hebdomadaire régulier',
], ],
@@ -150,7 +150,7 @@ export const WORKSHOPS: WorkshopConfig[] = [
home: { home: {
tagline: "Votre état en un coup d'œil", tagline: "Votre état en un coup d'œil",
description: description:
"Créez votre météo personnelle sur 4 axes clés (Performance, Moral, Flux, Création de valeur) et partagez-la avec votre équipe pour une meilleure visibilité de votre état.", 'Créez votre météo personnelle sur 4 axes clés (Performance, Moral, Flux, Création de valeur) et partagez-la avec votre équipe pour une meilleure visibilité de votre état.',
features: [ features: [
'4 axes : Performance, Moral, Flux, Création de valeur', '4 axes : Performance, Moral, Flux, Création de valeur',
'Emojis météo pour exprimer votre état visuellement', 'Emojis météo pour exprimer votre état visuellement',

View File

@@ -95,17 +95,14 @@ export async function resolveCollaborator(collaborator: string): Promise<Resolve
const userByName = await prisma.user.findFirst({ const userByName = await prisma.user.findFirst({
where: { where: {
name: { not: null }, name: { not: null },
AND: [ AND: [{ name: { contains: trimmed } }],
{ name: { contains: trimmed } },
],
}, },
select: { id: true, email: true, name: true }, select: { id: true, email: true, name: true },
}); });
// Verify exact match (contains may return partial matches) // Verify exact match (contains may return partial matches)
const exactMatch = userByName && userByName.name?.toLowerCase() === trimmed.toLowerCase() const exactMatch =
? userByName userByName && userByName.name?.toLowerCase() === trimmed.toLowerCase() ? userByName : null;
: null;
return { raw: collaborator, matchedUser: exactMatch }; return { raw: collaborator, matchedUser: exactMatch };
} }

View File

@@ -69,7 +69,10 @@ export async function getMotivatorSessionById(sessionId: string, userId: string)
include: motivatorByIdInclude, include: motivatorByIdInclude,
}), }),
(sid) => (sid) =>
prisma.movingMotivatorsSession.findUnique({ where: { id: sid }, include: motivatorByIdInclude }), prisma.movingMotivatorsSession.findUnique({
where: { id: sid },
include: motivatorByIdInclude,
}),
(s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r })) (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r }))
); );
} }

View File

@@ -29,7 +29,9 @@ const editWhere = (sessionId: string, userId: string) => ({
/** Factory: creates canAccess, canEdit, canDelete for a session-like model */ /** Factory: creates canAccess, canEdit, canDelete for a session-like model */
export function createSessionPermissionChecks(model: SessionLikeDelegate) { export function createSessionPermissionChecks(model: SessionLikeDelegate) {
const getOwnerId: GetOwnerIdFn = (sessionId) => const getOwnerId: GetOwnerIdFn = (sessionId) =>
model.findUnique({ where: { id: sessionId }, select: { userId: true } }).then((s) => s?.userId ?? null); model
.findUnique({ where: { id: sessionId }, select: { userId: true } })
.then((s) => s?.userId ?? null);
return { return {
canAccess: async (sessionId: string, userId: string) => { canAccess: async (sessionId: string, userId: string) => {

View File

@@ -80,7 +80,12 @@ export async function fetchTeamCollaboratorSessions<
withRole.map(async (s) => ({ ...s, ...(await resolveParticipant(s)) })) withRole.map(async (s) => ({ ...s, ...(await resolveParticipant(s)) }))
) as Promise<(T & { isOwner: false; role: 'VIEWER'; isTeamCollab: true; canEdit: true } & R)[]>; ) as Promise<(T & { isOwner: false; role: 'VIEWER'; isTeamCollab: true; canEdit: true } & R)[]>;
} }
return withRole as (T & { isOwner: false; role: 'VIEWER'; isTeamCollab: true; canEdit: true } & R)[]; return withRole as (T & {
isOwner: false;
role: 'VIEWER';
isTeamCollab: true;
canEdit: true;
} & R)[];
} }
type SessionWithShares = { type SessionWithShares = {
@@ -98,7 +103,9 @@ export async function getSessionByIdGeneric<
fetchWithAccess: (sessionId: string, userId: string) => Promise<T | null>, fetchWithAccess: (sessionId: string, userId: string) => Promise<T | null>,
fetchById: (sessionId: string) => Promise<T | null>, fetchById: (sessionId: string) => Promise<T | null>,
resolveParticipant?: (session: T) => Promise<R> resolveParticipant?: (session: T) => Promise<R>
): Promise<(T & { isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR'; canEdit: boolean } & R) | null> { ): Promise<
(T & { isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR'; canEdit: boolean } & R) | null
> {
let session = await fetchWithAccess(sessionId, userId); let session = await fetchWithAccess(sessionId, userId);
if (!session) { if (!session) {
const raw = await fetchById(sessionId); const raw = await fetchById(sessionId);
@@ -108,7 +115,7 @@ export async function getSessionByIdGeneric<
const isOwner = session.userId === userId; const isOwner = session.userId === userId;
const share = session.shares.find((s) => s.userId === userId); const share = session.shares.find((s) => s.userId === userId);
const isAdminOfOwner = !isOwner && !share && (await isAdminOfUser(session.userId, userId)); const isAdminOfOwner = !isOwner && !share && (await isAdminOfUser(session.userId, userId));
const role = isOwner ? ('OWNER' as const) : (share?.role || ('VIEWER' as const)); const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
const canEdit = isOwner || role === 'EDITOR' || isAdminOfOwner; const canEdit = isOwner || role === 'EDITOR' || isAdminOfOwner;
const base = { ...session, isOwner, role, canEdit }; const base = { ...session, isOwner, role, canEdit };
if (resolveParticipant) { if (resolveParticipant) {
@@ -118,5 +125,9 @@ export async function getSessionByIdGeneric<
canEdit: boolean; canEdit: boolean;
} & R; } & R;
} }
return base as T & { isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR'; canEdit: boolean } & R; return base as T & {
isOwner: boolean;
role: 'OWNER' | 'VIEWER' | 'EDITOR';
canEdit: boolean;
} & R;
} }

View File

@@ -28,10 +28,7 @@ type ShareDelegate = {
include: ShareInclude; include: ShareInclude;
}) => Promise<unknown>; }) => Promise<unknown>;
deleteMany: (args: { where: { sessionId: string; userId: string } }) => Promise<unknown>; deleteMany: (args: { where: { sessionId: string; userId: string } }) => Promise<unknown>;
findMany: (args: { findMany: (args: { where: { sessionId: string }; include: ShareInclude }) => Promise<unknown>;
where: { sessionId: string };
include: ShareInclude;
}) => Promise<unknown>;
}; };
type EventDelegate = { type EventDelegate = {
@@ -48,9 +45,7 @@ type EventDelegate = {
orderBy: { createdAt: 'desc' }; orderBy: { createdAt: 'desc' };
select: { createdAt: true }; select: { createdAt: true };
}) => Promise<{ createdAt: Date } | null>; }) => Promise<{ createdAt: Date } | null>;
deleteMany: (args: { deleteMany: (args: { where: { createdAt: { lt: Date } } }) => Promise<unknown>;
where: { createdAt: { lt: Date } };
}) => Promise<unknown>;
}; };
type SessionDelegate = { type SessionDelegate = {
@@ -104,11 +99,7 @@ export function createShareAndEventHandlers<TEventType extends string>(
}); });
}, },
async removeShare( async removeShare(sessionId: string, ownerId: string, shareUserId: string) {
sessionId: string,
ownerId: string,
shareUserId: string
) {
const session = await sessionModel.findFirst({ const session = await sessionModel.findFirst({
where: { id: sessionId, userId: ownerId }, where: { id: sessionId, userId: ownerId },
}); });

View File

@@ -88,12 +88,7 @@ const sessionShareEvents = createShareAndEventHandlers<
| 'ACTION_UPDATED' | 'ACTION_UPDATED'
| 'ACTION_DELETED' | 'ACTION_DELETED'
| 'SESSION_UPDATED' | 'SESSION_UPDATED'
>( >(prisma.session, prisma.sessionShare, prisma.sessionEvent, sessionPermissions.canAccess);
prisma.session,
prisma.sessionShare,
prisma.sessionEvent,
sessionPermissions.canAccess
);
export const canAccessSession = sessionPermissions.canAccess; export const canAccessSession = sessionPermissions.canAccess;
export const canEditSession = sessionPermissions.canEdit; export const canEditSession = sessionPermissions.canEdit;

View File

@@ -246,7 +246,10 @@ export async function getTeamMember(teamId: string, userId: string) {
} }
/** Returns true if adminUserId is ADMIN of any team that contains ownerUserId. */ /** Returns true if adminUserId is ADMIN of any team that contains ownerUserId. */
export const isAdminOfUser = cache(async function isAdminOfUser(ownerUserId: string, adminUserId: string): Promise<boolean> { export const isAdminOfUser = cache(async function isAdminOfUser(
ownerUserId: string,
adminUserId: string
): Promise<boolean> {
if (ownerUserId === adminUserId) return false; if (ownerUserId === adminUserId) return false;
const teamMemberIds = await getTeamMemberIdsForAdminTeams(adminUserId); const teamMemberIds = await getTeamMemberIdsForAdminTeams(adminUserId);
return teamMemberIds.includes(ownerUserId); return teamMemberIds.includes(ownerUserId);
@@ -254,7 +257,9 @@ export const isAdminOfUser = cache(async function isAdminOfUser(ownerUserId: str
/** Returns user IDs of all members in teams where the given user is ADMIN (excluding self). */ /** Returns user IDs of all members in teams where the given user is ADMIN (excluding self). */
// Wrapped with React.cache to deduplicate calls within a single server request // Wrapped with React.cache to deduplicate calls within a single server request
export const getTeamMemberIdsForAdminTeams = cache(async function getTeamMemberIdsForAdminTeams(userId: string): Promise<string[]> { export const getTeamMemberIdsForAdminTeams = cache(async function getTeamMemberIdsForAdminTeams(
userId: string
): Promise<string[]> {
const adminTeams = await prisma.teamMember.findMany({ const adminTeams = await prisma.teamMember.findMany({
where: { where: {
userId, userId,
@@ -295,4 +300,3 @@ export async function getTeamMemberById(teamMemberId: string) {
}, },
}); });
} }

View File

@@ -69,8 +69,7 @@ export async function getWeatherSessionById(sessionId: string, userId: string) {
where: { id: sid, OR: [{ userId: uid }, { shares: { some: { userId: uid } } }] }, where: { id: sid, OR: [{ userId: uid }, { shares: { some: { userId: uid } } }] },
include: weatherByIdInclude, include: weatherByIdInclude,
}), }),
(sid) => (sid) => prisma.weatherSession.findUnique({ where: { id: sid }, include: weatherByIdInclude })
prisma.weatherSession.findUnique({ where: { id: sid }, include: weatherByIdInclude })
); );
} }
@@ -177,7 +176,6 @@ export async function deleteWeatherEntry(sessionId: string, userId: string) {
}); });
} }
// Returns the most recent WeatherEntry per userId from any session BEFORE sessionDate, // Returns the most recent WeatherEntry per userId from any session BEFORE sessionDate,
// excluding the current session. Returned as a map userId → entry. // excluding the current session. Returned as a map userId → entry.
export async function getPreviousWeatherEntriesForUsers( export async function getPreviousWeatherEntriesForUsers(
@@ -240,7 +238,8 @@ export async function getPreviousWeatherEntriesForUsers(
valueCreationEmoji: null as string | null, valueCreationEmoji: null as string | null,
}; };
if (!existing) map.set(entry.userId, base); if (!existing) map.set(entry.userId, base);
if (base.performanceEmoji == null && entry.performanceEmoji != null) base.performanceEmoji = entry.performanceEmoji; if (base.performanceEmoji == null && entry.performanceEmoji != null)
base.performanceEmoji = entry.performanceEmoji;
if (base.moralEmoji == null && entry.moralEmoji != null) base.moralEmoji = entry.moralEmoji; if (base.moralEmoji == null && entry.moralEmoji != null) base.moralEmoji = entry.moralEmoji;
if (base.fluxEmoji == null && entry.fluxEmoji != null) base.fluxEmoji = entry.fluxEmoji; if (base.fluxEmoji == null && entry.fluxEmoji != null) base.fluxEmoji = entry.fluxEmoji;
if (base.valueCreationEmoji == null && entry.valueCreationEmoji != null) if (base.valueCreationEmoji == null && entry.valueCreationEmoji != null)
@@ -287,7 +286,7 @@ export async function shareWeatherSessionToTeam(
}, },
}); });
if (existingCount > 0) { if (existingCount > 0) {
throw new Error("Cette équipe a déjà une météo pour cette semaine"); throw new Error('Cette équipe a déjà une météo pour cette semaine');
} }
} }

View File

@@ -226,4 +226,3 @@ export type YRSessionEventType =
export const createYearReviewSessionEvent = yearReviewShareEvents.createEvent; export const createYearReviewSessionEvent = yearReviewShareEvents.createEvent;
export const getYearReviewSessionEvents = yearReviewShareEvents.getEvents; export const getYearReviewSessionEvents = yearReviewShareEvents.getEvents;
export const getLatestYearReviewEventTimestamp = yearReviewShareEvents.getLatestEventTimestamp; export const getLatestYearReviewEventTimestamp = yearReviewShareEvents.getLatestEventTimestamp;