refactor: improve team management, OKRs, and session components
This commit is contained in:
@@ -6,4 +6,3 @@
|
|||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"plugins": []
|
"plugins": []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -110,10 +111,11 @@ Application de gestion d'ateliers pour entretiens managériaux.
|
|||||||
action Action @relation(fields: [actionId], references: [id], onDelete: Cascade)
|
action Action @relation(fields: [actionId], references: [id], onDelete: Cascade)
|
||||||
swotItemId String
|
swotItemId String
|
||||||
swotItem SwotItem @relation(fields: [swotItemId], references: [id], onDelete: Cascade)
|
swotItem SwotItem @relation(fields: [swotItemId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
3507
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
const config = {
|
const config = {
|
||||||
plugins: {
|
plugins: {
|
||||||
"@tailwindcss/postcss": {},
|
'@tailwindcss/postcss': {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 +62,28 @@ 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 & {
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
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();
|
||||||
|
|
||||||
// Convert date strings to Date objects if provided
|
// Convert date strings to Date objects if provided
|
||||||
const updateData: UpdateOKRInput = { ...body };
|
const updateData: UpdateOKRInput = { ...body };
|
||||||
if (body.startDate) {
|
if (body.startDate) {
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,4 +30,3 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
|
|||||||
/>
|
/>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<CollaboratorDisplay
|
<CollaboratorDisplay
|
||||||
collaborator={session.resolvedParticipant as ResolvedCollaborator}
|
collaborator={session.resolvedParticipant as ResolvedCollaborator}
|
||||||
size="lg"
|
size="lg"
|
||||||
showEmail
|
showEmail
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
158
src/app/page.tsx
158
src/app/page.tsx
@@ -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'alignement et la détection
|
régulier avec vos collaborateurs. Il favorise la communication, l'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'objectifs qui permet
|
Les OKRs (Objectives and Key Results) sont un cadre de gestion d'objectifs qui
|
||||||
d'aligner les efforts de l'équipe autour d'objectifs communs et mesurables.
|
permet d'aligner les efforts de l'équipe autour d'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">
|
||||||
@@ -547,29 +617,29 @@ export default function Home() {
|
|||||||
Fonctionnalités principales
|
Fonctionnalités principales
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<FeaturePill
|
<FeaturePill
|
||||||
icon="👥"
|
icon="👥"
|
||||||
name="Gestion d'équipes"
|
name="Gestion d'équipes"
|
||||||
color="#8b5cf6"
|
color="#8b5cf6"
|
||||||
description="Créez des équipes et gérez les membres avec des rôles admin/membre"
|
description="Créez des équipes et gérez les membres avec des rôles admin/membre"
|
||||||
/>
|
/>
|
||||||
<FeaturePill
|
<FeaturePill
|
||||||
icon="🎯"
|
icon="🎯"
|
||||||
name="OKRs par période"
|
name="OKRs par période"
|
||||||
color="#3b82f6"
|
color="#3b82f6"
|
||||||
description="Définissez des OKRs pour des trimestres ou périodes personnalisées"
|
description="Définissez des OKRs pour des trimestres ou périodes personnalisées"
|
||||||
/>
|
/>
|
||||||
<FeaturePill
|
<FeaturePill
|
||||||
icon="📊"
|
icon="📊"
|
||||||
name="Key Results mesurables"
|
name="Key Results mesurables"
|
||||||
color="#10b981"
|
color="#10b981"
|
||||||
description="Suivez la progression de chaque Key Result avec des valeurs et pourcentages"
|
description="Suivez la progression de chaque Key Result avec des valeurs et pourcentages"
|
||||||
/>
|
/>
|
||||||
<FeaturePill
|
<FeaturePill
|
||||||
icon="👁️"
|
icon="👁️"
|
||||||
name="Visibilité transparente"
|
name="Visibilité transparente"
|
||||||
color="#f59e0b"
|
color="#f59e0b"
|
||||||
description="Tous les membres de l'équipe peuvent voir les OKRs de chacun"
|
description="Tous les membres de l'équipe peuvent voir les OKRs de chacun"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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[];
|
||||||
@@ -239,18 +244,20 @@ export function WorkshopTabs({
|
|||||||
: activeTab === 'team'
|
: activeTab === 'team'
|
||||||
? teamCollabSessions
|
? teamCollabSessions
|
||||||
: activeTab === 'swot'
|
: activeTab === 'swot'
|
||||||
? swotSessions
|
? swotSessions
|
||||||
: activeTab === 'motivators'
|
: activeTab === 'motivators'
|
||||||
? motivatorSessions
|
? motivatorSessions
|
||||||
: activeTab === 'year-review'
|
: activeTab === 'year-review'
|
||||||
? yearReviewSessions
|
? yearReviewSessions
|
||||||
: activeTab === 'weekly-checkin'
|
: activeTab === 'weekly-checkin'
|
||||||
? weeklyCheckInSessions
|
? weeklyCheckInSessions
|
||||||
: weatherSessions;
|
: weatherSessions;
|
||||||
|
|
||||||
// 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'équipe – non partagés ({teamCollabSessions.length})
|
🏢 Ateliers de l'é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'admin d'équipe, vous voyez les ateliers de vos collaborateurs qui ne vous sont pas encore partagés.
|
En tant qu'admin d'é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,117 +632,117 @@ 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
|
||||||
{/* Accent bar */}
|
hover={!isTeamCollab}
|
||||||
<div
|
className={`h-full p-4 relative overflow-hidden ${isTeamCollab ? 'opacity-60' : ''}`}
|
||||||
className="absolute top-0 left-0 right-0 h-1"
|
>
|
||||||
style={{ backgroundColor: accentColor }}
|
{/* Accent bar */}
|
||||||
/>
|
<div 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">
|
||||||
<span className="text-xl">{workshop.icon}</span>
|
<span className="text-xl">{workshop.icon}</span>
|
||||||
<h3 className="font-semibold text-foreground line-clamp-1 flex-1">{session.title}</h3>
|
<h3 className="font-semibold text-foreground line-clamp-1 flex-1">{session.title}</h3>
|
||||||
{!session.isOwner && (
|
{!session.isOwner && (
|
||||||
<span
|
<span
|
||||||
className="text-xs px-1.5 py-0.5 rounded"
|
className="text-xs px-1.5 py-0.5 rounded"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
session.role === 'EDITOR' ? 'rgba(6,182,212,0.1)' : 'rgba(234,179,8,0.1)',
|
session.role === 'EDITOR' ? 'rgba(6,182,212,0.1)' : 'rgba(234,179,8,0.1)',
|
||||||
color: session.role === 'EDITOR' ? '#06b6d4' : '#eab308',
|
color: session.role === 'EDITOR' ? '#06b6d4' : '#eab308',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{session.role === 'EDITOR' ? '✏️' : '👁️'}
|
{session.role === 'EDITOR' ? '✏️' : '👁️'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Participant + Owner info */}
|
{/* Participant + Owner info */}
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<CollaboratorDisplay collaborator={getResolvedCollaborator(session)} size="sm" />
|
<CollaboratorDisplay collaborator={getResolvedCollaborator(session)} size="sm" />
|
||||||
{!session.isOwner && (
|
{!session.isOwner && (
|
||||||
<span className="text-xs text-muted">
|
<span className="text-xs text-muted">
|
||||||
· par {session.user.name || session.user.email}
|
· par {session.user.name || session.user.email}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer: Stats + Avatars + Date */}
|
{/* Footer: Stats + Avatars + Date */}
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="flex items-center gap-2 text-muted">
|
<div className="flex items-center gap-2 text-muted">
|
||||||
{isSwot ? (
|
{isSwot ? (
|
||||||
<>
|
<>
|
||||||
<span>{(session as SwotSession)._count.items} items</span>
|
<span>{(session as SwotSession)._count.items} items</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{(session as SwotSession)._count.actions} actions</span>
|
<span>{(session as SwotSession)._count.actions} actions</span>
|
||||||
</>
|
</>
|
||||||
) : isYearReview ? (
|
) : isYearReview ? (
|
||||||
<>
|
<>
|
||||||
<span>{(session as YearReviewSession)._count.items} items</span>
|
<span>{(session as YearReviewSession)._count.items} items</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>Année {(session as YearReviewSession).year}</span>
|
<span>Année {(session as YearReviewSession).year}</span>
|
||||||
</>
|
</>
|
||||||
) : isWeeklyCheckIn ? (
|
) : isWeeklyCheckIn ? (
|
||||||
<>
|
<>
|
||||||
<span>{(session as WeeklyCheckInSession)._count.items} items</span>
|
<span>{(session as WeeklyCheckInSession)._count.items} items</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>
|
<span>
|
||||||
{new Date((session as WeeklyCheckInSession).date).toLocaleDateString('fr-FR', {
|
{new Date((session as WeeklyCheckInSession).date).toLocaleDateString('fr-FR', {
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : isWeather ? (
|
|
||||||
<>
|
|
||||||
<span>{(session as WeatherSession)._count.entries} membres</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>
|
|
||||||
{new Date((session as WeatherSession).date).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span>{(session as MotivatorSession)._count.cards}/10</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date */}
|
|
||||||
<span className="text-muted">
|
|
||||||
{new Date(session.updatedAt).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</>
|
||||||
|
) : isWeather ? (
|
||||||
|
<>
|
||||||
|
<span>{(session as WeatherSession)._count.entries} membres</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>
|
||||||
|
{new Date((session as WeatherSession).date).toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>{(session as MotivatorSession)._count.cards}/10</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Shared with */}
|
{/* Date */}
|
||||||
{session.isOwner && session.shares.length > 0 && (
|
<span className="text-muted">
|
||||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
|
{new Date(session.updatedAt).toLocaleDateString('fr-FR', {
|
||||||
<span className="text-[10px] text-muted uppercase tracking-wide">Partagé</span>
|
day: 'numeric',
|
||||||
<div className="flex flex-wrap gap-1.5">
|
month: 'short',
|
||||||
{session.shares.slice(0, 3).map((share) => (
|
})}
|
||||||
<div
|
</span>
|
||||||
key={share.id}
|
</div>
|
||||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-primary/10 text-[10px] text-primary"
|
|
||||||
title={share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
|
{/* Shared with */}
|
||||||
>
|
{session.isOwner && session.shares.length > 0 && (
|
||||||
<span className="font-medium">
|
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
|
||||||
{share.user.name?.split(' ')[0] || share.user.email.split('@')[0]}
|
<span className="text-[10px] text-muted uppercase tracking-wide">Partagé</span>
|
||||||
</span>
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<span>{share.role === 'EDITOR' ? '✏️' : '👁️'}</span>
|
{session.shares.slice(0, 3).map((share) => (
|
||||||
</div>
|
<div
|
||||||
))}
|
key={share.id}
|
||||||
{session.shares.length > 3 && (
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-primary/10 text-[10px] text-primary"
|
||||||
<span className="text-[10px] text-muted">+{session.shares.length - 3}</span>
|
title={share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
|
||||||
)}
|
>
|
||||||
</div>
|
<span className="font-medium">
|
||||||
|
{share.user.name?.split(' ')[0] || share.user.email.split('@')[0]}
|
||||||
|
</span>
|
||||||
|
<span>{share.role === 'EDITOR' ? '✏️' : '👁️'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
{session.shares.length > 3 && (
|
||||||
|
<span className="text-[10px] text-muted">+{session.shares.length - 3}</span>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -764,24 +778,24 @@ function SessionCard({ session, isTeamCollab = false }: { session: AnySession; i
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{(session.isOwner || session.isTeamCollab) && (
|
{(session.isOwner || session.isTeamCollab) && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setShowDeleteModal(true);
|
setShowDeleteModal(true);
|
||||||
}}
|
}}
|
||||||
className="p-1.5 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive/20"
|
className="p-1.5 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive/20"
|
||||||
title="Supprimer"
|
title="Supprimer"
|
||||||
>
|
>
|
||||||
<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
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -62,15 +62,17 @@ export default function EditOKRPage() {
|
|||||||
order?: number;
|
order?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (data: CreateOKRInput & {
|
const handleSubmit = async (
|
||||||
startDate: Date | string;
|
data: CreateOKRInput & {
|
||||||
endDate: Date | string;
|
startDate: Date | string;
|
||||||
keyResultsUpdates?: {
|
endDate: Date | string;
|
||||||
create?: CreateKeyResultInput[];
|
keyResultsUpdates?: {
|
||||||
update?: KeyResultUpdate[];
|
create?: CreateKeyResultInput[];
|
||||||
delete?: string[]
|
update?: KeyResultUpdate[];
|
||||||
}
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,4 +79,3 @@ export default function NewOKRPage() {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,10 +70,10 @@ export default async function TeamDetailPage({ params }: TeamDetailPageProps) {
|
|||||||
|
|
||||||
{/* Members Section */}
|
{/* Members Section */}
|
||||||
<Card className="mb-8 p-6">
|
<Card className="mb-8 p-6">
|
||||||
<TeamDetailClient
|
<TeamDetailClient
|
||||||
members={team.members as unknown as TeamMember[]}
|
members={team.members as unknown as TeamMember[]}
|
||||||
teamId={id}
|
teamId={id}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -82,4 +82,3 @@ export default async function TeamDetailPage({ params }: TeamDetailPageProps) {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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'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'ils
|
||||||
|
puissent voir votre état. Chaque membre peut créer sa propre météo et la partager !
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckIn
|
|||||||
// We use session.resolvedParticipant.matchedUser.id which is the participant's user ID
|
// We use session.resolvedParticipant.matchedUser.id which is the participant's user ID
|
||||||
const currentQuarterPeriod = getCurrentQuarterPeriod(session.date);
|
const currentQuarterPeriod = getCurrentQuarterPeriod(session.date);
|
||||||
let currentQuarterOKRs: Awaited<ReturnType<typeof getUserOKRsForPeriod>> = [];
|
let currentQuarterOKRs: Awaited<ReturnType<typeof getUserOKRsForPeriod>> = [];
|
||||||
|
|
||||||
// Only fetch OKRs if the participant is a recognized user (has matchedUser)
|
// Only fetch OKRs if the participant is a recognized user (has matchedUser)
|
||||||
const resolvedParticipant = session.resolvedParticipant as ResolvedCollaborator;
|
const resolvedParticipant = session.resolvedParticipant as ResolvedCollaborator;
|
||||||
if (resolvedParticipant.matchedUser) {
|
if (resolvedParticipant.matchedUser) {
|
||||||
@@ -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));
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>) {
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ export default async function YearReviewSessionPage({ params }: YearReviewSessio
|
|||||||
/>
|
/>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<CollaboratorDisplay
|
<CollaboratorDisplay
|
||||||
collaborator={session.resolvedParticipant as ResolvedCollaborator}
|
collaborator={session.resolvedParticipant as ResolvedCollaborator}
|
||||||
size="lg"
|
size="lg"
|
||||||
showEmail
|
showEmail
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -135,4 +135,3 @@ export default function NewYearReviewPage() {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'êtes membre d'aucune équipe ou vos équipes n'ont pas d'autres membres.
|
Vous n'êtes membre d'aucune équipe ou vos équipes n'ont pas
|
||||||
Créez une équipe depuis la page{' '}
|
d'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"
|
||||||
|
|||||||
@@ -150,33 +150,33 @@ export function Header() {
|
|||||||
|
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="absolute right-0 z-20 mt-2 w-48 rounded-lg border border-border bg-card py-1 shadow-lg">
|
<div className="absolute right-0 z-20 mt-2 w-48 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||||
<div className="border-b border-border px-4 py-2">
|
<div className="border-b border-border px-4 py-2">
|
||||||
<p className="text-xs text-muted">Connecté en tant que</p>
|
<p className="text-xs text-muted">Connecté en tant que</p>
|
||||||
<p className="truncate text-sm font-medium text-foreground">
|
<p className="truncate text-sm font-medium text-foreground">
|
||||||
{session.user.email}
|
{session.user.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/profile"
|
|
||||||
onClick={() => setMenuOpen(false)}
|
|
||||||
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
|
|
||||||
>
|
|
||||||
👤 Mon Profil
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/users"
|
|
||||||
onClick={() => setMenuOpen(false)}
|
|
||||||
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
|
|
||||||
>
|
|
||||||
👥 Utilisateurs
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={() => signOut({ callbackUrl: '/' })}
|
|
||||||
className="w-full px-4 py-2 text-left text-sm text-destructive hover:bg-card-hover"
|
|
||||||
>
|
|
||||||
Se déconnecter
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/profile"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
|
||||||
|
>
|
||||||
|
👤 Mon Profil
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/users"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
|
||||||
|
>
|
||||||
|
👥 Utilisateurs
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => signOut({ callbackUrl: '/' })}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-destructive hover:bg-card-hover"
|
||||||
|
>
|
||||||
|
Se déconnecter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -183,4 +183,3 @@ export function KeyResultItem({ keyResult, okrId, canEdit, onUpdate }: KeyResult
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,70 +21,71 @@ 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 },
|
(
|
||||||
ref
|
{ item, sessionId, isSelected, isHighlighted, isDragging, linkMode, onSelect, ...props },
|
||||||
) => {
|
ref
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
) => {
|
||||||
const [content, setContent] = useState(item.content);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [content, setContent] = useState(item.content);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const styles = categoryStyles[item.category];
|
const styles = categoryStyles[item.category];
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (content.trim() === item.content) {
|
if (content.trim() === item.content) {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content.trim()) {
|
||||||
|
// If empty, delete
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteSwotItem(item.id, sessionId);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
await updateSwotItem(item.id, sessionId, { content: content.trim() });
|
||||||
|
setIsEditing(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!content.trim()) {
|
async function handleDelete() {
|
||||||
// If empty, delete
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
await deleteSwotItem(item.id, sessionId);
|
await deleteSwotItem(item.id, sessionId);
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(async () => {
|
async function handleDuplicate() {
|
||||||
await updateSwotItem(item.id, sessionId, { content: content.trim() });
|
startTransition(async () => {
|
||||||
setIsEditing(false);
|
await duplicateSwotItem(item.id, sessionId);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete() {
|
|
||||||
startTransition(async () => {
|
|
||||||
await deleteSwotItem(item.id, sessionId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDuplicate() {
|
|
||||||
startTransition(async () => {
|
|
||||||
await duplicateSwotItem(item.id, sessionId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSave();
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setContent(item.content);
|
|
||||||
setIsEditing(false);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function handleClick() {
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
if (linkMode) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
onSelect();
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setContent(item.content);
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
function handleClick() {
|
||||||
<div
|
if (linkMode) {
|
||||||
ref={ref}
|
onSelect();
|
||||||
onClick={handleClick}
|
}
|
||||||
className={`
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`
|
||||||
group relative rounded-lg border bg-card p-3 shadow-sm transition-all
|
group relative rounded-lg border bg-card p-3 shadow-sm transition-all
|
||||||
${isDragging ? 'shadow-lg ring-2 ring-primary' : 'border-border'}
|
${isDragging ? 'shadow-lg ring-2 ring-primary' : 'border-border'}
|
||||||
${isSelected ? `ring-2 ${styles.ring}` : ''}
|
${isSelected ? `ring-2 ${styles.ring}` : ''}
|
||||||
@@ -92,109 +93,110 @@ export const SwotCard = memo(forwardRef<HTMLDivElement, SwotCardProps>(
|
|||||||
${linkMode ? 'cursor-pointer hover:ring-2 hover:ring-primary/50' : ''}
|
${linkMode ? 'cursor-pointer hover:ring-2 hover:ring-primary/50' : ''}
|
||||||
${isPending ? 'opacity-50' : ''}
|
${isPending ? 'opacity-50' : ''}
|
||||||
`}
|
`}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<textarea
|
<textarea
|
||||||
autoFocus
|
autoFocus
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => setContent(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onBlur={handleSave}
|
onBlur={handleSave}
|
||||||
className="w-full resize-none rounded border-0 bg-transparent p-0 text-sm text-foreground focus:outline-none focus:ring-0"
|
className="w-full resize-none rounded border-0 bg-transparent p-0 text-sm text-foreground focus:outline-none focus:ring-0"
|
||||||
rows={2}
|
rows={2}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-foreground whitespace-pre-wrap">{item.content}</p>
|
<p className="text-sm text-foreground whitespace-pre-wrap">{item.content}</p>
|
||||||
|
|
||||||
{/* Actions (visible on hover) */}
|
{/* Actions (visible on hover) */}
|
||||||
{!linkMode && (
|
{!linkMode && (
|
||||||
<div className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}}
|
}}
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
strokeLinecap="round"
|
className="h-3.5 w-3.5"
|
||||||
strokeLinejoin="round"
|
fill="none"
|
||||||
strokeWidth={2}
|
viewBox="0 0 24 24"
|
||||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
stroke="currentColor"
|
||||||
/>
|
>
|
||||||
</svg>
|
<path
|
||||||
</button>
|
strokeLinecap="round"
|
||||||
<button
|
strokeLinejoin="round"
|
||||||
onClick={(e) => {
|
strokeWidth={2}
|
||||||
e.stopPropagation();
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||||
handleDuplicate();
|
/>
|
||||||
}}
|
</svg>
|
||||||
className="rounded p-1 text-muted hover:bg-primary/10 hover:text-primary"
|
</button>
|
||||||
aria-label="Dupliquer"
|
<button
|
||||||
>
|
onClick={(e) => {
|
||||||
<svg
|
e.stopPropagation();
|
||||||
className="h-3.5 w-3.5"
|
handleDuplicate();
|
||||||
fill="none"
|
}}
|
||||||
viewBox="0 0 24 24"
|
className="rounded p-1 text-muted hover:bg-primary/10 hover:text-primary"
|
||||||
stroke="currentColor"
|
aria-label="Dupliquer"
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
strokeLinecap="round"
|
className="h-3.5 w-3.5"
|
||||||
strokeLinejoin="round"
|
fill="none"
|
||||||
strokeWidth={2}
|
viewBox="0 0 24 24"
|
||||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
stroke="currentColor"
|
||||||
/>
|
>
|
||||||
</svg>
|
<path
|
||||||
</button>
|
strokeLinecap="round"
|
||||||
<button
|
strokeLinejoin="round"
|
||||||
onClick={(e) => {
|
strokeWidth={2}
|
||||||
e.stopPropagation();
|
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
handleDelete();
|
/>
|
||||||
}}
|
</svg>
|
||||||
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
|
</button>
|
||||||
aria-label="Supprimer"
|
<button
|
||||||
>
|
onClick={(e) => {
|
||||||
<svg
|
e.stopPropagation();
|
||||||
className="h-3.5 w-3.5"
|
handleDelete();
|
||||||
fill="none"
|
}}
|
||||||
viewBox="0 0 24 24"
|
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
|
||||||
stroke="currentColor"
|
aria-label="Supprimer"
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
strokeLinecap="round"
|
className="h-3.5 w-3.5"
|
||||||
strokeLinejoin="round"
|
fill="none"
|
||||||
strokeWidth={2}
|
viewBox="0 0 24 24"
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
stroke="currentColor"
|
||||||
/>
|
>
|
||||||
</svg>
|
<path
|
||||||
</button>
|
strokeLinecap="round"
|
||||||
</div>
|
strokeLinejoin="round"
|
||||||
)}
|
strokeWidth={2}
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Selection indicator in link mode */}
|
{/* Selection indicator in link mode */}
|
||||||
{linkMode && isSelected && (
|
{linkMode && isSelected && (
|
||||||
<div
|
<div
|
||||||
className={`absolute -right-1 -top-1 rounded-full bg-card p-0.5 shadow ${styles.text}`}
|
className={`absolute -right-1 -top-1 rounded-full bg-card p-0.5 shadow ${styles.text}`}
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
));
|
)
|
||||||
|
);
|
||||||
SwotCard.displayName = 'SwotCard';
|
SwotCard.displayName = 'SwotCard';
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,4 +26,3 @@ export function EditableMotivatorTitle({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,4 +26,3 @@ export function EditableSessionTitle({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,4 +26,3 @@ export function EditableYearReviewTitle({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,9 +32,10 @@ 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
|
${
|
||||||
? 'bg-[#8b5cf6] text-white shadow-sm'
|
value === option.value
|
||||||
: 'text-muted hover:text-foreground hover:bg-card-hover'
|
? 'bg-[#8b5cf6] text-white shadow-sm'
|
||||||
|
: 'text-muted hover:text-foreground hover:bg-card-hover'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
@@ -43,4 +46,3 @@ export function ToggleGroup<T extends string>({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,4 +17,4 @@ export { Select } from './Select';
|
|||||||
export type { SelectOption } from './Select';
|
export type { SelectOption } from './Select';
|
||||||
export { Textarea } from './Textarea';
|
export { Textarea } from './Textarea';
|
||||||
export { ToggleGroup } from './ToggleGroup';
|
export { ToggleGroup } from './ToggleGroup';
|
||||||
export type { ToggleOption } from './ToggleGroup';
|
export type { ToggleOption } from './ToggleGroup';
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -61,15 +61,15 @@ export function WeatherBoard({
|
|||||||
// Get all users who have access: owner + shared users
|
// Get all users who have access: owner + shared users
|
||||||
const allUsers = useMemo(() => {
|
const allUsers = useMemo(() => {
|
||||||
const usersMap = new Map<string, { id: string; name: string | null; email: string }>();
|
const usersMap = new Map<string, { id: string; name: string | null; email: string }>();
|
||||||
|
|
||||||
// Add owner
|
// Add owner
|
||||||
usersMap.set(owner.id, owner);
|
usersMap.set(owner.id, owner);
|
||||||
|
|
||||||
// Add shared users
|
// Add shared users
|
||||||
shares.forEach((share) => {
|
shares.forEach((share) => {
|
||||||
usersMap.set(share.userId, share.user);
|
usersMap.set(share.userId, share.user);
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(usersMap.values());
|
return Array.from(usersMap.values());
|
||||||
}, [owner, shares]);
|
}, [owner, shares]);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -47,86 +47,99 @@ 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>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{okrs.map((okr) => {
|
{okrs.map((okr) => {
|
||||||
const statusColors = getOKRStatusColor(okr.status);
|
const statusColors = getOKRStatusColor(okr.status);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={okr.id}
|
key={okr.id}
|
||||||
className="rounded-lg border border-border bg-card p-3 hover:bg-card-hover transition-colors"
|
className="rounded-lg border border-border bg-card p-3 hover:bg-card-hover transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<EditableObjective
|
<EditableObjective
|
||||||
okr={okr}
|
okr={okr}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
onUpdate={() => router.refresh()}
|
onUpdate={() => router.refresh()}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant="default"
|
variant="default"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: statusColors.bg,
|
backgroundColor: statusColors.bg,
|
||||||
color: statusColors.color,
|
color: statusColors.color,
|
||||||
borderColor: statusColors.color + '30',
|
borderColor: statusColors.color + '30',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{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>
|
||||||
|
{okr.description && (
|
||||||
|
<p className="text-sm text-muted mb-2">{okr.description}</p>
|
||||||
|
)}
|
||||||
|
{okr.keyResults && okr.keyResults.length > 0 && (
|
||||||
|
<ul className="space-y-1 mt-2">
|
||||||
|
{okr.keyResults.slice(0, 5).map((kr) => (
|
||||||
|
<EditableKeyResultRow
|
||||||
|
key={kr.id}
|
||||||
|
kr={kr}
|
||||||
|
okrId={okr.id}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onUpdate={() => router.refresh()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{okr.keyResults.length > 5 && (
|
||||||
|
<li className="text-xs text-muted pl-3.5">
|
||||||
|
+{okr.keyResults.length - 5} autre
|
||||||
|
{okr.keyResults.length - 5 > 1 ? 's' : ''}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{okr.team && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="text-xs text-muted">Équipe: {okr.team.name}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{okr.description && (
|
|
||||||
<p className="text-sm text-muted mb-2">{okr.description}</p>
|
|
||||||
)}
|
|
||||||
{okr.keyResults && okr.keyResults.length > 0 && (
|
|
||||||
<ul className="space-y-1 mt-2">
|
|
||||||
{okr.keyResults.slice(0, 5).map((kr) => (
|
|
||||||
<EditableKeyResultRow
|
|
||||||
key={kr.id}
|
|
||||||
kr={kr}
|
|
||||||
okrId={okr.id}
|
|
||||||
canEdit={canEdit}
|
|
||||||
onUpdate={() => router.refresh()}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{okr.keyResults.length > 5 && (
|
|
||||||
<li className="text-xs text-muted pl-3.5">
|
|
||||||
+{okr.keyResults.length - 5} autre{okr.keyResults.length - 5 > 1 ? 's' : ''}
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
{okr.team && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<span className="text-xs text-muted">Équipe: {okr.team.name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
<div className="mt-4 pt-4 border-t border-border">
|
||||||
<div className="mt-4 pt-4 border-t border-border">
|
<Link
|
||||||
<Link
|
href="/objectives"
|
||||||
href="/objectives"
|
className="text-sm text-primary hover:underline flex items-center gap-1"
|
||||||
className="text-sm text-primary hover:underline flex items-center gap-1"
|
>
|
||||||
>
|
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
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
strokeLinecap="round"
|
||||||
</svg>
|
strokeLinejoin="round"
|
||||||
</Link>
|
strokeWidth={2}
|
||||||
</div>
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -12,188 +12,200 @@ interface WeeklyCheckInCardProps {
|
|||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WeeklyCheckInCard = memo(forwardRef<HTMLDivElement, WeeklyCheckInCardProps>(
|
export const WeeklyCheckInCard = memo(
|
||||||
({ item, sessionId, isDragging, ...props }, ref) => {
|
forwardRef<HTMLDivElement, WeeklyCheckInCardProps>(
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
({ item, sessionId, isDragging, ...props }, ref) => {
|
||||||
const [content, setContent] = useState(item.content);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [emotion, setEmotion] = useState(item.emotion);
|
const [content, setContent] = useState(item.content);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [emotion, setEmotion] = useState(item.emotion);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const config = WEEKLY_CHECK_IN_BY_CATEGORY[item.category];
|
const config = WEEKLY_CHECK_IN_BY_CATEGORY[item.category];
|
||||||
const emotionConfig = EMOTION_BY_TYPE[item.emotion];
|
const emotionConfig = EMOTION_BY_TYPE[item.emotion];
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (content.trim() === item.content && emotion === item.emotion) {
|
if (content.trim() === item.content && emotion === item.emotion) {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content.trim()) {
|
||||||
|
// If empty, delete
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteWeeklyCheckInItem(item.id, sessionId);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
await updateWeeklyCheckInItem(item.id, sessionId, {
|
||||||
|
content: content.trim(),
|
||||||
|
emotion,
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!content.trim()) {
|
async function handleDelete() {
|
||||||
// If empty, delete
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
await deleteWeeklyCheckInItem(item.id, sessionId);
|
await deleteWeeklyCheckInItem(item.id, sessionId);
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(async () => {
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
await updateWeeklyCheckInItem(item.id, sessionId, {
|
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||||
content: content.trim(),
|
e.preventDefault();
|
||||||
emotion,
|
handleSave();
|
||||||
});
|
} else if (e.key === 'Escape') {
|
||||||
setIsEditing(false);
|
setContent(item.content);
|
||||||
});
|
setEmotion(item.emotion);
|
||||||
}
|
setIsEditing(false);
|
||||||
|
}
|
||||||
async function handleDelete() {
|
|
||||||
startTransition(async () => {
|
|
||||||
await deleteWeeklyCheckInItem(item.id, sessionId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSave();
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setContent(item.content);
|
|
||||||
setEmotion(item.emotion);
|
|
||||||
setIsEditing(false);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`
|
className={`
|
||||||
group relative rounded-lg border bg-card p-3 shadow-sm transition-all
|
group relative rounded-lg border bg-card p-3 shadow-sm transition-all
|
||||||
${isDragging ? 'shadow-lg ring-2 ring-primary' : 'border-border'}
|
${isDragging ? 'shadow-lg ring-2 ring-primary' : 'border-border'}
|
||||||
${isPending ? 'opacity-50' : ''}
|
${isPending ? 'opacity-50' : ''}
|
||||||
`}
|
`}
|
||||||
style={{
|
style={{
|
||||||
borderLeftColor: config.color,
|
borderLeftColor: config.color,
|
||||||
borderLeftWidth: '3px',
|
borderLeftWidth: '3px',
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div
|
<div
|
||||||
className="space-y-2"
|
className="space-y-2"
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
// Don't close if focus moves to another element in this container
|
// Don't close if focus moves to another element in this container
|
||||||
const currentTarget = e.currentTarget;
|
const currentTarget = e.currentTarget;
|
||||||
const relatedTarget = e.relatedTarget as Node | null;
|
const relatedTarget = e.relatedTarget as Node | null;
|
||||||
if (relatedTarget && currentTarget.contains(relatedTarget)) {
|
if (relatedTarget && currentTarget.contains(relatedTarget)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Only save on blur if content changed
|
// Only save on blur if content changed
|
||||||
if (content.trim() !== item.content || emotion !== item.emotion) {
|
if (content.trim() !== item.content || emotion !== item.emotion) {
|
||||||
handleSave();
|
handleSave();
|
||||||
} else {
|
} else {
|
||||||
setIsEditing(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
autoFocus
|
|
||||||
value={content}
|
|
||||||
onChange={(e) => setContent(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className="w-full resize-none rounded border-0 bg-transparent p-0 text-sm text-foreground focus:outline-none focus:ring-0"
|
|
||||||
rows={2}
|
|
||||||
disabled={isPending}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={emotion}
|
|
||||||
onChange={(e) => setEmotion(e.target.value as typeof emotion)}
|
|
||||||
className="text-xs"
|
|
||||||
options={Object.values(EMOTION_BY_TYPE).map((em) => ({
|
|
||||||
value: em.emotion,
|
|
||||||
label: `${em.icon} ${em.label}`,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setContent(item.content);
|
|
||||||
setEmotion(item.emotion);
|
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
}}
|
}
|
||||||
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
|
}}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="w-full resize-none rounded border-0 bg-transparent p-0 text-sm text-foreground focus:outline-none focus:ring-0"
|
||||||
|
rows={2}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
/>
|
||||||
Annuler
|
<Select
|
||||||
</button>
|
value={emotion}
|
||||||
<button
|
onChange={(e) => setEmotion(e.target.value as typeof emotion)}
|
||||||
onClick={handleSave}
|
className="text-xs"
|
||||||
disabled={isPending || !content.trim()}
|
options={Object.values(EMOTION_BY_TYPE).map((em) => ({
|
||||||
className="rounded px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10 disabled:opacity-50"
|
value: em.emotion,
|
||||||
>
|
label: `${em.icon} ${em.label}`,
|
||||||
{isPending ? '...' : 'Enregistrer'}
|
}))}
|
||||||
</button>
|
/>
|
||||||
</div>
|
<div className="flex justify-end gap-2">
|
||||||
</div>
|
<button
|
||||||
) : (
|
onClick={() => {
|
||||||
<>
|
setContent(item.content);
|
||||||
<div className="flex items-start justify-between gap-2">
|
setEmotion(item.emotion);
|
||||||
<p className="text-sm text-foreground whitespace-pre-wrap flex-1">{item.content}</p>
|
setIsEditing(false);
|
||||||
{emotion !== 'NONE' && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium shrink-0"
|
|
||||||
style={{
|
|
||||||
backgroundColor: `${emotionConfig.color}15`,
|
|
||||||
color: emotionConfig.color,
|
|
||||||
border: `1px solid ${emotionConfig.color}30`,
|
|
||||||
}}
|
}}
|
||||||
title={emotionConfig.label}
|
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
|
||||||
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<span>{emotionConfig.icon}</span>
|
Annuler
|
||||||
<span>{emotionConfig.label}</span>
|
</button>
|
||||||
</div>
|
<button
|
||||||
)}
|
onClick={handleSave}
|
||||||
|
disabled={isPending || !content.trim()}
|
||||||
|
className="rounded px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isPending ? '...' : 'Enregistrer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="text-sm text-foreground whitespace-pre-wrap flex-1">{item.content}</p>
|
||||||
|
{emotion !== 'NONE' && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${emotionConfig.color}15`,
|
||||||
|
color: emotionConfig.color,
|
||||||
|
border: `1px solid ${emotionConfig.color}30`,
|
||||||
|
}}
|
||||||
|
title={emotionConfig.label}
|
||||||
|
>
|
||||||
|
<span>{emotionConfig.icon}</span>
|
||||||
|
<span>{emotionConfig.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions (visible on hover) */}
|
{/* Actions (visible on hover) */}
|
||||||
<div className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}}
|
}}
|
||||||
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
|
||||||
<path
|
className="h-3.5 w-3.5"
|
||||||
strokeLinecap="round"
|
fill="none"
|
||||||
strokeLinejoin="round"
|
viewBox="0 0 24 24"
|
||||||
strokeWidth={2}
|
stroke="currentColor"
|
||||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
>
|
||||||
/>
|
<path
|
||||||
</svg>
|
strokeLinecap="round"
|
||||||
</button>
|
strokeLinejoin="round"
|
||||||
<button
|
strokeWidth={2}
|
||||||
onClick={(e) => {
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||||
e.stopPropagation();
|
/>
|
||||||
handleDelete();
|
</svg>
|
||||||
}}
|
</button>
|
||||||
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
|
<button
|
||||||
aria-label="Supprimer"
|
onClick={(e) => {
|
||||||
>
|
e.stopPropagation();
|
||||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
handleDelete();
|
||||||
<path
|
}}
|
||||||
strokeLinecap="round"
|
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
|
||||||
strokeLinejoin="round"
|
aria-label="Supprimer"
|
||||||
strokeWidth={2}
|
>
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
<svg
|
||||||
/>
|
className="h-3.5 w-3.5"
|
||||||
</svg>
|
fill="none"
|
||||||
</button>
|
viewBox="0 0 24 24"
|
||||||
</div>
|
stroke="currentColor"
|
||||||
</>
|
>
|
||||||
)}
|
<path
|
||||||
</div>
|
strokeLinecap="round"
|
||||||
);
|
strokeLinejoin="round"
|
||||||
}
|
strokeWidth={2}
|
||||||
));
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
WeeklyCheckInCard.displayName = 'WeeklyCheckInCard';
|
WeeklyCheckInCard.displayName = 'WeeklyCheckInCard';
|
||||||
|
|||||||
@@ -11,120 +11,132 @@ interface YearReviewCardProps {
|
|||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const YearReviewCard = memo(forwardRef<HTMLDivElement, YearReviewCardProps>(
|
export const YearReviewCard = memo(
|
||||||
({ item, sessionId, isDragging, ...props }, ref) => {
|
forwardRef<HTMLDivElement, YearReviewCardProps>(
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
({ item, sessionId, isDragging, ...props }, ref) => {
|
||||||
const [content, setContent] = useState(item.content);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [content, setContent] = useState(item.content);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const config = YEAR_REVIEW_BY_CATEGORY[item.category];
|
const config = YEAR_REVIEW_BY_CATEGORY[item.category];
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (content.trim() === item.content) {
|
if (content.trim() === item.content) {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content.trim()) {
|
||||||
|
// If empty, delete
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteYearReviewItem(item.id, sessionId);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
await updateYearReviewItem(item.id, sessionId, { content: content.trim() });
|
||||||
|
setIsEditing(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!content.trim()) {
|
async function handleDelete() {
|
||||||
// If empty, delete
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
await deleteYearReviewItem(item.id, sessionId);
|
await deleteYearReviewItem(item.id, sessionId);
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(async () => {
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
await updateYearReviewItem(item.id, sessionId, { content: content.trim() });
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
setIsEditing(false);
|
e.preventDefault();
|
||||||
});
|
handleSave();
|
||||||
}
|
} else if (e.key === 'Escape') {
|
||||||
|
setContent(item.content);
|
||||||
async function handleDelete() {
|
setIsEditing(false);
|
||||||
startTransition(async () => {
|
}
|
||||||
await deleteYearReviewItem(item.id, sessionId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSave();
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setContent(item.content);
|
|
||||||
setIsEditing(false);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`
|
className={`
|
||||||
group relative rounded-lg border bg-card p-3 shadow-sm transition-all
|
group relative rounded-lg border bg-card p-3 shadow-sm transition-all
|
||||||
${isDragging ? 'shadow-lg ring-2 ring-primary' : 'border-border'}
|
${isDragging ? 'shadow-lg ring-2 ring-primary' : 'border-border'}
|
||||||
${isPending ? 'opacity-50' : ''}
|
${isPending ? 'opacity-50' : ''}
|
||||||
`}
|
`}
|
||||||
style={{
|
style={{
|
||||||
borderLeftColor: config.color,
|
borderLeftColor: config.color,
|
||||||
borderLeftWidth: '3px',
|
borderLeftWidth: '3px',
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<textarea
|
<textarea
|
||||||
autoFocus
|
autoFocus
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => setContent(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onBlur={handleSave}
|
onBlur={handleSave}
|
||||||
className="w-full resize-none rounded border-0 bg-transparent p-0 text-sm text-foreground focus:outline-none focus:ring-0"
|
className="w-full resize-none rounded border-0 bg-transparent p-0 text-sm text-foreground focus:outline-none focus:ring-0"
|
||||||
rows={2}
|
rows={2}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-foreground whitespace-pre-wrap">{item.content}</p>
|
<p className="text-sm text-foreground whitespace-pre-wrap">{item.content}</p>
|
||||||
|
|
||||||
{/* Actions (visible on hover) */}
|
{/* Actions (visible on hover) */}
|
||||||
<div className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}}
|
}}
|
||||||
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
|
||||||
<path
|
className="h-3.5 w-3.5"
|
||||||
strokeLinecap="round"
|
fill="none"
|
||||||
strokeLinejoin="round"
|
viewBox="0 0 24 24"
|
||||||
strokeWidth={2}
|
stroke="currentColor"
|
||||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
>
|
||||||
/>
|
<path
|
||||||
</svg>
|
strokeLinecap="round"
|
||||||
</button>
|
strokeLinejoin="round"
|
||||||
<button
|
strokeWidth={2}
|
||||||
onClick={(e) => {
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||||
e.stopPropagation();
|
/>
|
||||||
handleDelete();
|
</svg>
|
||||||
}}
|
</button>
|
||||||
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
|
<button
|
||||||
aria-label="Supprimer"
|
onClick={(e) => {
|
||||||
>
|
e.stopPropagation();
|
||||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
handleDelete();
|
||||||
<path
|
}}
|
||||||
strokeLinecap="round"
|
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
|
||||||
strokeLinejoin="round"
|
aria-label="Supprimer"
|
||||||
strokeWidth={2}
|
>
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
<svg
|
||||||
/>
|
className="h-3.5 w-3.5"
|
||||||
</svg>
|
fill="none"
|
||||||
</button>
|
viewBox="0 0 24 24"
|
||||||
</div>
|
stroke="currentColor"
|
||||||
</>
|
>
|
||||||
)}
|
<path
|
||||||
</div>
|
strokeLinecap="round"
|
||||||
);
|
strokeLinejoin="round"
|
||||||
}
|
strokeWidth={2}
|
||||||
));
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
YearReviewCard.displayName = 'YearReviewCard';
|
YearReviewCard.displayName = 'YearReviewCard';
|
||||||
|
|||||||
@@ -55,4 +55,3 @@ export function YearReviewLiveWrapper({
|
|||||||
</BaseSessionLiveWrapper>
|
</BaseSessionLiveWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,4 +131,3 @@ export function useLive({
|
|||||||
|
|
||||||
return { isConnected, lastEvent, error };
|
return { isConnected, lastEvent, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
});
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
(acc, config) => {
|
||||||
> = YEAR_REVIEW_SECTIONS.reduce(
|
acc[config.category] = config;
|
||||||
(acc, config) => {
|
return acc;
|
||||||
acc[config.category] = config;
|
},
|
||||||
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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function getEmojiEvolution(
|
|||||||
const previousScore = getEmojiScore(previous);
|
const previousScore = getEmojiScore(previous);
|
||||||
if (currentScore === null || previousScore === null) return null;
|
if (currentScore === null || previousScore === null) return null;
|
||||||
const delta = currentScore - previousScore;
|
const delta = currentScore - previousScore;
|
||||||
if (delta < 0) return 'up'; // lower score = better weather
|
if (delta < 0) return 'up'; // lower score = better weather
|
||||||
if (delta > 0) return 'down'; // higher score = worse weather
|
if (delta > 0) return 'down'; // higher score = worse weather
|
||||||
return 'same';
|
return 'same';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user