refactor: improve team management, OKRs, and session components

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

View File

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

View File

@@ -28,6 +28,7 @@ pnpm prisma studio # Open DB GUI
## Architecture Overview
### Workshop Types
There are 5 workshop types: `swot` (sessions), `motivators`, `year-review`, `weekly-checkin`, `weather`.
**Single source of truth**: `src/lib/workshops.ts` exports `WORKSHOPS`, `WORKSHOP_BY_ID`, and helpers. Every place that lists or routes workshop types must use this file.
@@ -58,6 +59,7 @@ src/
```
### Real-Time Collaboration (SSE)
Each workshop has `/api/[path]/[id]/subscribe` — a GET route that opens a `ReadableStream` (SSE). The server polls the DB every 1 second for new events and pushes them to connected clients. Server Actions write events to the DB after mutations.
Client side: `useLive` hook (`src/hooks/useLive.ts`) connects to the subscribe endpoint with `EventSource`, filters out events from the current user (to avoid duplicates), and calls `router.refresh()` on incoming events.
@@ -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.
### Shared Permission System
`createSessionPermissionChecks(model)` in `src/services/session-permissions.ts` returns `canAccess`, `canEdit`, `canDelete` for any Prisma model that follows the session shape (has `userId` + `shares` relation). Team admins have implicit access to their members' sessions.
`createShareAndEventHandlers(...)` in `src/services/session-share-events.ts` returns `share`, `removeShare`, `getShares`, `createEvent`, `getEvents` — used by all workshop services.
### Auth
- `src/lib/auth.ts` — NextAuth config (signIn, signOut, auth exports)
- `src/lib/auth.config.ts` — config object (used separately for Edge middleware)
- `src/middleware.ts` — protects all routes except `/api/auth`, `_next/static`, `_next/image`, `favicon.ico`
- Session user ID is available via `auth()` call server-side; token includes `id` field
### Database
Prisma client is a singleton in `src/services/database.ts`. `DATABASE_URL` env var controls the SQLite file path (default: `file:./prisma/dev.db`). Schema is at `prisma/schema.prisma`.
### Adding a New Workshop
Pattern followed by all existing workshops:
1. Add entry to `WORKSHOPS` in `src/lib/workshops.ts`
2. Add Prisma models (Session, Item, Share, Event) following the existing pattern
3. Create service in `src/services/` using `createSessionPermissionChecks` and `createShareAndEventHandlers`

View File

@@ -3,18 +3,21 @@
## Requêtes DB (impact critique)
### resolveCollaborator — suppression du scan complet de la table User
**Fichier:** `src/services/auth.ts`
Avant : `findMany` sur tous les users puis `find()` en JS pour un match case-insensitive par nom.
Après : `findFirst` avec `contains` + vérification exacte. O(1) au lieu de O(N users).
### getAllUsersWithStats — suppression du N+1
**Fichier:** `src/services/auth.ts`
Avant : 2 queries `count` par utilisateur (`Promise.all` avec map).
Après : 2 `groupBy` en bulk + construction d'une Map. 3 queries au lieu de 2N+1.
### React.cache sur les fonctions teams
**Fichier:** `src/services/teams.ts`
`getTeamMemberIdsForAdminTeams` et `isAdminOfUser` wrappées avec `React.cache()`.
@@ -23,11 +26,13 @@ Sur la page `/sessions`, ces fonctions étaient appelées ~10 fois par requête
## SSE / Temps réel (impact haut)
### Polling interval 1s → 2s
**Fichiers:** 5 routes `src/app/api/*/[id]/subscribe/route.ts`
Réduit de 50% le nombre de queries DB en temps réel. Imperceptible côté UX (la plupart des outils collab utilisent 2-5s).
### Nettoyage des events
**Fichier:** `src/services/session-share-events.ts`
Ajout de `cleanupOldEvents(maxAgeHours)` pour purger les events périmés. Les tables d'events n'ont pas de mécanisme de TTL — cette méthode peut être appelée périodiquement ou à la connexion SSE.
@@ -35,7 +40,9 @@ Ajout de `cleanupOldEvents(maxAgeHours)` pour purger les events périmés. Les t
## Rendu client (impact haut)
### React.memo sur les composants de cartes
**Fichiers:**
- `src/components/swot/SwotCard.tsx`
- `src/components/moving-motivators/MotivatorCard.tsx` (+ `MotivatorCardStatic`)
- `src/components/weather/WeatherCard.tsx`
@@ -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.
### WeatherCard — fix du pattern useEffect + setState
**Fichier:** `src/components/weather/WeatherCard.tsx`
Remplacé le `useEffect` qui appelait 5 `setState` (cascading renders, erreur lint React 19) par le pattern idiomatique de state-driven prop sync (comparaison directe dans le render body).
@@ -52,12 +60,14 @@ Remplacé le `useEffect` qui appelait 5 `setState` (cascading renders, erreur li
## Configuration Next.js (impact moyen)
### next.config.ts
**Fichier:** `next.config.ts`
- `poweredByHeader: false` — supprime le header `X-Powered-By` (sécurité)
- `optimizePackageImports` — tree-shaking amélioré pour `@dnd-kit/*` et `@hello-pangea/dnd`
### Fix FOUC dark mode
**Fichier:** `src/app/layout.tsx`
Script inline dans `<head>` qui lit `localStorage` et applique la classe `dark`/`light` sur `<html>` avant l'hydratation React. Élimine le flash blanc pour les utilisateurs en dark mode.

View File

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

View File

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

View File

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

3507
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -115,7 +115,11 @@ export async function createOrUpdateWeatherEntry(
}
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
const user = await getUserById(authSession.user.id);
@@ -124,7 +128,8 @@ export async function createOrUpdateWeatherEntry(
}
// 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(
sessionId,
authSession.user.id,
@@ -254,7 +259,7 @@ export async function shareWeatherSessionToTeam(
return { success: true, data: shares };
} catch (error) {
console.error('Error sharing weather session to team:', error);
const message = error instanceof Error ? error.message : 'Erreur lors du partage à l\'équipe';
const message = error instanceof Error ? error.message : "Erreur lors du partage à l'équipe";
return { success: false, error: message };
}
}

View File

@@ -104,10 +104,7 @@ export async function createYearReviewItem(
}
// Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession(
sessionId,
authSession.user.id
);
const canEdit = await yearReviewService.canEditYearReviewSession(sessionId, authSession.user.id);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
@@ -146,10 +143,7 @@ export async function updateYearReviewItem(
}
// Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession(
sessionId,
authSession.user.id
);
const canEdit = await yearReviewService.canEditYearReviewSession(sessionId, authSession.user.id);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
@@ -183,10 +177,7 @@ export async function deleteYearReviewItem(itemId: string, sessionId: string) {
}
// Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession(
sessionId,
authSession.user.id
);
const canEdit = await yearReviewService.canEditYearReviewSession(sessionId, authSession.user.id);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
@@ -222,10 +213,7 @@ export async function moveYearReviewItem(
}
// Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession(
sessionId,
authSession.user.id
);
const canEdit = await yearReviewService.canEditYearReviewSession(sessionId, authSession.user.id);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
@@ -264,10 +252,7 @@ export async function reorderYearReviewItems(
}
// Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession(
sessionId,
authSession.user.id
);
const canEdit = await yearReviewService.canEditYearReviewSession(sessionId, authSession.user.id);
if (!canEdit) {
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' };
}
}

View File

@@ -34,7 +34,10 @@ export async function PATCH(
if (!isAdmin && !isConcernedMember) {
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 }
);
}
@@ -51,10 +54,8 @@ export async function PATCH(
return NextResponse.json(updated);
} catch (error) {
console.error('Error updating key result:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de la mise à jour du Key Result';
return NextResponse.json(
{ error: errorMessage },
{ status: 500 }
);
const errorMessage =
error instanceof Error ? error.message : 'Erreur lors de la mise à jour du Key Result';
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}

View File

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

View File

@@ -15,7 +15,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
// Check if user is admin
const isAdmin = await isTeamAdmin(id, session.user.id);
if (!isAdmin) {
return NextResponse.json({ error: 'Seuls les administrateurs peuvent ajouter des membres' }, { status: 403 });
return NextResponse.json(
{ error: 'Seuls les administrateurs peuvent ajouter des membres' },
{ status: 403 }
);
}
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 });
} catch (error) {
console.error('Error adding team member:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de l\'ajout du membre';
return NextResponse.json(
{ error: errorMessage },
{ status: 500 }
);
const errorMessage =
error instanceof Error ? error.message : "Erreur lors de l'ajout du membre";
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}
@@ -50,7 +51,10 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
// Check if user is admin
const isAdmin = await isTeamAdmin(id, session.user.id);
if (!isAdmin) {
return NextResponse.json({ error: 'Seuls les administrateurs peuvent modifier les rôles' }, { status: 403 });
return NextResponse.json(
{ error: 'Seuls les administrateurs peuvent modifier les rôles' },
{ status: 403 }
);
}
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);
} catch (error) {
console.error('Error updating member role:', error);
return NextResponse.json(
{ error: 'Erreur lors de la mise à jour du rôle' },
{ status: 500 }
);
return NextResponse.json({ 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
const isAdmin = await isTeamAdmin(id, session.user.id);
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);
@@ -99,10 +103,6 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error removing team member:', error);
return NextResponse.json(
{ error: 'Erreur lors de la suppression du membre' },
{ status: 500 }
);
return NextResponse.json({ error: 'Erreur lors de la suppression du membre' }, { status: 500 });
}
}

View File

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

View File

@@ -35,7 +35,7 @@ export async function POST(request: Request) {
const { name, description } = body;
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);
@@ -43,10 +43,6 @@ export async function POST(request: Request) {
return NextResponse.json(team, { status: 201 });
} catch (error) {
console.error('Error creating team:', error);
return NextResponse.json(
{ error: 'Erreur lors de la création de l\'équipe' },
{ status: 500 }
);
return NextResponse.json({ error: "Erreur lors de la création de l'équipe" }, { status: 500 });
}
}

View File

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

View File

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

View File

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

View File

@@ -56,10 +56,10 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
/>
<div className="mt-2">
<CollaboratorDisplay
collaborator={session.resolvedParticipant as ResolvedCollaborator}
size="lg"
showEmail
/>
collaborator={session.resolvedParticipant as ResolvedCollaborator}
size="lg"
showEmail
/>
</div>
</div>
<div className="flex items-center gap-3">

View File

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

View File

@@ -125,7 +125,12 @@ interface WeatherSession {
canEdit?: boolean;
}
type AnySession = SwotSession | MotivatorSession | YearReviewSession | WeeklyCheckInSession | WeatherSession;
type AnySession =
| SwotSession
| MotivatorSession
| YearReviewSession
| WeeklyCheckInSession
| WeatherSession;
interface WorkshopTabsProps {
swotSessions: SwotSession[];
@@ -239,18 +244,20 @@ export function WorkshopTabs({
: activeTab === 'team'
? teamCollabSessions
: activeTab === 'swot'
? swotSessions
: activeTab === 'motivators'
? motivatorSessions
: activeTab === 'year-review'
? yearReviewSessions
: activeTab === 'weekly-checkin'
? weeklyCheckInSessions
: weatherSessions;
? swotSessions
: activeTab === 'motivators'
? motivatorSessions
: activeTab === 'year-review'
? yearReviewSessions
: activeTab === 'weekly-checkin'
? weeklyCheckInSessions
: weatherSessions;
// Separate by ownership (for non-team tab: owned, shared, teamCollab)
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 =
activeTab === 'all' ? teamCollabSessions : activeTab === 'team' ? teamCollabSessions : [];
@@ -342,7 +349,8 @@ export function WorkshopTabs({
🏢 Ateliers de l&apos;équipe non partagés ({teamCollabSessions.length})
</h2>
<p className="text-sm text-muted mb-4">
En tant qu&apos;admin d&apos;équipe, vous voyez les ateliers de vos collaborateurs qui ne vous sont pas encore partagés.
En tant qu&apos;admin d&apos;équipe, vous voyez les ateliers de vos collaborateurs
qui ne vous sont pas encore partagés.
</p>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{teamCollabSessions.map((s) => (
@@ -436,7 +444,7 @@ function TypeFilterDropdown({
<span>{current.icon}</span>
<span>{current.label}</span>
<Badge variant={isTypeSelected ? 'default' : 'primary'} className="ml-1 text-xs">
{isTypeSelected ? counts[activeTab] ?? 0 : totalCount}
{isTypeSelected ? (counts[activeTab] ?? 0) : totalCount}
</Badge>
<svg
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 [showEditModal, setShowEditModal] = useState(false);
const [isPending, startTransition] = useTransition();
@@ -618,117 +632,117 @@ function SessionCard({ session, isTeamCollab = false }: { session: AnySession; i
const editParticipantLabel = workshop.participantLabel;
const cardContent = (
<Card hover={!isTeamCollab} className={`h-full p-4 relative overflow-hidden ${isTeamCollab ? 'opacity-60' : ''}`}>
{/* Accent bar */}
<div
className="absolute top-0 left-0 right-0 h-1"
style={{ backgroundColor: accentColor }}
/>
<Card
hover={!isTeamCollab}
className={`h-full p-4 relative overflow-hidden ${isTeamCollab ? 'opacity-60' : ''}`}
>
{/* Accent bar */}
<div className="absolute top-0 left-0 right-0 h-1" style={{ backgroundColor: accentColor }} />
{/* Header: Icon + Title + Role badge */}
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">{workshop.icon}</span>
<h3 className="font-semibold text-foreground line-clamp-1 flex-1">{session.title}</h3>
{!session.isOwner && (
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor:
session.role === 'EDITOR' ? 'rgba(6,182,212,0.1)' : 'rgba(234,179,8,0.1)',
color: session.role === 'EDITOR' ? '#06b6d4' : '#eab308',
}}
>
{session.role === 'EDITOR' ? '✏️' : '👁️'}
</span>
)}
</div>
{/* Header: Icon + Title + Role badge */}
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">{workshop.icon}</span>
<h3 className="font-semibold text-foreground line-clamp-1 flex-1">{session.title}</h3>
{!session.isOwner && (
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor:
session.role === 'EDITOR' ? 'rgba(6,182,212,0.1)' : 'rgba(234,179,8,0.1)',
color: session.role === 'EDITOR' ? '#06b6d4' : '#eab308',
}}
>
{session.role === 'EDITOR' ? '✏️' : '👁️'}
</span>
)}
</div>
{/* Participant + Owner info */}
<div className="mb-3 flex items-center gap-2">
<CollaboratorDisplay collaborator={getResolvedCollaborator(session)} size="sm" />
{!session.isOwner && (
<span className="text-xs text-muted">
· par {session.user.name || session.user.email}
</span>
)}
</div>
{/* Participant + Owner info */}
<div className="mb-3 flex items-center gap-2">
<CollaboratorDisplay collaborator={getResolvedCollaborator(session)} size="sm" />
{!session.isOwner && (
<span className="text-xs text-muted">
· par {session.user.name || session.user.email}
</span>
)}
</div>
{/* Footer: Stats + Avatars + Date */}
<div className="flex items-center justify-between text-xs">
{/* Stats */}
<div className="flex items-center gap-2 text-muted">
{isSwot ? (
<>
<span>{(session as SwotSession)._count.items} items</span>
<span>·</span>
<span>{(session as SwotSession)._count.actions} actions</span>
</>
) : isYearReview ? (
<>
<span>{(session as YearReviewSession)._count.items} items</span>
<span>·</span>
<span>Année {(session as YearReviewSession).year}</span>
</>
) : isWeeklyCheckIn ? (
<>
<span>{(session as WeeklyCheckInSession)._count.items} items</span>
<span>·</span>
<span>
{new Date((session as WeeklyCheckInSession).date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</>
) : isWeather ? (
<>
<span>{(session as WeatherSession)._count.entries} membres</span>
<span>·</span>
<span>
{new Date((session as WeatherSession).date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</>
) : (
<span>{(session as MotivatorSession)._count.cards}/10</span>
)}
</div>
{/* Date */}
<span className="text-muted">
{new Date(session.updatedAt).toLocaleDateString('fr-FR', {
{/* Footer: Stats + Avatars + Date */}
<div className="flex items-center justify-between text-xs">
{/* Stats */}
<div className="flex items-center gap-2 text-muted">
{isSwot ? (
<>
<span>{(session as SwotSession)._count.items} items</span>
<span>·</span>
<span>{(session as SwotSession)._count.actions} actions</span>
</>
) : isYearReview ? (
<>
<span>{(session as YearReviewSession)._count.items} items</span>
<span>·</span>
<span>Année {(session as YearReviewSession).year}</span>
</>
) : isWeeklyCheckIn ? (
<>
<span>{(session as WeeklyCheckInSession)._count.items} items</span>
<span>·</span>
<span>
{new Date((session as WeeklyCheckInSession).date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</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 */}
{session.isOwner && session.shares.length > 0 && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
<span className="text-[10px] text-muted uppercase tracking-wide">Partagé</span>
<div className="flex flex-wrap gap-1.5">
{session.shares.slice(0, 3).map((share) => (
<div
key={share.id}
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-primary/10 text-[10px] text-primary"
title={share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
>
<span className="font-medium">
{share.user.name?.split(' ')[0] || share.user.email.split('@')[0]}
</span>
<span>{share.role === 'EDITOR' ? '✏️' : '👁️'}</span>
</div>
))}
{session.shares.length > 3 && (
<span className="text-[10px] text-muted">+{session.shares.length - 3}</span>
)}
</div>
{/* Date */}
<span className="text-muted">
{new Date(session.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</div>
{/* Shared with */}
{session.isOwner && session.shares.length > 0 && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
<span className="text-[10px] text-muted uppercase tracking-wide">Partagé</span>
<div className="flex flex-wrap gap-1.5">
{session.shares.slice(0, 3).map((share) => (
<div
key={share.id}
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-primary/10 text-[10px] text-primary"
title={share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
>
<span className="font-medium">
{share.user.name?.split(' ')[0] || share.user.email.split('@')[0]}
</span>
<span>{share.role === 'EDITOR' ? '✏️' : '👁️'}</span>
</div>
))}
{session.shares.length > 3 && (
<span className="text-[10px] text-muted">+{session.shares.length - 3}</span>
)}
</Card>
</div>
</div>
)}
</Card>
);
return (
@@ -764,24 +778,24 @@ function SessionCard({ session, isTeamCollab = false }: { session: AnySession; i
</svg>
</button>
{(session.isOwner || session.isTeamCollab) && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowDeleteModal(true);
}}
className="p-1.5 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive/20"
title="Supprimer"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowDeleteModal(true);
}}
className="p-1.5 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive/20"
title="Supprimer"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
)}
</div>
)}

View File

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

View File

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

View File

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

View File

@@ -70,10 +70,10 @@ export default async function TeamDetailPage({ params }: TeamDetailPageProps) {
{/* Members Section */}
<Card className="mb-8 p-6">
<TeamDetailClient
members={team.members as unknown as TeamMember[]}
teamId={id}
isAdmin={isAdmin}
<TeamDetailClient
members={team.members as unknown as TeamMember[]}
teamId={id}
isAdmin={isAdmin}
/>
</Card>
@@ -82,4 +82,3 @@ export default async function TeamDetailPage({ params }: TeamDetailPageProps) {
</main>
);
}

View File

@@ -18,7 +18,7 @@ export default function NewTeamPage() {
e.preventDefault();
if (!name.trim()) {
alert('Le nom de l\'équipe est requis');
alert("Le nom de l'équipe est requis");
return;
}
@@ -32,7 +32,7 @@ export default function NewTeamPage() {
if (!response.ok) {
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;
}
@@ -41,7 +41,7 @@ export default function NewTeamPage() {
router.refresh();
} catch (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 {
setSubmitting(false);
}
@@ -81,7 +81,7 @@ export default function NewTeamPage() {
disabled={submitting}
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>
</div>
</form>
@@ -89,4 +89,3 @@ export default function NewTeamPage() {
</main>
);
}

View File

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

View File

@@ -19,7 +19,9 @@ export default function NewWeatherPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [title, setTitle] = useState(() => getWeekYearLabel(new Date(new Date().toISOString().split('T')[0])));
const [title, setTitle] = useState(() =>
getWeekYearLabel(new Date(new Date().toISOString().split('T')[0]))
);
const [isTitleManuallyEdited, setIsTitleManuallyEdited] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
@@ -69,7 +71,8 @@ export default function NewWeatherPage() {
Nouvelle Météo
</CardTitle>
<CardDescription>
Créez une météo personnelle pour faire le point sur 4 axes clés et partagez-la avec votre équipe
Créez une météo personnelle pour faire le point sur 4 axes clés et partagez-la avec
votre équipe
</CardDescription>
</CardHeader>
@@ -109,7 +112,8 @@ export default function NewWeatherPage() {
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
<ol className="text-sm text-muted space-y-1 list-decimal list-inside">
<li>
<strong>Performance</strong> : Comment évaluez-vous votre performance personnelle ?
<strong>Performance</strong> : Comment évaluez-vous votre performance personnelle
?
</li>
<li>
<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 ?
</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>
</ol>
<p className="text-sm text-muted mt-2">
💡 <strong>Astuce</strong> : Partagez votre météo avec votre équipe pour qu&apos;ils puissent voir votre état. Chaque membre peut créer sa propre météo et la partager !
💡 <strong>Astuce</strong> : Partagez votre météo avec votre équipe pour qu&apos;ils
puissent voir votre état. Chaque membre peut créer sa propre météo et la partager !
</p>
</div>

View File

@@ -37,7 +37,7 @@ export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckIn
// We use session.resolvedParticipant.matchedUser.id which is the participant's user ID
const currentQuarterPeriod = getCurrentQuarterPeriod(session.date);
let currentQuarterOKRs: Awaited<ReturnType<typeof getUserOKRsForPeriod>> = [];
// Only fetch OKRs if the participant is a recognized user (has matchedUser)
const resolvedParticipant = session.resolvedParticipant as ResolvedCollaborator;
if (resolvedParticipant.matchedUser) {
@@ -99,9 +99,7 @@ export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckIn
const participantTeamIds = new Set(
currentQuarterOKRs.map((okr) => okr.team?.id).filter(Boolean) as string[]
);
const adminTeamIds = userTeams
.filter((t) => t.userRole === 'ADMIN')
.map((t) => t.id);
const adminTeamIds = userTeams.filter((t) => t.userRole === 'ADMIN').map((t) => t.id);
return adminTeamIds.some((tid) => participantTeamIds.has(tid));
})()
}

View File

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

View File

@@ -56,10 +56,10 @@ export default async function YearReviewSessionPage({ params }: YearReviewSessio
/>
<div className="mt-2">
<CollaboratorDisplay
collaborator={session.resolvedParticipant as ResolvedCollaborator}
size="lg"
showEmail
/>
collaborator={session.resolvedParticipant as ResolvedCollaborator}
size="lg"
showEmail
/>
</div>
</div>
<div className="flex items-center gap-3">

View File

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

View File

@@ -18,9 +18,15 @@ interface ShareModalConfig {
interface BaseSessionLiveWrapperConfig {
apiPath: LiveApiPath;
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>;
onShareWithTeam?: (teamId: string, role: ShareRole) => Promise<{ success: boolean; error?: string }>;
onShareWithTeam?: (
teamId: string,
role: ShareRole
) => Promise<{ success: boolean; error?: string }>;
}
interface BaseSessionLiveWrapperProps {

View File

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

View File

@@ -150,33 +150,33 @@ export function Header() {
{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="border-b border-border px-4 py-2">
<p className="text-xs text-muted">Connecté en tant que</p>
<p className="truncate text-sm font-medium text-foreground">
{session.user.email}
</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 className="border-b border-border px-4 py-2">
<p className="text-xs text-muted">Connecté en tant que</p>
<p className="truncate text-sm font-medium text-foreground">
{session.user.email}
</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>
) : (

View File

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

View File

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

View File

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

View File

@@ -21,70 +21,71 @@ const categoryStyles: Record<SwotCategory, { ring: string; text: string }> = {
THREAT: { ring: 'ring-threat', text: 'text-threat' },
};
export const SwotCard = memo(forwardRef<HTMLDivElement, SwotCardProps>(
(
{ item, sessionId, isSelected, isHighlighted, isDragging, linkMode, onSelect, ...props },
ref
) => {
const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(item.content);
const [isPending, startTransition] = useTransition();
export const SwotCard = memo(
forwardRef<HTMLDivElement, SwotCardProps>(
(
{ item, sessionId, isSelected, isHighlighted, isDragging, linkMode, onSelect, ...props },
ref
) => {
const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(item.content);
const [isPending, startTransition] = useTransition();
const styles = categoryStyles[item.category];
const styles = categoryStyles[item.category];
async function handleSave() {
if (content.trim() === item.content) {
setIsEditing(false);
return;
async function handleSave() {
if (content.trim() === item.content) {
setIsEditing(false);
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()) {
// If empty, delete
async function handleDelete() {
startTransition(async () => {
await deleteSwotItem(item.id, sessionId);
});
return;
}
startTransition(async () => {
await updateSwotItem(item.id, sessionId, { content: content.trim() });
setIsEditing(false);
});
}
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);
async function handleDuplicate() {
startTransition(async () => {
await duplicateSwotItem(item.id, sessionId);
});
}
}
function handleClick() {
if (linkMode) {
onSelect();
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 (
<div
ref={ref}
onClick={handleClick}
className={`
function handleClick() {
if (linkMode) {
onSelect();
}
}
return (
<div
ref={ref}
onClick={handleClick}
className={`
group relative rounded-lg border bg-card p-3 shadow-sm transition-all
${isDragging ? 'shadow-lg ring-2 ring-primary' : 'border-border'}
${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' : ''}
${isPending ? 'opacity-50' : ''}
`}
{...props}
>
{isEditing ? (
<textarea
autoFocus
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
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}
/>
) : (
<>
<p className="text-sm text-foreground whitespace-pre-wrap">{item.content}</p>
{...props}
>
{isEditing ? (
<textarea
autoFocus
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
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}
/>
) : (
<>
<p className="text-sm text-foreground whitespace-pre-wrap">{item.content}</p>
{/* Actions (visible on hover) */}
{!linkMode && (
<div className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
aria-label="Modifier"
>
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
{/* Actions (visible on hover) */}
{!linkMode && (
<div className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
aria-label="Modifier"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDuplicate();
}}
className="rounded p-1 text-muted hover:bg-primary/10 hover:text-primary"
aria-label="Dupliquer"
>
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDuplicate();
}}
className="rounded p-1 text-muted hover:bg-primary/10 hover:text-primary"
aria-label="Dupliquer"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
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"
/>
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
aria-label="Supprimer"
>
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
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"
/>
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
aria-label="Supprimer"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
)}
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
)}
{/* Selection indicator in link mode */}
{linkMode && isSelected && (
<div
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">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
</div>
)}
</>
)}
</div>
);
}
));
{/* Selection indicator in link mode */}
{linkMode && isSelected && (
<div
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">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
</div>
)}
</>
)}
</div>
);
}
)
);
SwotCard.displayName = 'SwotCard';

View File

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

View File

@@ -45,7 +45,7 @@ export function MembersList({ members, teamId, isAdmin, onMemberUpdate }: Member
};
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;
}
@@ -172,4 +172,3 @@ export function MembersList({ members, teamId, isAdmin, onMemberUpdate }: Member
</div>
);
}

View File

@@ -24,7 +24,9 @@ export function TeamCard({ team }: TeamCardProps) {
<span className="text-2xl">👥</span>
<CardTitle>{team.name}</CardTitle>
</div>
{team.description && <CardDescription className="mt-2">{team.description}</CardDescription>}
{team.description && (
<CardDescription className="mt-2">{team.description}</CardDescription>
)}
</div>
{isAdmin && (
<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"
/>
</svg>
<span>{memberCount} membre{memberCount !== 1 ? 's' : ''}</span>
<span>
{memberCount} membre{memberCount !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-lg">🎯</span>
<span>{okrCount} OKR{okrCount !== 1 ? 's' : ''}</span>
<span>
{okrCount} OKR{okrCount !== 1 ? 's' : ''}
</span>
</div>
</div>
</CardContent>
@@ -61,4 +67,3 @@ export function TeamCard({ team }: TeamCardProps) {
</Link>
);
}

View File

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

View File

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

View File

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

View File

@@ -9,19 +9,17 @@ interface EditableTitleProps {
onUpdate: (sessionId: string, title: string) => Promise<{ success: boolean; error?: string }>;
}
export function EditableTitle({
sessionId,
initialTitle,
canEdit,
onUpdate,
}: EditableTitleProps) {
export function EditableTitle({ sessionId, initialTitle, canEdit, onUpdate }: EditableTitleProps) {
const [isEditing, setIsEditing] = useState(false);
const [editingTitle, setEditingTitle] = useState('');
const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
// 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(() => {
if (isEditing && inputRef.current) {
@@ -110,4 +108,3 @@ export function EditableTitle({
</button>
);
}

View File

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

View File

@@ -90,9 +90,16 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
</option>
))}
</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">
<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>
</div>
</div>

View File

@@ -22,7 +22,9 @@ export function ToggleGroup<T extends string>({
className = '',
}: ToggleGroupProps<T>) {
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) => (
<button
key={option.value}
@@ -30,9 +32,10 @@ export function ToggleGroup<T extends string>({
onClick={() => onChange(option.value)}
className={`
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'
: 'text-muted hover:text-foreground hover:bg-card-hover'
${
value === option.value
? '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>
);
}

View File

@@ -17,4 +17,4 @@ export { Select } from './Select';
export type { SelectOption } from './Select';
export { Textarea } from './Textarea';
export { ToggleGroup } from './ToggleGroup';
export type { ToggleOption } from './ToggleGroup';
export type { ToggleOption } from './ToggleGroup';

View File

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

View File

@@ -61,15 +61,15 @@ export function WeatherBoard({
// Get all users who have access: owner + shared users
const allUsers = useMemo(() => {
const usersMap = new Map<string, { id: string; name: string | null; email: string }>();
// Add owner
usersMap.set(owner.id, owner);
// Add shared users
shares.forEach((share) => {
usersMap.set(share.userId, share.user);
});
return Array.from(usersMap.values());
}, [owner, shares]);

View File

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

View File

@@ -11,7 +11,9 @@ export function WeatherInfoPanel() {
onClick={() => setIsOpen(!isOpen)}
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
className={`h-4 w-4 text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"

View File

@@ -1,7 +1,11 @@
'use client';
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';
interface WeatherLiveWrapperProps {

View File

@@ -47,86 +47,99 @@ export function CurrentQuarterOKRs({ okrs, period, canEdit = false }: CurrentQua
viewBox="0 0 24 24"
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>
</button>
</CardTitle>
</CardHeader>
{isExpanded && (
<CardContent>
<div className="space-y-3">
{okrs.map((okr) => {
const statusColors = getOKRStatusColor(okr.status);
return (
<div
key={okr.id}
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-1">
<div className="flex items-center gap-2 mb-1">
<EditableObjective
okr={okr}
canEdit={canEdit}
onUpdate={() => router.refresh()}
/>
<Badge
variant="default"
style={{
backgroundColor: statusColors.bg,
color: statusColors.color,
borderColor: statusColors.color + '30',
}}
>
{OKR_STATUS_LABELS[okr.status]}
</Badge>
{okr.progress !== undefined && (
<span className="text-xs text-muted whitespace-nowrap">{okr.progress}%</span>
<div className="space-y-3">
{okrs.map((okr) => {
const statusColors = getOKRStatusColor(okr.status);
return (
<div
key={okr.id}
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-1">
<div className="flex items-center gap-2 mb-1">
<EditableObjective
okr={okr}
canEdit={canEdit}
onUpdate={() => router.refresh()}
/>
<Badge
variant="default"
style={{
backgroundColor: statusColors.bg,
color: statusColors.color,
borderColor: statusColors.color + '30',
}}
>
{OKR_STATUS_LABELS[okr.status]}
</Badge>
{okr.progress !== undefined && (
<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>
{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 className="mt-4 pt-4 border-t border-border">
<Link
href="/objectives"
className="text-sm text-primary hover:underline flex items-center gap-1"
>
Voir tous les objectifs
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
);
})}
</div>
<div className="mt-4 pt-4 border-t border-border">
<Link
href="/objectives"
className="text-sm text-primary hover:underline flex items-center gap-1"
>
Voir tous les objectifs
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</Link>
</div>
</CardContent>
)}
</Card>
@@ -222,8 +235,7 @@ function EditableKeyResultRow({
const [currentValue, setCurrentValue] = useState(kr.currentValue);
const [updating, setUpdating] = useState(false);
const krProgress =
kr.targetValue > 0 ? Math.round((kr.currentValue / kr.targetValue) * 100) : 0;
const krProgress = kr.targetValue > 0 ? Math.round((kr.currentValue / kr.targetValue) * 100) : 0;
const handleSave = async () => {
setUpdating(true);
@@ -263,7 +275,9 @@ function EditableKeyResultRow({
step="0.1"
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 className="flex items-center gap-1 flex-shrink-0">
<Button size="sm" onClick={handleSave} disabled={updating} className="h-6 text-xs">

View File

@@ -12,188 +12,200 @@ interface WeeklyCheckInCardProps {
isDragging: boolean;
}
export const WeeklyCheckInCard = memo(forwardRef<HTMLDivElement, WeeklyCheckInCardProps>(
({ item, sessionId, isDragging, ...props }, ref) => {
const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(item.content);
const [emotion, setEmotion] = useState(item.emotion);
const [isPending, startTransition] = useTransition();
export const WeeklyCheckInCard = memo(
forwardRef<HTMLDivElement, WeeklyCheckInCardProps>(
({ item, sessionId, isDragging, ...props }, ref) => {
const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(item.content);
const [emotion, setEmotion] = useState(item.emotion);
const [isPending, startTransition] = useTransition();
const config = WEEKLY_CHECK_IN_BY_CATEGORY[item.category];
const emotionConfig = EMOTION_BY_TYPE[item.emotion];
const config = WEEKLY_CHECK_IN_BY_CATEGORY[item.category];
const emotionConfig = EMOTION_BY_TYPE[item.emotion];
async function handleSave() {
if (content.trim() === item.content && emotion === item.emotion) {
setIsEditing(false);
return;
async function handleSave() {
if (content.trim() === item.content && emotion === item.emotion) {
setIsEditing(false);
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()) {
// If empty, delete
async function handleDelete() {
startTransition(async () => {
await deleteWeeklyCheckInItem(item.id, sessionId);
});
return;
}
startTransition(async () => {
await updateWeeklyCheckInItem(item.id, sessionId, {
content: content.trim(),
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);
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 (
<div
ref={ref}
className={`
return (
<div
ref={ref}
className={`
group relative rounded-lg border bg-card p-3 shadow-sm transition-all
${isDragging ? 'shadow-lg ring-2 ring-primary' : 'border-border'}
${isPending ? 'opacity-50' : ''}
`}
style={{
borderLeftColor: config.color,
borderLeftWidth: '3px',
}}
{...props}
>
{isEditing ? (
<div
className="space-y-2"
onBlur={(e) => {
// Don't close if focus moves to another element in this container
const currentTarget = e.currentTarget;
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget && currentTarget.contains(relatedTarget)) {
return;
}
// Only save on blur if content changed
if (content.trim() !== item.content || emotion !== item.emotion) {
handleSave();
} 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);
style={{
borderLeftColor: config.color,
borderLeftWidth: '3px',
}}
{...props}
>
{isEditing ? (
<div
className="space-y-2"
onBlur={(e) => {
// Don't close if focus moves to another element in this container
const currentTarget = e.currentTarget;
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget && currentTarget.contains(relatedTarget)) {
return;
}
// Only save on blur if content changed
if (content.trim() !== item.content || emotion !== item.emotion) {
handleSave();
} else {
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}
>
Annuler
</button>
<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 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`,
/>
<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);
}}
title={emotionConfig.label}
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
disabled={isPending}
>
<span>{emotionConfig.icon}</span>
<span>{emotionConfig.label}</span>
</div>
)}
Annuler
</button>
<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 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) */}
<div className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
aria-label="Modifier"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
aria-label="Supprimer"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</>
)}
</div>
);
}
));
{/* Actions (visible on hover) */}
<div className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
aria-label="Modifier"
>
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
aria-label="Supprimer"
>
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</>
)}
</div>
);
}
)
);
WeeklyCheckInCard.displayName = 'WeeklyCheckInCard';

View File

@@ -11,120 +11,132 @@ interface YearReviewCardProps {
isDragging: boolean;
}
export const YearReviewCard = memo(forwardRef<HTMLDivElement, YearReviewCardProps>(
({ item, sessionId, isDragging, ...props }, ref) => {
const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(item.content);
const [isPending, startTransition] = useTransition();
export const YearReviewCard = memo(
forwardRef<HTMLDivElement, YearReviewCardProps>(
({ item, sessionId, isDragging, ...props }, ref) => {
const [isEditing, setIsEditing] = useState(false);
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() {
if (content.trim() === item.content) {
setIsEditing(false);
return;
async function handleSave() {
if (content.trim() === item.content) {
setIsEditing(false);
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()) {
// If empty, delete
async function handleDelete() {
startTransition(async () => {
await deleteYearReviewItem(item.id, sessionId);
});
return;
}
startTransition(async () => {
await updateYearReviewItem(item.id, sessionId, { content: content.trim() });
setIsEditing(false);
});
}
async function handleDelete() {
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);
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 (
<div
ref={ref}
className={`
return (
<div
ref={ref}
className={`
group relative rounded-lg border bg-card p-3 shadow-sm transition-all
${isDragging ? 'shadow-lg ring-2 ring-primary' : 'border-border'}
${isPending ? 'opacity-50' : ''}
`}
style={{
borderLeftColor: config.color,
borderLeftWidth: '3px',
}}
{...props}
>
{isEditing ? (
<textarea
autoFocus
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
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}
/>
) : (
<>
<p className="text-sm text-foreground whitespace-pre-wrap">{item.content}</p>
style={{
borderLeftColor: config.color,
borderLeftWidth: '3px',
}}
{...props}
>
{isEditing ? (
<textarea
autoFocus
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
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}
/>
) : (
<>
<p className="text-sm text-foreground whitespace-pre-wrap">{item.content}</p>
{/* Actions (visible on hover) */}
<div className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
aria-label="Modifier"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
aria-label="Supprimer"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</>
)}
</div>
);
}
));
{/* Actions (visible on hover) */}
<div className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
aria-label="Modifier"
>
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
aria-label="Supprimer"
>
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</>
)}
</div>
);
}
)
);
YearReviewCard.displayName = 'YearReviewCard';

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,7 +57,7 @@ export function getEmojiEvolution(
const previousScore = getEmojiScore(previous);
if (currentScore === null || previousScore === null) return null;
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
return 'same';
}

View File

@@ -58,7 +58,7 @@ export const WORKSHOPS: WorkshopConfig[] = [
home: {
tagline: 'Analysez. Planifiez. Progressez.',
description:
"Cartographiez les forces et faiblesses de vos collaborateurs. Identifiez opportunités et menaces pour définir des actions concrètes.",
'Cartographiez les forces et faiblesses de vos collaborateurs. Identifiez opportunités et menaces pour définir des actions concrètes.',
features: [
'Matrice interactive Forces/Faiblesses/Opportunités/Menaces',
'Actions croisées et plan de développement',
@@ -81,7 +81,7 @@ export const WORKSHOPS: WorkshopConfig[] = [
home: {
tagline: 'Révélez ce qui motive vraiment',
description:
"Explorez les 10 motivations intrinsèques de vos collaborateurs. Comprenez leur impact et alignez aspirations et missions.",
'Explorez les 10 motivations intrinsèques de vos collaborateurs. Comprenez leur impact et alignez aspirations et missions.',
features: [
'10 cartes de motivation à classer',
"Évaluation de l'influence positive/négative",
@@ -106,7 +106,7 @@ export const WORKSHOPS: WorkshopConfig[] = [
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.",
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',
"Vue d'ensemble de l'année",
],
@@ -129,7 +129,7 @@ export const WORKSHOPS: WorkshopConfig[] = [
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.",
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.)",
'Suivi hebdomadaire régulier',
],
@@ -150,7 +150,7 @@ export const WORKSHOPS: WorkshopConfig[] = [
home: {
tagline: "Votre état en un coup d'œil",
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: [
'4 axes : Performance, Moral, Flux, Création de valeur',
'Emojis météo pour exprimer votre état visuellement',

View File

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

View File

@@ -69,7 +69,10 @@ export async function getMotivatorSessionById(sessionId: string, userId: string)
include: motivatorByIdInclude,
}),
(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 }))
);
}

View File

@@ -29,7 +29,9 @@ const editWhere = (sessionId: string, userId: string) => ({
/** Factory: creates canAccess, canEdit, canDelete for a session-like model */
export function createSessionPermissionChecks(model: SessionLikeDelegate) {
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 {
canAccess: async (sessionId: string, userId: string) => {

View File

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

View File

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

View File

@@ -88,12 +88,7 @@ const sessionShareEvents = createShareAndEventHandlers<
| 'ACTION_UPDATED'
| 'ACTION_DELETED'
| '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 canEditSession = sessionPermissions.canEdit;

View File

@@ -246,7 +246,10 @@ export async function getTeamMember(teamId: string, userId: string) {
}
/** Returns true if adminUserId is ADMIN of any team that contains ownerUserId. */
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;
const teamMemberIds = await getTeamMemberIdsForAdminTeams(adminUserId);
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). */
// 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({
where: {
userId,
@@ -295,4 +300,3 @@ export async function getTeamMemberById(teamMemberId: string) {
},
});
}

View File

@@ -69,8 +69,7 @@ export async function getWeatherSessionById(sessionId: string, userId: string) {
where: { id: sid, OR: [{ userId: uid }, { shares: { some: { userId: uid } } }] },
include: weatherByIdInclude,
}),
(sid) =>
prisma.weatherSession.findUnique({ where: { id: sid }, include: weatherByIdInclude })
(sid) => 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,
// excluding the current session. Returned as a map userId → entry.
export async function getPreviousWeatherEntriesForUsers(
@@ -240,7 +238,8 @@ export async function getPreviousWeatherEntriesForUsers(
valueCreationEmoji: null as string | null,
};
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.fluxEmoji == null && entry.fluxEmoji != null) base.fluxEmoji = entry.fluxEmoji;
if (base.valueCreationEmoji == null && entry.valueCreationEmoji != null)
@@ -287,7 +286,7 @@ export async function shareWeatherSessionToTeam(
},
});
if (existingCount > 0) {
throw new Error("Cette équipe a déjà une météo pour cette semaine");
throw new Error('Cette équipe a déjà une météo pour cette semaine');
}
}

View File

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