Compare commits

..

13 Commits

Author SHA1 Message Date
Julien Froidefond
39910f559e feat: improve SSE broadcasting for weather sessions with enhanced error handling and connection management
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m49s
2026-02-04 13:18:10 +01:00
Julien Froidefond
057732f00e feat: enhance real-time weather session updates by broadcasting user information and syncing local state in WeatherCard component
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m14s
2026-02-04 11:05:33 +01:00
Julien Froidefond
e8ffccd286 refactor: streamline date and title handling in NewWeatherPage and NewWeeklyCheckInPage components for improved user experience
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-02-04 11:02:52 +01:00
Julien Froidefond
ef0772f894 feat: enhance user experience by adding notifications for OKR updates and improving session timeout handling for better usability
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m21s
2026-02-04 10:50:38 +01:00
Julien Froidefond
163caa398c feat: implement Weather Workshop feature with models, UI components, and session management for enhanced team visibility and personal well-being tracking
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m16s
2026-02-03 18:08:06 +01:00
Julien Froidefond
3a2eb83197 feat: add comparePeriods utility for sorting OKR periods and refactor ObjectivesPage to utilize it for improved period sorting
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 8m28s
2026-01-28 13:58:01 +01:00
Julien Froidefond
e848e85b63 feat: implement period filtering in OKRsList component with toggle for viewing all OKRs or current quarter's OKRs
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-01-28 13:54:10 +01:00
Julien Froidefond
53ee344ae7 feat: add Weekly Check-in feature with models, UI components, and session management for enhanced team collaboration
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m24s
2026-01-14 10:23:58 +01:00
Julien Froidefond
67d685d346 refactor: update component exports in OKRs, SWOT, Teams, and UI modules for improved organization and clarity 2026-01-13 14:51:50 +01:00
Julien Froidefond
47703db348 refactor: update OKR form and edit page to use new CreateKeyResultInput type for improved type safety and clarity
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m54s
2026-01-07 17:32:27 +01:00
Julien Froidefond
86c26b5af8 fix: improve error handling in API routes and update date handling for OKR and Key Result submissions
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 3m38s
2026-01-07 17:22:33 +01:00
Julien Froidefond
97045342b7 feat: refactor ObjectivesPage to utilize ObjectivesList component for improved rendering and simplify OKR status handling in OKRCard with compact view option
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 4m17s
2026-01-07 17:18:16 +01:00
Julien Froidefond
ca9b68ebbd feat: enhance OKR management by adding permission checks for editing and deleting, and updating OKR forms to handle key results more effectively
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 4m44s
2026-01-07 16:48:23 +01:00
56 changed files with 6316 additions and 449 deletions

View File

@@ -0,0 +1,103 @@
-- CreateEnum
CREATE TABLE "WeeklyCheckInCategory" (
"value" TEXT NOT NULL PRIMARY KEY
);
-- CreateEnum
CREATE TABLE "Emotion" (
"value" TEXT NOT NULL PRIMARY KEY
);
-- InsertEnumValues
INSERT INTO "WeeklyCheckInCategory" ("value") VALUES ('WENT_WELL');
INSERT INTO "WeeklyCheckInCategory" ("value") VALUES ('WENT_WRONG');
INSERT INTO "WeeklyCheckInCategory" ("value") VALUES ('CURRENT_FOCUS');
INSERT INTO "WeeklyCheckInCategory" ("value") VALUES ('NEXT_FOCUS');
-- InsertEnumValues
INSERT INTO "Emotion" ("value") VALUES ('PRIDE');
INSERT INTO "Emotion" ("value") VALUES ('JOY');
INSERT INTO "Emotion" ("value") VALUES ('SATISFACTION');
INSERT INTO "Emotion" ("value") VALUES ('GRATITUDE');
INSERT INTO "Emotion" ("value") VALUES ('CONFIDENCE');
INSERT INTO "Emotion" ("value") VALUES ('FRUSTRATION');
INSERT INTO "Emotion" ("value") VALUES ('WORRY');
INSERT INTO "Emotion" ("value") VALUES ('DISAPPOINTMENT');
INSERT INTO "Emotion" ("value") VALUES ('EXCITEMENT');
INSERT INTO "Emotion" ("value") VALUES ('ANTICIPATION');
INSERT INTO "Emotion" ("value") VALUES ('DETERMINATION');
INSERT INTO "Emotion" ("value") VALUES ('NONE');
-- CreateTable
CREATE TABLE "WeeklyCheckInSession" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"participant" TEXT NOT NULL,
"date" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WeeklyCheckInSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WeeklyCheckInItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"content" TEXT NOT NULL,
"category" TEXT NOT NULL,
"emotion" TEXT NOT NULL DEFAULT 'NONE',
"order" INTEGER NOT NULL DEFAULT 0,
"sessionId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WeeklyCheckInItem_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeeklyCheckInSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WeeklyCheckInItem_category_fkey" FOREIGN KEY ("category") REFERENCES "WeeklyCheckInCategory" ("value") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WeeklyCheckInItem_emotion_fkey" FOREIGN KEY ("emotion") REFERENCES "Emotion" ("value") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WCISessionShare" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'EDITOR',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WCISessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeeklyCheckInSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WCISessionShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WCISessionEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payload" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WCISessionEvent_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeeklyCheckInSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WCISessionEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "WeeklyCheckInSession_userId_idx" ON "WeeklyCheckInSession"("userId");
-- CreateIndex
CREATE INDEX "WeeklyCheckInSession_date_idx" ON "WeeklyCheckInSession"("date");
-- CreateIndex
CREATE INDEX "WeeklyCheckInItem_sessionId_idx" ON "WeeklyCheckInItem"("sessionId");
-- CreateIndex
CREATE INDEX "WeeklyCheckInItem_sessionId_category_idx" ON "WeeklyCheckInItem"("sessionId", "category");
-- CreateIndex
CREATE INDEX "WCISessionShare_sessionId_idx" ON "WCISessionShare"("sessionId");
-- CreateIndex
CREATE INDEX "WCISessionShare_userId_idx" ON "WCISessionShare"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "WCISessionShare_sessionId_userId_key" ON "WCISessionShare"("sessionId", "userId");
-- CreateIndex
CREATE INDEX "WCISessionEvent_sessionId_createdAt_idx" ON "WCISessionEvent"("sessionId", "createdAt");

View File

@@ -0,0 +1,76 @@
-- CreateTable
CREATE TABLE "WeatherSession" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"date" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WeatherSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WeatherEntry" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"performanceEmoji" TEXT,
"moralEmoji" TEXT,
"fluxEmoji" TEXT,
"valueCreationEmoji" TEXT,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WeatherEntry_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeatherSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WeatherEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WeatherSessionShare" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'EDITOR',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WeatherSessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeatherSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WeatherSessionShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WeatherSessionEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payload" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WeatherSessionEvent_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeatherSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WeatherSessionEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "WeatherSession_userId_idx" ON "WeatherSession"("userId");
-- CreateIndex
CREATE INDEX "WeatherSession_date_idx" ON "WeatherSession"("date");
-- CreateIndex
CREATE UNIQUE INDEX "WeatherEntry_sessionId_userId_key" ON "WeatherEntry"("sessionId", "userId");
-- CreateIndex
CREATE INDEX "WeatherEntry_sessionId_idx" ON "WeatherEntry"("sessionId");
-- CreateIndex
CREATE INDEX "WeatherEntry_userId_idx" ON "WeatherEntry"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "WeatherSessionShare_sessionId_userId_key" ON "WeatherSessionShare"("sessionId", "userId");
-- CreateIndex
CREATE INDEX "WeatherSessionShare_sessionId_idx" ON "WeatherSessionShare"("sessionId");
-- CreateIndex
CREATE INDEX "WeatherSessionShare_userId_idx" ON "WeatherSessionShare"("userId");
-- CreateIndex
CREATE INDEX "WeatherSessionEvent_sessionId_createdAt_idx" ON "WeatherSessionEvent"("sessionId", "createdAt");

View File

@@ -25,6 +25,15 @@ model User {
yearReviewSessions YearReviewSession[] yearReviewSessions YearReviewSession[]
sharedYearReviewSessions YRSessionShare[] sharedYearReviewSessions YRSessionShare[]
yearReviewSessionEvents YRSessionEvent[] yearReviewSessionEvents YRSessionEvent[]
// Weekly Check-in relations
weeklyCheckInSessions WeeklyCheckInSession[]
sharedWeeklyCheckInSessions WCISessionShare[]
weeklyCheckInSessionEvents WCISessionEvent[]
// Weather Workshop relations
weatherSessions WeatherSession[]
sharedWeatherSessions WeatherSessionShare[]
weatherSessionEvents WeatherSessionEvent[]
weatherEntries WeatherEntry[]
// Teams & OKRs relations // Teams & OKRs relations
createdTeams Team[] createdTeams Team[]
teamMembers TeamMember[] teamMembers TeamMember[]
@@ -365,3 +374,154 @@ model KeyResult {
@@index([okrId]) @@index([okrId])
@@index([okrId, order]) @@index([okrId, order])
} }
// ============================================
// Weekly Check-in Workshop
// ============================================
enum WeeklyCheckInCategory {
WENT_WELL // Ce qui s'est bien passé
WENT_WRONG // Ce qui s'est mal passé
CURRENT_FOCUS // Les enjeux du moment (je me concentre sur ...)
NEXT_FOCUS // Les prochains enjeux
}
enum Emotion {
PRIDE // Fierté
JOY // Joie
SATISFACTION // Satisfaction
GRATITUDE // Gratitude
CONFIDENCE // Confiance
FRUSTRATION // Frustration
WORRY // Inquiétude
DISAPPOINTMENT // Déception
EXCITEMENT // Excitement
ANTICIPATION // Anticipation
DETERMINATION // Détermination
NONE // Aucune émotion
}
model WeeklyCheckInSession {
id String @id @default(cuid())
title String
participant String // Nom du participant
date DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items WeeklyCheckInItem[]
shares WCISessionShare[]
events WCISessionEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([date])
}
model WeeklyCheckInItem {
id String @id @default(cuid())
content String
category WeeklyCheckInCategory
emotion Emotion @default(NONE)
order Int @default(0)
sessionId String
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([sessionId])
@@index([sessionId, category])
}
model WCISessionShare {
id String @id @default(cuid())
sessionId String
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role ShareRole @default(EDITOR)
createdAt DateTime @default(now())
@@unique([sessionId, userId])
@@index([sessionId])
@@index([userId])
}
model WCISessionEvent {
id String @id @default(cuid())
sessionId String
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // ITEM_CREATED, ITEM_UPDATED, ITEM_DELETED, etc.
payload String // JSON payload
createdAt DateTime @default(now())
@@index([sessionId, createdAt])
}
// ============================================
// Weather Workshop
// ============================================
model WeatherSession {
id String @id @default(cuid())
title String
date DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
entries WeatherEntry[]
shares WeatherSessionShare[]
events WeatherSessionEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([date])
}
model WeatherEntry {
id String @id @default(cuid())
sessionId String
session WeatherSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
performanceEmoji String? // Emoji météo pour Performance
moralEmoji String? // Emoji météo pour Moral
fluxEmoji String? // Emoji météo pour Flux
valueCreationEmoji String? // Emoji météo pour Création de valeur
notes String? // Notes globales
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([sessionId, userId]) // Un seul entry par membre par session
@@index([sessionId])
@@index([userId])
}
model WeatherSessionShare {
id String @id @default(cuid())
sessionId String
session WeatherSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role ShareRole @default(EDITOR)
createdAt DateTime @default(now())
@@unique([sessionId, userId])
@@index([sessionId])
@@index([userId])
}
model WeatherSessionEvent {
id String @id @default(cuid())
sessionId String
session WeatherSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // ENTRY_CREATED, ENTRY_UPDATED, ENTRY_DELETED, SESSION_UPDATED, etc.
payload String // JSON payload
createdAt DateTime @default(now())
@@index([sessionId, createdAt])
}

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

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

View File

@@ -0,0 +1,333 @@
'use server';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import * as weeklyCheckInService from '@/services/weekly-checkin';
import type { WeeklyCheckInCategory, Emotion } from '@prisma/client';
// ============================================
// Session Actions
// ============================================
export async function createWeeklyCheckInSession(data: {
title: string;
participant: string;
date?: Date;
}) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const weeklyCheckInSession = await weeklyCheckInService.createWeeklyCheckInSession(
session.user.id,
data
);
revalidatePath('/weekly-checkin');
revalidatePath('/sessions');
return { success: true, data: weeklyCheckInSession };
} catch (error) {
console.error('Error creating weekly check-in session:', error);
return { success: false, error: 'Erreur lors de la création' };
}
}
export async function updateWeeklyCheckInSession(
sessionId: string,
data: { title?: string; participant?: string; date?: Date }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await weeklyCheckInService.updateWeeklyCheckInSession(sessionId, authSession.user.id, data);
// Emit event for real-time sync
await weeklyCheckInService.createWeeklyCheckInSessionEvent(
sessionId,
authSession.user.id,
'SESSION_UPDATED',
data
);
revalidatePath(`/weekly-checkin/${sessionId}`);
revalidatePath('/weekly-checkin');
revalidatePath('/sessions');
return { success: true };
} catch (error) {
console.error('Error updating weekly check-in session:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function deleteWeeklyCheckInSession(sessionId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await weeklyCheckInService.deleteWeeklyCheckInSession(sessionId, authSession.user.id);
revalidatePath('/weekly-checkin');
revalidatePath('/sessions');
return { success: true };
} catch (error) {
console.error('Error deleting weekly check-in session:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}
// ============================================
// Item Actions
// ============================================
export async function createWeeklyCheckInItem(
sessionId: string,
data: { content: string; category: WeeklyCheckInCategory; emotion?: Emotion }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
const item = await weeklyCheckInService.createWeeklyCheckInItem(sessionId, data);
// Emit event for real-time sync
await weeklyCheckInService.createWeeklyCheckInSessionEvent(
sessionId,
authSession.user.id,
'ITEM_CREATED',
{
itemId: item.id,
content: item.content,
category: item.category,
emotion: item.emotion,
}
);
revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true, data: item };
} catch (error) {
console.error('Error creating weekly check-in item:', error);
return { success: false, error: 'Erreur lors de la création' };
}
}
export async function updateWeeklyCheckInItem(
itemId: string,
sessionId: string,
data: { content?: string; category?: WeeklyCheckInCategory; emotion?: Emotion }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
const item = await weeklyCheckInService.updateWeeklyCheckInItem(itemId, data);
// Emit event for real-time sync
await weeklyCheckInService.createWeeklyCheckInSessionEvent(
sessionId,
authSession.user.id,
'ITEM_UPDATED',
{
itemId: item.id,
...data,
}
);
revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true, data: item };
} catch (error) {
console.error('Error updating weekly check-in item:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function deleteWeeklyCheckInItem(itemId: string, sessionId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await weeklyCheckInService.deleteWeeklyCheckInItem(itemId);
// Emit event for real-time sync
await weeklyCheckInService.createWeeklyCheckInSessionEvent(
sessionId,
authSession.user.id,
'ITEM_DELETED',
{ itemId }
);
revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error deleting weekly check-in item:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}
export async function moveWeeklyCheckInItem(
itemId: string,
sessionId: string,
newCategory: WeeklyCheckInCategory,
newOrder: number
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await weeklyCheckInService.moveWeeklyCheckInItem(itemId, newCategory, newOrder);
// Emit event for real-time sync
await weeklyCheckInService.createWeeklyCheckInSessionEvent(
sessionId,
authSession.user.id,
'ITEM_MOVED',
{
itemId,
category: newCategory,
order: newOrder,
}
);
revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error moving weekly check-in item:', error);
return { success: false, error: 'Erreur lors du déplacement' };
}
}
export async function reorderWeeklyCheckInItems(
sessionId: string,
category: WeeklyCheckInCategory,
itemIds: string[]
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await weeklyCheckInService.reorderWeeklyCheckInItems(sessionId, category, itemIds);
// Emit event for real-time sync
await weeklyCheckInService.createWeeklyCheckInSessionEvent(
sessionId,
authSession.user.id,
'ITEMS_REORDERED',
{ category, itemIds }
);
revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error reordering weekly check-in items:', error);
return { success: false, error: 'Erreur lors du réordonnancement' };
}
}
// ============================================
// Sharing Actions
// ============================================
export async function shareWeeklyCheckInSession(
sessionId: string,
targetEmail: string,
role: 'VIEWER' | 'EDITOR' = 'EDITOR'
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const share = await weeklyCheckInService.shareWeeklyCheckInSession(
sessionId,
authSession.user.id,
targetEmail,
role
);
revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true, data: share };
} catch (error) {
console.error('Error sharing weekly check-in session:', error);
const message = error instanceof Error ? error.message : 'Erreur lors du partage';
return { success: false, error: message };
}
}
export async function removeWeeklyCheckInShare(sessionId: string, shareUserId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await weeklyCheckInService.removeWeeklyCheckInShare(
sessionId,
authSession.user.id,
shareUserId
);
revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error removing weekly check-in share:', error);
return { success: false, error: 'Erreur lors de la suppression du partage' };
}
}

View File

@@ -49,10 +49,11 @@ export async function PATCH(
const updated = await updateKeyResult(krId, Number(currentValue), notes || null); const updated = await updateKeyResult(krId, Number(currentValue), notes || null);
return NextResponse.json(updated); return NextResponse.json(updated);
} catch (error: any) { } catch (error) {
console.error('Error updating key result:', error); console.error('Error updating key result:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de la mise à jour du Key Result';
return NextResponse.json( return NextResponse.json(
{ error: error.message || 'Erreur lors de la mise à jour du Key Result' }, { error: errorMessage },
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -25,7 +25,19 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 }); return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
} }
return NextResponse.json(okr); // Check permissions
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
const isConcernedMember = okr.teamMember.userId === session.user.id;
return NextResponse.json({
...okr,
permissions: {
isAdmin,
isConcernedMember,
canEdit: isAdmin || isConcernedMember,
canDelete: isAdmin,
},
});
} catch (error) { } catch (error) {
console.error('Error fetching OKR:', error); console.error('Error fetching OKR:', error);
return NextResponse.json( return NextResponse.json(
@@ -49,13 +61,22 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
return NextResponse.json({ error: 'OKR non trouvé' }, { status: 404 }); return NextResponse.json({ error: 'OKR non trouvé' }, { status: 404 });
} }
// Check if user is admin of the team // Check if user is admin of the team or the concerned member
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id); const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
if (!isAdmin) { const isConcernedMember = okr.teamMember.userId === session.user.id;
return NextResponse.json({ error: 'Seuls les administrateurs peuvent modifier les OKRs' }, { status: 403 }); if (!isAdmin && !isConcernedMember) {
return NextResponse.json({ error: 'Seuls les administrateurs et le membre concerné peuvent modifier les OKRs' }, { status: 403 });
} }
const body: UpdateOKRInput & { startDate?: string; endDate?: string } = await request.json(); const body: UpdateOKRInput & {
startDate?: string;
endDate?: string;
keyResultsUpdates?: {
create?: Array<{ title: string; targetValue: number; unit: string; order: number }>;
update?: Array<{ id: string; title?: string; targetValue?: number; unit?: string; order?: number }>;
delete?: string[];
};
} = await request.json();
// Convert date strings to Date objects if provided // Convert date strings to Date objects if provided
const updateData: UpdateOKRInput = { ...body }; const updateData: UpdateOKRInput = { ...body };
@@ -66,13 +87,24 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
updateData.endDate = new Date(body.endDate); updateData.endDate = new Date(body.endDate);
} }
const updated = await updateOKR(id, updateData); // Remove keyResultsUpdates from updateData as it's not part of UpdateOKRInput
const { keyResultsUpdates, ...okrUpdateData } = body;
const finalUpdateData: UpdateOKRInput = { ...okrUpdateData };
if (finalUpdateData.startDate && typeof finalUpdateData.startDate === 'string') {
finalUpdateData.startDate = new Date(finalUpdateData.startDate);
}
if (finalUpdateData.endDate && typeof finalUpdateData.endDate === 'string') {
finalUpdateData.endDate = new Date(finalUpdateData.endDate);
}
const updated = await updateOKR(id, finalUpdateData, keyResultsUpdates);
return NextResponse.json(updated); return NextResponse.json(updated);
} catch (error: any) { } catch (error) {
console.error('Error updating OKR:', error); console.error('Error updating OKR:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de la mise à jour de l\'OKR';
return NextResponse.json( return NextResponse.json(
{ error: error.message || 'Erreur lors de la mise à jour de l\'OKR' }, { error: errorMessage },
{ status: 500 } { status: 500 }
); );
} }
@@ -101,10 +133,11 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
await deleteOKR(id); await deleteOKR(id);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error: any) { } catch (error) {
console.error('Error deleting OKR:', error); console.error('Error deleting OKR:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de la suppression de l\'OKR';
return NextResponse.json( return NextResponse.json(
{ error: error.message || 'Erreur lors de la suppression de l\'OKR' }, { error: errorMessage },
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -28,10 +28,11 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
const member = await addTeamMember(id, userId, role || 'MEMBER'); const member = await addTeamMember(id, userId, role || 'MEMBER');
return NextResponse.json(member, { status: 201 }); return NextResponse.json(member, { status: 201 });
} catch (error: any) { } catch (error) {
console.error('Error adding team member:', error); console.error('Error adding team member:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de l\'ajout du membre';
return NextResponse.json( return NextResponse.json(
{ error: error.message || 'Erreur lors de l\'ajout du membre' }, { error: errorMessage },
{ status: 500 } { status: 500 }
); );
} }

View File

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

View File

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

View File

@@ -2,72 +2,9 @@ import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { getUserOKRs } from '@/services/okrs'; import { getUserOKRs } from '@/services/okrs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'; import { Card } from '@/components/ui';
import { Badge } from '@/components/ui'; import { ObjectivesList } from '@/components/okrs/ObjectivesList';
import { getGravatarUrl } from '@/lib/gravatar'; import { comparePeriods } from '@/lib/okr-utils';
import type { OKRStatus, KeyResultStatus } from '@/lib/types';
import { OKR_STATUS_LABELS, KEY_RESULT_STATUS_LABELS } from '@/lib/types';
// Helper functions for status colors
function getOKRStatusColor(status: OKRStatus): { bg: string; color: string } {
switch (status) {
case 'NOT_STARTED':
return {
bg: 'color-mix(in srgb, #6b7280 15%, transparent)',
color: '#6b7280',
};
case 'IN_PROGRESS':
return {
bg: 'color-mix(in srgb, #3b82f6 15%, transparent)',
color: '#3b82f6',
};
case 'COMPLETED':
return {
bg: 'color-mix(in srgb, #10b981 15%, transparent)',
color: '#10b981',
};
case 'CANCELLED':
return {
bg: 'color-mix(in srgb, #ef4444 15%, transparent)',
color: '#ef4444',
};
default:
return {
bg: 'color-mix(in srgb, #6b7280 15%, transparent)',
color: '#6b7280',
};
}
}
function getKeyResultStatusColor(status: KeyResultStatus): { bg: string; color: string } {
switch (status) {
case 'NOT_STARTED':
return {
bg: 'color-mix(in srgb, #6b7280 12%, transparent)',
color: '#6b7280',
};
case 'IN_PROGRESS':
return {
bg: 'color-mix(in srgb, #3b82f6 12%, transparent)',
color: '#3b82f6',
};
case 'COMPLETED':
return {
bg: 'color-mix(in srgb, #10b981 12%, transparent)',
color: '#10b981',
};
case 'AT_RISK':
return {
bg: 'color-mix(in srgb, #f59e0b 12%, transparent)',
color: '#f59e0b',
};
default:
return {
bg: 'color-mix(in srgb, #6b7280 12%, transparent)',
color: '#6b7280',
};
}
}
export default async function ObjectivesPage() { export default async function ObjectivesPage() {
const session = await auth(); const session = await auth();
@@ -91,16 +28,7 @@ export default async function ObjectivesPage() {
{} as Record<string, typeof okrs> {} as Record<string, typeof okrs>
); );
const periods = Object.keys(okrsByPeriod).sort((a, b) => { const periods = Object.keys(okrsByPeriod).sort(comparePeriods);
// Sort periods: extract year and quarter/period
const aMatch = a.match(/(\d{4})/);
const bMatch = b.match(/(\d{4})/);
if (aMatch && bMatch) {
const yearDiff = parseInt(bMatch[1]) - parseInt(aMatch[1]);
if (yearDiff !== 0) return yearDiff;
}
return b.localeCompare(a);
});
return ( return (
<main className="mx-auto max-w-7xl px-4 py-8"> <main className="mx-auto max-w-7xl px-4 py-8">
@@ -120,8 +48,8 @@ export default async function ObjectivesPage() {
<div className="text-5xl mb-4">🎯</div> <div className="text-5xl mb-4">🎯</div>
<h3 className="text-xl font-semibold text-foreground mb-2">Aucun OKR défini</h3> <h3 className="text-xl font-semibold text-foreground mb-2">Aucun OKR défini</h3>
<p className="text-muted mb-6"> <p className="text-muted mb-6">
Vous n&apos;avez pas encore d&apos;OKR défini. Contactez un administrateur d&apos;équipe pour Vous n&apos;avez pas encore d&apos;OKR défini. Contactez un administrateur d&apos;équipe
en créer. pour en créer.
</p> </p>
<Link href="/teams"> <Link href="/teams">
<span className="inline-block rounded-lg bg-[var(--purple)] px-4 py-2 text-white hover:opacity-90"> <span className="inline-block rounded-lg bg-[var(--purple)] px-4 py-2 text-white hover:opacity-90">
@@ -130,172 +58,8 @@ export default async function ObjectivesPage() {
</Link> </Link>
</Card> </Card>
) : ( ) : (
<div className="space-y-8"> <ObjectivesList okrsByPeriod={okrsByPeriod} periods={periods} />
{periods.map((period) => {
const periodOKRs = okrsByPeriod[period];
const totalProgress =
periodOKRs.reduce((sum, okr) => sum + (okr.progress || 0), 0) / periodOKRs.length;
return (
<div key={period} className="space-y-4">
{/* Period Header */}
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-3">
<Badge
style={{
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
color: 'var(--purple)',
fontSize: '14px',
padding: '6px 12px',
}}
>
{period}
</Badge>
<span className="text-sm text-muted">
{periodOKRs.length} OKR{periodOKRs.length !== 1 ? 's' : ''}
</span>
</div>
<div className="text-sm font-medium text-foreground">
Progression moyenne: <span style={{ color: 'var(--primary)' }}>{Math.round(totalProgress)}%</span>
</div>
</div>
{/* OKRs Grid */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{periodOKRs.map((okr) => {
const progress = okr.progress || 0;
const progressColor =
progress >= 75 ? '#10b981' : progress >= 25 ? '#f59e0b' : '#ef4444';
return (
<Link key={okr.id} href={`/teams/${okr.team.id}/okrs/${okr.id}`}>
<Card hover className="h-full flex flex-col">
<CardHeader>
<div className="flex items-start justify-between mb-2">
<CardTitle className="text-lg flex-1 line-clamp-2">{okr.objective}</CardTitle>
<Badge style={getOKRStatusColor(okr.status)}>
{OKR_STATUS_LABELS[okr.status]}
</Badge>
</div>
{okr.description && (
<p className="text-sm text-muted line-clamp-2">{okr.description}</p>
)}
<div className="mt-2 flex items-center gap-2 text-xs text-muted">
<span>👥</span>
<span>{okr.team.name}</span>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col">
{/* Progress Bar */}
<div className="mb-4">
<div className="mb-1 flex items-center justify-between text-sm">
<span className="text-muted">Progression</span>
<span className="font-medium" style={{ color: progressColor }}>
{progress}%
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-card-column">
<div
className="h-full transition-all"
style={{
width: `${progress}%`,
backgroundColor: progressColor,
}}
/>
</div>
</div>
{/* Key Results Preview */}
{okr.keyResults && okr.keyResults.length > 0 && (
<div className="mt-auto space-y-2">
<div className="text-xs font-medium text-muted uppercase tracking-wide">
Key Results ({okr.keyResults.length})
</div>
<div className="space-y-2">
{okr.keyResults.slice(0, 3).map((kr) => {
const krProgress =
kr.targetValue > 0 ? (kr.currentValue / kr.targetValue) * 100 : 0;
const krProgressColor =
krProgress >= 100
? '#10b981'
: krProgress >= 50
? '#f59e0b'
: '#ef4444';
return (
<div key={kr.id} className="space-y-1">
<div className="flex items-start justify-between gap-2">
<span className="text-xs text-foreground flex-1 line-clamp-1">
{kr.title}
</span>
<Badge
style={{
...getKeyResultStatusColor(kr.status),
fontSize: '9px',
padding: '1px 4px',
}}
>
{KEY_RESULT_STATUS_LABELS[kr.status]}
</Badge>
</div>
<div className="flex items-center justify-between text-xs text-muted">
<span>
{kr.currentValue} / {kr.targetValue} {kr.unit}
</span>
<span className="font-medium" style={{ color: krProgressColor }}>
{Math.round(krProgress)}%
</span>
</div>
<div className="h-1 w-full overflow-hidden rounded-full bg-card-column">
<div
className="h-full transition-all"
style={{
width: `${Math.min(krProgress, 100)}%`,
backgroundColor: krProgressColor,
}}
/>
</div>
</div>
);
})}
{okr.keyResults.length > 3 && (
<div className="text-xs text-muted text-center pt-1">
+{okr.keyResults.length - 3} autre{okr.keyResults.length - 3 !== 1 ? 's' : ''}
</div>
)}
</div>
</div>
)}
{/* Dates */}
<div className="mt-4 pt-4 border-t border-border flex items-center justify-between text-xs text-muted">
<span>
{new Date(okr.startDate).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
<span></span>
<span>
{new Date(okr.endDate).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
</div>
);
})}
</div>
)} )}
</main> </main>
); );
} }

View File

@@ -68,6 +68,38 @@ export default function Home() {
accentColor="#f59e0b" accentColor="#f59e0b"
newHref="/year-review/new" newHref="/year-review/new"
/> />
{/* Weekly Check-in Workshop Card */}
<WorkshopCard
href="/sessions?tab=weekly-checkin"
icon="📝"
title="Weekly Check-in"
tagline="Le point hebdomadaire avec vos collaborateurs"
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',
'Ajout d\'émotions à chaque item (fierté, joie, frustration, etc.)',
'Suivi hebdomadaire régulier',
]}
accentColor="#10b981"
newHref="/weekly-checkin/new"
/>
{/* Weather Workshop Card */}
<WorkshopCard
href="/sessions?tab=weather"
icon="🌤️"
title="Météo"
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."
features={[
'4 axes : Performance, Moral, Flux, Création de valeur',
'Emojis météo pour exprimer votre état visuellement',
'Notes globales pour détailler votre ressenti',
]}
accentColor="#3b82f6"
newHref="/weather/new"
/>
</div> </div>
</section> </section>
@@ -355,6 +387,182 @@ export default function Home() {
</div> </div>
</section> </section>
{/* Weekly Check-in Deep Dive Section */}
<section className="mb-16">
<div className="flex items-center gap-3 mb-8">
<span className="text-4xl">📝</span>
<div>
<h2 className="text-3xl font-bold text-foreground">Weekly Check-in</h2>
<p className="text-green-500 font-medium">Le point hebdomadaire avec vos collaborateurs</p>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-2">
{/* Why */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">💡</span>
Pourquoi faire un check-in hebdomadaire ?
</h3>
<p className="text-muted mb-4">
Le Weekly Check-in est un rituel de management qui permet de maintenir un lien régulier
avec vos collaborateurs. Il favorise la communication, l&apos;alignement et la détection
précoce des problèmes ou opportunités.
</p>
<ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2">
<span className="text-green-500"></span>
Maintenir un suivi régulier et structuré avec chaque collaborateur
</li>
<li className="flex items-start gap-2">
<span className="text-green-500"></span>
Identifier rapidement les points positifs et les difficultés rencontrées
</li>
<li className="flex items-start gap-2">
<span className="text-green-500"></span>
Comprendre les priorités et enjeux du moment pour mieux accompagner
</li>
<li className="flex items-start gap-2">
<span className="text-green-500"></span>
Créer un espace d&apos;échange ouvert les émotions peuvent être exprimées
</li>
</ul>
</div>
{/* The 4 categories */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">📋</span>
Les 4 catégories du check-in
</h3>
<div className="space-y-3">
<CategoryPill icon="✅" name="Ce qui s'est bien passé" color="#22c55e" description="Les réussites et points positifs" />
<CategoryPill icon="⚠️" name="Ce qui s'est mal passé" color="#ef4444" description="Les difficultés et points d'amélioration" />
<CategoryPill icon="🎯" name="Enjeux du moment" color="#3b82f6" description="Sur quoi je me concentre actuellement" />
<CategoryPill icon="🚀" name="Prochains enjeux" color="#8b5cf6" description="Ce sur quoi je vais me concentrer prochainement" />
</div>
</div>
{/* How it works */}
<div className="rounded-xl border border-border bg-card p-6 lg:col-span-2">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl"></span>
Comment ça marche ?
</h3>
<div className="grid md:grid-cols-4 gap-4">
<StepCard
number={1}
title="Créer le check-in"
description="Créez un nouveau check-in pour la semaine avec votre collaborateur"
/>
<StepCard
number={2}
title="Remplir les catégories"
description="Pour chaque catégorie, ajoutez les éléments pertinents de la semaine"
/>
<StepCard
number={3}
title="Ajouter des émotions"
description="Associez une émotion à chaque item pour mieux exprimer votre ressenti"
/>
<StepCard
number={4}
title="Partager et discuter"
description="Partagez le check-in avec votre collaborateur pour un échange constructif"
/>
</div>
</div>
</div>
</section>
{/* Weather Deep Dive Section */}
<section className="mb-16">
<div className="flex items-center gap-3 mb-8">
<span className="text-4xl">🌤</span>
<div>
<h2 className="text-3xl font-bold text-foreground">Météo</h2>
<p className="text-blue-500 font-medium">Votre état en un coup d&apos;œil</p>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-2">
{/* Why */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">💡</span>
Pourquoi créer une météo personnelle ?
</h3>
<p className="text-muted mb-4">
La météo est un outil simple et visuel pour exprimer rapidement votre état sur 4 axes clés.
En la partageant avec votre équipe, vous créez de la transparence et facilitez la communication
sur votre bien-être et votre performance.
</p>
<ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2">
<span className="text-blue-500"></span>
Exprimer rapidement votre état avec des emojis météo intuitifs
</li>
<li className="flex items-start gap-2">
<span className="text-blue-500"></span>
Partager votre météo avec votre équipe pour une meilleure visibilité
</li>
<li className="flex items-start gap-2">
<span className="text-blue-500"></span>
Créer un espace de dialogue ouvert sur votre performance et votre moral
</li>
<li className="flex items-start gap-2">
<span className="text-blue-500"></span>
Suivre l&apos;évolution de votre état dans le temps
</li>
</ul>
</div>
{/* The 4 axes */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">📋</span>
Les 4 axes de la météo
</h3>
<div className="space-y-3">
<CategoryPill icon="☀️" name="Performance" color="#f59e0b" description="Votre performance personnelle et l'atteinte de vos objectifs" />
<CategoryPill icon="😊" name="Moral" color="#22c55e" description="Votre moral actuel et votre ressenti" />
<CategoryPill icon="🌊" name="Flux" color="#3b82f6" description="Votre flux de travail personnel et les blocages éventuels" />
<CategoryPill icon="💎" name="Création de valeur" color="#8b5cf6" description="Votre création de valeur et votre apport" />
</div>
</div>
{/* How it works */}
<div className="rounded-xl border border-border bg-card p-6 lg:col-span-2">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl"></span>
Comment ça marche ?
</h3>
<div className="grid md:grid-cols-4 gap-4">
<StepCard
number={1}
title="Créer votre météo"
description="Créez une nouvelle météo personnelle avec un titre et une date"
/>
<StepCard
number={2}
title="Choisir vos emojis"
description="Pour chaque axe, sélectionnez un emoji météo qui reflète votre état"
/>
<StepCard
number={3}
title="Ajouter des notes"
description="Complétez avec des notes globales pour détailler votre ressenti"
/>
<StepCard
number={4}
title="Partager avec l'équipe"
description="Partagez votre météo avec votre équipe ou une équipe entière pour qu'ils puissent voir votre état"
/>
</div>
</div>
</div>
</section>
{/* OKRs Deep Dive Section */} {/* OKRs Deep Dive Section */}
<section className="mb-16"> <section className="mb-16">
<div className="flex items-center gap-3 mb-8"> <div className="flex items-center gap-3 mb-8">

View File

@@ -15,10 +15,20 @@ import {
import { deleteSwotSession, updateSwotSession } from '@/actions/session'; import { deleteSwotSession, updateSwotSession } from '@/actions/session';
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators'; import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review'; import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review';
import { deleteWeeklyCheckInSession, updateWeeklyCheckInSession } from '@/actions/weekly-checkin';
import { deleteWeatherSession, updateWeatherSession } from '@/actions/weather';
type WorkshopType = 'all' | 'swot' | 'motivators' | 'year-review' | 'byPerson'; type WorkshopType = 'all' | 'swot' | 'motivators' | 'year-review' | 'weekly-checkin' | 'weather' | 'byPerson';
const VALID_TABS: WorkshopType[] = ['all', 'swot', 'motivators', 'year-review', 'byPerson']; const VALID_TABS: WorkshopType[] = [
'all',
'swot',
'motivators',
'year-review',
'weekly-checkin',
'weather',
'byPerson',
];
interface ShareUser { interface ShareUser {
id: string; id: string;
@@ -84,12 +94,42 @@ interface YearReviewSession {
workshopType: 'year-review'; workshopType: 'year-review';
} }
type AnySession = SwotSession | MotivatorSession | YearReviewSession; interface WeeklyCheckInSession {
id: string;
title: string;
participant: string;
resolvedParticipant: ResolvedCollaborator;
date: Date;
updatedAt: Date;
isOwner: boolean;
role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[];
_count: { items: number };
workshopType: 'weekly-checkin';
}
interface WeatherSession {
id: string;
title: string;
date: Date;
updatedAt: Date;
isOwner: boolean;
role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[];
_count: { entries: number };
workshopType: 'weather';
}
type AnySession = SwotSession | MotivatorSession | YearReviewSession | WeeklyCheckInSession | WeatherSession;
interface WorkshopTabsProps { interface WorkshopTabsProps {
swotSessions: SwotSession[]; swotSessions: SwotSession[];
motivatorSessions: MotivatorSession[]; motivatorSessions: MotivatorSession[];
yearReviewSessions: YearReviewSession[]; yearReviewSessions: YearReviewSession[];
weeklyCheckInSessions: WeeklyCheckInSession[];
weatherSessions: WeatherSession[];
} }
// Helper to get resolved collaborator from any session // Helper to get resolved collaborator from any session
@@ -98,6 +138,19 @@ function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
return (session as SwotSession).resolvedCollaborator; return (session as SwotSession).resolvedCollaborator;
} else if (session.workshopType === 'year-review') { } else if (session.workshopType === 'year-review') {
return (session as YearReviewSession).resolvedParticipant; return (session as YearReviewSession).resolvedParticipant;
} else if (session.workshopType === 'weekly-checkin') {
return (session as WeeklyCheckInSession).resolvedParticipant;
} else if (session.workshopType === 'weather') {
// For weather sessions, use the owner as the "participant" since it's a personal weather
const weatherSession = session as WeatherSession;
return {
raw: weatherSession.user.name || weatherSession.user.email,
matchedUser: {
id: weatherSession.user.id,
email: weatherSession.user.email,
name: weatherSession.user.name,
},
};
} else { } else {
return (session as MotivatorSession).resolvedParticipant; return (session as MotivatorSession).resolvedParticipant;
} }
@@ -141,6 +194,8 @@ export function WorkshopTabs({
swotSessions, swotSessions,
motivatorSessions, motivatorSessions,
yearReviewSessions, yearReviewSessions,
weeklyCheckInSessions,
weatherSessions,
}: WorkshopTabsProps) { }: WorkshopTabsProps) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
@@ -165,6 +220,8 @@ export function WorkshopTabs({
...swotSessions, ...swotSessions,
...motivatorSessions, ...motivatorSessions,
...yearReviewSessions, ...yearReviewSessions,
...weeklyCheckInSessions,
...weatherSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); ].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
// Filter based on active tab (for non-byPerson tabs) // Filter based on active tab (for non-byPerson tabs)
@@ -175,7 +232,11 @@ export function WorkshopTabs({
? swotSessions ? swotSessions
: activeTab === 'motivators' : activeTab === 'motivators'
? motivatorSessions ? motivatorSessions
: yearReviewSessions; : activeTab === 'year-review'
? yearReviewSessions
: activeTab === 'weekly-checkin'
? weeklyCheckInSessions
: weatherSessions;
// Separate by ownership // Separate by ownership
const ownedSessions = filteredSessions.filter((s) => s.isOwner); const ownedSessions = filteredSessions.filter((s) => s.isOwner);
@@ -226,6 +287,20 @@ export function WorkshopTabs({
label="Year Review" label="Year Review"
count={yearReviewSessions.length} count={yearReviewSessions.length}
/> />
<TabButton
active={activeTab === 'weekly-checkin'}
onClick={() => setActiveTab('weekly-checkin')}
icon="📝"
label="Weekly Check-in"
count={weeklyCheckInSessions.length}
/>
<TabButton
active={activeTab === 'weather'}
onClick={() => setActiveTab('weather')}
icon="🌤️"
label="Météo"
count={weatherSessions.length}
/>
</div> </div>
{/* Sessions */} {/* Sessions */}
@@ -338,23 +413,43 @@ function SessionCard({ session }: { session: AnySession }) {
? (session as SwotSession).collaborator ? (session as SwotSession).collaborator
: session.workshopType === 'year-review' : session.workshopType === 'year-review'
? (session as YearReviewSession).participant ? (session as YearReviewSession).participant
: session.workshopType === 'weather'
? ''
: (session as MotivatorSession).participant : (session as MotivatorSession).participant
); );
const isSwot = session.workshopType === 'swot'; const isSwot = session.workshopType === 'swot';
const isYearReview = session.workshopType === 'year-review'; const isYearReview = session.workshopType === 'year-review';
const isWeeklyCheckIn = session.workshopType === 'weekly-checkin';
const isWeather = session.workshopType === 'weather';
const href = isSwot const href = isSwot
? `/sessions/${session.id}` ? `/sessions/${session.id}`
: isYearReview : isYearReview
? `/year-review/${session.id}` ? `/year-review/${session.id}`
: isWeeklyCheckIn
? `/weekly-checkin/${session.id}`
: isWeather
? `/weather/${session.id}`
: `/motivators/${session.id}`; : `/motivators/${session.id}`;
const icon = isSwot ? '📊' : isYearReview ? '📅' : '🎯'; const icon = isSwot ? '📊' : isYearReview ? '📅' : isWeeklyCheckIn ? '📝' : isWeather ? '🌤️' : '🎯';
const participant = isSwot const participant = isSwot
? (session as SwotSession).collaborator ? (session as SwotSession).collaborator
: isYearReview : isYearReview
? (session as YearReviewSession).participant ? (session as YearReviewSession).participant
: isWeeklyCheckIn
? (session as WeeklyCheckInSession).participant
: isWeather
? (session as WeatherSession).user.name || (session as WeatherSession).user.email
: (session as MotivatorSession).participant; : (session as MotivatorSession).participant;
const accentColor = isSwot ? '#06b6d4' : isYearReview ? '#f59e0b' : '#8b5cf6'; const accentColor = isSwot
? '#06b6d4'
: isYearReview
? '#f59e0b'
: isWeeklyCheckIn
? '#10b981'
: isWeather
? '#3b82f6'
: '#8b5cf6';
const handleDelete = () => { const handleDelete = () => {
startTransition(async () => { startTransition(async () => {
@@ -362,6 +457,10 @@ function SessionCard({ session }: { session: AnySession }) {
? await deleteSwotSession(session.id) ? await deleteSwotSession(session.id)
: isYearReview : isYearReview
? await deleteYearReviewSession(session.id) ? await deleteYearReviewSession(session.id)
: isWeeklyCheckIn
? await deleteWeeklyCheckInSession(session.id)
: isWeather
? await deleteWeatherSession(session.id)
: await deleteMotivatorSession(session.id); : await deleteMotivatorSession(session.id);
if (result.success) { if (result.success) {
@@ -381,6 +480,13 @@ function SessionCard({ session }: { session: AnySession }) {
title: editTitle, title: editTitle,
participant: editParticipant, participant: editParticipant,
}) })
: isWeeklyCheckIn
? await updateWeeklyCheckInSession(session.id, {
title: editTitle,
participant: editParticipant,
})
: isWeather
? await updateWeatherSession(session.id, { title: editTitle })
: await updateMotivatorSession(session.id, { : await updateMotivatorSession(session.id, {
title: editTitle, title: editTitle,
participant: editParticipant, participant: editParticipant,
@@ -401,6 +507,8 @@ function SessionCard({ session }: { session: AnySession }) {
setShowEditModal(true); setShowEditModal(true);
}; };
const editParticipantLabel = isSwot ? 'Collaborateur' : isWeather ? '' : 'Participant';
return ( return (
<> <>
<div className="relative group"> <div className="relative group">
@@ -456,6 +564,28 @@ function SessionCard({ session }: { session: AnySession }) {
<span>·</span> <span>·</span>
<span>Année {(session as YearReviewSession).year}</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> <span>{(session as MotivatorSession)._count.cards}/10</span>
)} )}
@@ -570,8 +700,9 @@ function SessionCard({ session }: { session: AnySession }) {
htmlFor="edit-participant" htmlFor="edit-participant"
className="block text-sm font-medium text-foreground mb-1" className="block text-sm font-medium text-foreground mb-1"
> >
{isSwot ? 'Collaborateur' : 'Participant'} {editParticipantLabel}
</label> </label>
{!isWeather && (
<Input <Input
id="edit-participant" id="edit-participant"
value={editParticipant} value={editParticipant}
@@ -579,6 +710,7 @@ function SessionCard({ session }: { session: AnySession }) {
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'} placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'}
required required
/> />
)}
</div> </div>
<ModalFooter> <ModalFooter>
<Button <Button
@@ -591,7 +723,7 @@ function SessionCard({ session }: { session: AnySession }) {
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={isPending || !editTitle.trim() || !editParticipant.trim()} disabled={isPending || !editTitle.trim() || (!isWeather && !editParticipant.trim())}
> >
{isPending ? 'Enregistrement...' : 'Enregistrer'} {isPending ? 'Enregistrement...' : 'Enregistrer'}
</Button> </Button>

View File

@@ -4,6 +4,8 @@ import { auth } from '@/lib/auth';
import { getSessionsByUserId } from '@/services/sessions'; import { getSessionsByUserId } from '@/services/sessions';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators'; import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
import { getYearReviewSessionsByUserId } from '@/services/year-review'; import { getYearReviewSessionsByUserId } from '@/services/year-review';
import { getWeeklyCheckInSessionsByUserId } from '@/services/weekly-checkin';
import { getWeatherSessionsByUserId } from '@/services/weather';
import { Card, Button } from '@/components/ui'; import { Card, Button } from '@/components/ui';
import { WorkshopTabs } from './WorkshopTabs'; import { WorkshopTabs } from './WorkshopTabs';
@@ -33,11 +35,14 @@ export default async function SessionsPage() {
return null; return null;
} }
// Fetch SWOT, Moving Motivators, and Year Review sessions // Fetch SWOT, Moving Motivators, Year Review, Weekly Check-in, and Weather sessions
const [swotSessions, motivatorSessions, yearReviewSessions] = await Promise.all([ const [swotSessions, motivatorSessions, yearReviewSessions, weeklyCheckInSessions, weatherSessions] =
await Promise.all([
getSessionsByUserId(session.user.id), getSessionsByUserId(session.user.id),
getMotivatorSessionsByUserId(session.user.id), getMotivatorSessionsByUserId(session.user.id),
getYearReviewSessionsByUserId(session.user.id), getYearReviewSessionsByUserId(session.user.id),
getWeeklyCheckInSessionsByUserId(session.user.id),
getWeatherSessionsByUserId(session.user.id),
]); ]);
// Add type to each session for unified display // Add type to each session for unified display
@@ -56,11 +61,23 @@ export default async function SessionsPage() {
workshopType: 'year-review' as const, workshopType: 'year-review' as const,
})); }));
const allWeeklyCheckInSessions = weeklyCheckInSessions.map((s) => ({
...s,
workshopType: 'weekly-checkin' as const,
}));
const allWeatherSessions = weatherSessions.map((s) => ({
...s,
workshopType: 'weather' as const,
}));
// Combine and sort by updatedAt // Combine and sort by updatedAt
const allSessions = [ const allSessions = [
...allSwotSessions, ...allSwotSessions,
...allMotivatorSessions, ...allMotivatorSessions,
...allYearReviewSessions, ...allYearReviewSessions,
...allWeeklyCheckInSessions,
...allWeatherSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); ].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const hasNoSessions = allSessions.length === 0; const hasNoSessions = allSessions.length === 0;
@@ -87,11 +104,23 @@ export default async function SessionsPage() {
</Button> </Button>
</Link> </Link>
<Link href="/year-review/new"> <Link href="/year-review/new">
<Button> <Button variant="outline">
<span>📅</span> <span>📅</span>
Nouveau Year Review Nouveau Year Review
</Button> </Button>
</Link> </Link>
<Link href="/weekly-checkin/new">
<Button variant="outline">
<span>📝</span>
Nouveau Check-in
</Button>
</Link>
<Link href="/weather/new">
<Button>
<span>🌤</span>
Nouvelle Météo
</Button>
</Link>
</div> </div>
</div> </div>
@@ -104,9 +133,10 @@ export default async function SessionsPage() {
</h2> </h2>
<p className="text-muted mb-6 max-w-md mx-auto"> <p className="text-muted mb-6 max-w-md mx-auto">
Créez un atelier SWOT pour analyser les forces et faiblesses, un Moving Motivators pour Créez un atelier SWOT pour analyser les forces et faiblesses, un Moving Motivators pour
découvrir les motivations, ou un Year Review pour faire le bilan de l&apos;année. découvrir les motivations, un Year Review pour faire le bilan de l&apos;année, ou un
Weekly Check-in pour le suivi hebdomadaire.
</p> </p>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center flex-wrap">
<Link href="/sessions/new"> <Link href="/sessions/new">
<Button variant="outline"> <Button variant="outline">
<span>📊</span> <span>📊</span>
@@ -120,11 +150,23 @@ export default async function SessionsPage() {
</Button> </Button>
</Link> </Link>
<Link href="/year-review/new"> <Link href="/year-review/new">
<Button> <Button variant="outline">
<span>📅</span> <span>📅</span>
Créer un Year Review Créer un Year Review
</Button> </Button>
</Link> </Link>
<Link href="/weekly-checkin/new">
<Button variant="outline">
<span>📝</span>
Créer un Check-in
</Button>
</Link>
<Link href="/weather/new">
<Button>
<span>🌤</span>
Créer une Météo
</Button>
</Link>
</div> </div>
</Card> </Card>
) : ( ) : (
@@ -133,6 +175,8 @@ export default async function SessionsPage() {
swotSessions={allSwotSessions} swotSessions={allSwotSessions}
motivatorSessions={allMotivatorSessions} motivatorSessions={allMotivatorSessions}
yearReviewSessions={allYearReviewSessions} yearReviewSessions={allYearReviewSessions}
weeklyCheckInSessions={allWeeklyCheckInSessions}
weatherSessions={allWeatherSessions}
/> />
</Suspense> </Suspense>
)} )}

View File

@@ -0,0 +1,167 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import { OKRForm } from '@/components/okrs';
import { Card } from '@/components/ui';
import type { CreateOKRInput, CreateKeyResultInput, TeamMember, OKR, KeyResult } from '@/lib/types';
type OKRWithTeamMember = OKR & {
teamMember: {
user: {
id: string;
email: string;
name: string | null;
};
userId: string;
team: {
id: string;
name: string;
};
};
};
export default function EditOKRPage() {
const router = useRouter();
const params = useParams();
const teamId = params.id as string;
const okrId = params.okrId as string;
const [okr, setOkr] = useState<OKRWithTeamMember | null>(null);
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch OKR and team members in parallel
Promise.all([
fetch(`/api/okrs/${okrId}`).then((res) => {
if (!res.ok) {
throw new Error('OKR not found');
}
return res.json();
}),
fetch(`/api/teams/${teamId}`).then((res) => res.json()),
])
.then(([okrData, teamData]) => {
setOkr(okrData);
setTeamMembers(teamData.members || []);
})
.catch((error) => {
console.error('Error fetching data:', error);
})
.finally(() => {
setLoading(false);
});
}, [okrId, teamId]);
type KeyResultUpdate = {
id: string;
title?: string;
targetValue?: number;
unit?: string;
order?: number;
};
const handleSubmit = async (data: CreateOKRInput & {
startDate: Date | string;
endDate: Date | string;
keyResultsUpdates?: {
create?: CreateKeyResultInput[];
update?: KeyResultUpdate[];
delete?: string[]
}
}) => {
// Convert to UpdateOKRInput format
const updateData = {
objective: data.objective,
description: data.description || undefined,
period: data.period,
startDate: typeof data.startDate === 'string' ? new Date(data.startDate) : data.startDate,
endDate: typeof data.endDate === 'string' ? new Date(data.endDate) : data.endDate,
};
const payload: {
objective: string;
description?: string;
period: string;
startDate: string;
endDate: string;
keyResultsUpdates?: {
create?: CreateKeyResultInput[];
update?: KeyResultUpdate[];
delete?: string[];
};
} = {
...updateData,
startDate: updateData.startDate.toISOString(),
endDate: updateData.endDate.toISOString(),
};
// Add Key Results updates if in edit mode
if (data.keyResultsUpdates) {
payload.keyResultsUpdates = data.keyResultsUpdates;
}
const response = await fetch(`/api/okrs/${okrId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erreur lors de la mise à jour de l&apos;OKR');
}
router.push(`/teams/${teamId}/okrs/${okrId}`);
router.refresh();
};
if (loading) {
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<div className="text-center">Chargement...</div>
</main>
);
}
if (!okr) {
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<div className="text-center">OKR non trouvé</div>
</main>
);
}
// Prepare initial data for the form
const initialData: Partial<CreateOKRInput> & { keyResults?: KeyResult[] } = {
teamMemberId: okr.teamMemberId,
objective: okr.objective,
description: okr.description || undefined,
period: okr.period,
startDate: okr.startDate,
endDate: okr.endDate,
keyResults: okr.keyResults || [],
};
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<div className="mb-6">
<Link href={`/teams/${teamId}/okrs/${okrId}`} className="text-muted hover:text-foreground">
Retour à l&apos;OKR
</Link>
</div>
<Card className="p-6">
<h1 className="text-2xl font-bold text-foreground mb-6">Modifier l&apos;OKR</h1>
<OKRForm
teamMembers={teamMembers}
onSubmit={handleSubmit}
onCancel={() => router.push(`/teams/${teamId}/okrs/${okrId}`)}
initialData={initialData}
/>
</Card>
</main>
);
}

View File

@@ -55,6 +55,12 @@ type OKRWithTeamMember = OKR & {
name: string; name: string;
}; };
}; };
permissions?: {
isAdmin: boolean;
isConcernedMember: boolean;
canEdit: boolean;
canDelete: boolean;
};
}; };
export default function OKRDetailPage() { export default function OKRDetailPage() {
@@ -64,8 +70,6 @@ export default function OKRDetailPage() {
const okrId = params.okrId as string; const okrId = params.okrId as string;
const [okr, setOkr] = useState<OKRWithTeamMember | null>(null); const [okr, setOkr] = useState<OKRWithTeamMember | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [isConcernedMember, setIsConcernedMember] = useState(false);
useEffect(() => { useEffect(() => {
// Fetch OKR // Fetch OKR
@@ -78,10 +82,6 @@ export default function OKRDetailPage() {
}) })
.then((data) => { .then((data) => {
setOkr(data); setOkr(data);
// Check if current user is admin or the concerned member
// This will be properly checked server-side, but we set flags for UI
setIsAdmin(data.teamMember?.team?.id ? true : false);
setIsConcernedMember(data.teamMember?.userId ? true : false);
}) })
.catch((error) => { .catch((error) => {
console.error('Error fetching OKR:', error); console.error('Error fetching OKR:', error);
@@ -141,13 +141,14 @@ export default function OKRDetailPage() {
const progress = okr.progress || 0; const progress = okr.progress || 0;
const progressColor = const progressColor =
progress >= 75 ? 'var(--success)' : progress >= 25 ? 'var(--accent)' : 'var(--destructive)'; progress >= 75 ? 'var(--success)' : progress >= 25 ? 'var(--accent)' : 'var(--destructive)';
const canEdit = isAdmin || isConcernedMember; const canEdit = okr.permissions?.canEdit ?? false;
const canDelete = okr.permissions?.canDelete ?? false;
return ( return (
<main className="mx-auto max-w-4xl px-4 py-8"> <main className="mx-auto max-w-4xl px-4 py-8">
<div className="mb-6"> <div className="mb-6">
<Link href={`/teams/${teamId}`} className="text-muted hover:text-foreground"> <Link href={`/teams/${teamId}`} className="text-muted hover:text-foreground">
Retour à lquipe Retour à l&apos;équipe
</Link> </Link>
</div> </div>
@@ -222,8 +223,18 @@ export default function OKRDetailPage() {
</div> </div>
{/* Actions */} {/* Actions */}
{isAdmin && ( {(canEdit || canDelete) && (
<div className="mt-4 flex gap-2"> <div className="mt-4 flex gap-2">
{canEdit && (
<Button
onClick={() => router.push(`/teams/${teamId}/okrs/${okrId}/edit`)}
variant="outline"
size="sm"
>
Éditer
</Button>
)}
{canDelete && (
<Button <Button
onClick={handleDelete} onClick={handleDelete}
variant="outline" variant="outline"
@@ -235,6 +246,7 @@ export default function OKRDetailPage() {
> >
Supprimer Supprimer
</Button> </Button>
)}
</div> </div>
)} )}
</CardContent> </CardContent>

View File

@@ -45,7 +45,7 @@ export default function NewOKRPage() {
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Erreur lors de la création de l\'OKR'); throw new Error(error.error || 'Erreur lors de la création de l&apos;OKR');
} }
router.push(`/teams/${teamId}`); router.push(`/teams/${teamId}`);
@@ -64,7 +64,7 @@ export default function NewOKRPage() {
<main className="mx-auto max-w-4xl px-4 py-8"> <main className="mx-auto max-w-4xl px-4 py-8">
<div className="mb-6"> <div className="mb-6">
<Link href={`/teams/${teamId}`} className="text-muted hover:text-foreground"> <Link href={`/teams/${teamId}`} className="text-muted hover:text-foreground">
Retour à l'équipe Retour à l&apos;équipe
</Link> </Link>
</div> </div>

View File

@@ -78,7 +78,7 @@ export default async function TeamDetailPage({ params }: TeamDetailPageProps) {
</Card> </Card>
{/* OKRs Section */} {/* OKRs Section */}
<OKRsList okrsData={okrsData} teamId={id} /> <OKRsList okrsData={okrsData} teamId={id} isAdmin={isAdmin} />
</main> </main>
); );
} }

View File

@@ -0,0 +1,102 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getWeatherSessionById } from '@/services/weather';
import { getUserTeams } from '@/services/teams';
import { WeatherBoard, WeatherLiveWrapper, WeatherInfoPanel } from '@/components/weather';
import { Badge } from '@/components/ui';
import { EditableWeatherTitle } from '@/components/ui/EditableWeatherTitle';
interface WeatherSessionPageProps {
params: Promise<{ id: string }>;
}
export default async function WeatherSessionPage({ params }: WeatherSessionPageProps) {
const { id } = await params;
const authSession = await auth();
if (!authSession?.user?.id) {
return null;
}
const [session, userTeams] = await Promise.all([
getWeatherSessionById(id, authSession.user.id),
getUserTeams(authSession.user.id),
]);
if (!session) {
notFound();
}
return (
<main className="mx-auto max-w-7xl px-4 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-muted mb-2">
<Link href="/sessions?tab=weather" className="hover:text-foreground">
Météo
</Link>
<span>/</span>
<span className="text-foreground">{session.title}</span>
{!session.isOwner && (
<Badge variant="accent" className="ml-2">
Partagé par {session.user.name || session.user.email}
</Badge>
)}
</div>
<div className="flex items-start justify-between">
<div>
<EditableWeatherTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
/>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary">{session.entries.length} membres</Badge>
<span className="text-sm text-muted">
{new Date(session.date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
{/* Info sur les catégories */}
<WeatherInfoPanel />
{/* Live Wrapper + Board */}
<WeatherLiveWrapper
sessionId={session.id}
sessionTitle={session.title}
currentUserId={authSession.user.id}
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
userTeams={userTeams}
>
<WeatherBoard
sessionId={session.id}
currentUserId={authSession.user.id}
currentUser={{
id: authSession.user.id,
name: authSession.user.name ?? null,
email: authSession.user.email ?? '',
}}
entries={session.entries}
shares={session.shares}
owner={{
id: session.user.id,
name: session.user.name ?? null,
email: session.user.email ?? '',
}}
canEdit={session.canEdit}
/>
</WeatherLiveWrapper>
</main>
);
}

View File

@@ -0,0 +1,147 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
Input,
} from '@/components/ui';
import { createWeatherSession } from '@/actions/weather';
import { getWeekYearLabel } from '@/lib/date-utils';
export default function NewWeatherPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [title, setTitle] = useState(() => getWeekYearLabel(new Date(new Date().toISOString().split('T')[0])));
const [isTitleManuallyEdited, setIsTitleManuallyEdited] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const date = selectedDate ? new Date(selectedDate) : undefined;
if (!title) {
setError('Veuillez remplir le titre');
setLoading(false);
return;
}
const result = await createWeatherSession({ title, date });
if (!result.success) {
setError(result.error || 'Une erreur est survenue');
setLoading(false);
return;
}
router.push(`/weather/${result.data?.id}`);
}
function handleDateChange(e: React.ChangeEvent<HTMLInputElement>) {
const newDate = e.target.value;
setSelectedDate(newDate);
// Only update title if user hasn't manually modified it
if (!isTitleManuallyEdited) {
setTitle(getWeekYearLabel(new Date(newDate)));
}
}
function handleTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
setTitle(e.target.value);
setIsTitleManuallyEdited(true);
}
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>🌤</span>
Nouvelle Météo
</CardTitle>
<CardDescription>
Créez une météo personnelle pour faire le point sur 4 axes clés et partagez-la avec votre équipe
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<Input
label="Titre de la météo"
name="title"
placeholder="Ex: Météo S05 - 2026"
value={title}
onChange={handleTitleChange}
required
/>
<div>
<label htmlFor="date" className="block text-sm font-medium text-foreground mb-1">
Date de la météo
</label>
<input
id="date"
name="date"
type="date"
value={selectedDate}
onChange={handleDateChange}
required
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
/>
</div>
<div className="rounded-lg border border-border bg-card-hover p-4">
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
<ol className="text-sm text-muted space-y-1 list-decimal list-inside">
<li>
<strong>Performance</strong> : Comment évaluez-vous votre performance personnelle ?
</li>
<li>
<strong>Moral</strong> : Quel est votre moral actuel ?
</li>
<li>
<strong>Flux</strong> : Comment se passe votre flux de travail personnel ?
</li>
<li>
<strong>Création de valeur</strong> : Comment évaluez-vous votre création de valeur ?
</li>
</ol>
<p className="text-sm text-muted mt-2">
💡 <strong>Astuce</strong> : Partagez votre météo avec votre équipe pour qu&apos;ils puissent voir votre état. Chaque membre peut créer sa propre météo et la partager !
</p>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={loading}
>
Annuler
</Button>
<Button type="submit" loading={loading} className="flex-1">
Créer la météo
</Button>
</div>
</form>
</CardContent>
</Card>
</main>
);
}

View File

@@ -0,0 +1,101 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getWeeklyCheckInSessionById } from '@/services/weekly-checkin';
import { getUserOKRsForPeriod } from '@/services/okrs';
import { getCurrentQuarterPeriod } from '@/lib/okr-utils';
import { WeeklyCheckInBoard, WeeklyCheckInLiveWrapper } from '@/components/weekly-checkin';
import { CurrentQuarterOKRs } from '@/components/weekly-checkin/CurrentQuarterOKRs';
import { Badge, CollaboratorDisplay } from '@/components/ui';
import { EditableWeeklyCheckInTitle } from '@/components/ui';
interface WeeklyCheckInSessionPageProps {
params: Promise<{ id: string }>;
}
export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckInSessionPageProps) {
const { id } = await params;
const authSession = await auth();
if (!authSession?.user?.id) {
return null;
}
const session = await getWeeklyCheckInSessionById(id, authSession.user.id);
if (!session) {
notFound();
}
// Get current quarter OKRs for the participant (NOT the creator)
// We use session.resolvedParticipant.matchedUser.id which is the participant's user ID
const currentQuarterPeriod = getCurrentQuarterPeriod(session.date);
let currentQuarterOKRs: Awaited<ReturnType<typeof getUserOKRsForPeriod>> = [];
// Only fetch OKRs if the participant is a recognized user (has matchedUser)
if (session.resolvedParticipant.matchedUser) {
// Use participant's ID, not session.userId (which is the creator's ID)
const participantUserId = session.resolvedParticipant.matchedUser.id;
currentQuarterOKRs = await getUserOKRsForPeriod(participantUserId, currentQuarterPeriod);
}
return (
<main className="mx-auto max-w-7xl px-4 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-muted mb-2">
<Link href="/sessions?tab=weekly-checkin" className="hover:text-foreground">
Weekly Check-in
</Link>
<span>/</span>
<span className="text-foreground">{session.title}</span>
{!session.isOwner && (
<Badge variant="accent" className="ml-2">
Partagé par {session.user.name || session.user.email}
</Badge>
)}
</div>
<div className="flex items-start justify-between">
<div>
<EditableWeeklyCheckInTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
/>
<div className="mt-2">
<CollaboratorDisplay collaborator={session.resolvedParticipant} size="lg" showEmail />
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary">{session.items.length} items</Badge>
<span className="text-sm text-muted">
{new Date(session.date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
{/* Current Quarter OKRs */}
{currentQuarterOKRs.length > 0 && (
<CurrentQuarterOKRs okrs={currentQuarterOKRs} period={currentQuarterPeriod} />
)}
{/* Live Wrapper + Board */}
<WeeklyCheckInLiveWrapper
sessionId={session.id}
sessionTitle={session.title}
currentUserId={authSession.user.id}
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
>
<WeeklyCheckInBoard sessionId={session.id} items={session.items} />
</WeeklyCheckInLiveWrapper>
</main>
);
}

View File

@@ -0,0 +1,162 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
Input,
} from '@/components/ui';
import { createWeeklyCheckInSession } from '@/actions/weekly-checkin';
import { getWeekYearLabel } from '@/lib/date-utils';
export default function NewWeeklyCheckInPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [title, setTitle] = useState(() => getWeekYearLabel(new Date(new Date().toISOString().split('T')[0])));
const [isTitleManuallyEdited, setIsTitleManuallyEdited] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const formData = new FormData(e.currentTarget);
const participant = formData.get('participant') as string;
const date = selectedDate ? new Date(selectedDate) : undefined;
if (!title || !participant) {
setError('Veuillez remplir tous les champs');
setLoading(false);
return;
}
const result = await createWeeklyCheckInSession({ title, participant, date });
if (!result.success) {
setError(result.error || 'Une erreur est survenue');
setLoading(false);
return;
}
router.push(`/weekly-checkin/${result.data?.id}`);
}
function handleDateChange(e: React.ChangeEvent<HTMLInputElement>) {
const newDate = e.target.value;
setSelectedDate(newDate);
// Only update title if user hasn't manually modified it
if (!isTitleManuallyEdited) {
setTitle(getWeekYearLabel(new Date(newDate)));
}
}
function handleTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
setTitle(e.target.value);
setIsTitleManuallyEdited(true);
}
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>📝</span>
Nouveau Check-in Hebdomadaire
</CardTitle>
<CardDescription>
Créez un check-in hebdomadaire pour faire le point sur la semaine avec votre
collaborateur
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<Input
label="Titre du check-in"
name="title"
placeholder="Ex: Check-in semaine du 15 janvier"
value={title}
onChange={handleTitleChange}
required
/>
<Input
label="Nom du collaborateur"
name="participant"
placeholder="Ex: Jean Dupont"
required
/>
<div>
<label htmlFor="date" className="block text-sm font-medium text-foreground mb-1">
Date du check-in
</label>
<input
id="date"
name="date"
type="date"
value={selectedDate}
onChange={handleDateChange}
required
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
/>
</div>
<div className="rounded-lg border border-border bg-card-hover p-4">
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
<ol className="text-sm text-muted space-y-1 list-decimal list-inside">
<li>
<strong>Ce qui s&apos;est bien passé</strong> : Notez les réussites et points
positifs de la semaine
</li>
<li>
<strong>Ce qui s&apos;est mal passé</strong> : Identifiez les difficultés et
points d&apos;amélioration
</li>
<li>
<strong>Enjeux du moment</strong> : Décrivez sur quoi vous vous concentrez
actuellement
</li>
<li>
<strong>Prochains enjeux</strong> : Définissez ce sur quoi vous allez vous
concentrer prochainement
</li>
</ol>
<p className="text-sm text-muted mt-2">
💡 <strong>Astuce</strong> : Ajoutez une émotion à chaque item pour mieux exprimer
votre ressenti (fierté, joie, frustration, etc.)
</p>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={loading}
>
Annuler
</Button>
<Button type="submit" loading={loading} className="flex-1">
Créer le check-in
</Button>
</div>
</form>
</CardContent>
</Card>
</main>
);
}

View File

@@ -1,5 +1,7 @@
'use client'; 'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
import { Badge } from '@/components/ui'; import { Badge } from '@/components/ui';
@@ -71,19 +73,162 @@ function getKeyResultStatusColor(status: KeyResultStatus): { bg: string; color:
interface OKRCardProps { interface OKRCardProps {
okr: OKR & { teamMember?: { user: { id: string; email: string; name: string | null } } }; okr: OKR & { teamMember?: { user: { id: string; email: string; name: string | null } } };
teamId: string; teamId: string;
isAdmin?: boolean;
compact?: boolean;
} }
export function OKRCard({ okr, teamId }: OKRCardProps) { export function OKRCard({ okr, teamId, isAdmin = false, compact = false }: OKRCardProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const progress = okr.progress || 0; const progress = okr.progress || 0;
const progressColor = const progressColor =
progress >= 75 ? 'var(--success)' : progress >= 25 ? 'var(--accent)' : 'var(--destructive)'; progress >= 75 ? 'var(--success)' : progress >= 25 ? 'var(--accent)' : 'var(--destructive)';
const handleDelete = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!confirm(`Êtes-vous sûr de vouloir supprimer l'OKR "${okr.objective}" ?`)) {
return;
}
startTransition(async () => {
try {
const response = await fetch(`/api/okrs/${okr.id}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await response.json();
alert(error.error || "Erreur lors de la suppression de l'OKR");
return;
}
router.refresh();
} catch (error) {
console.error('Error deleting OKR:', error);
alert("Erreur lors de la suppression de l'OKR");
}
});
};
if (compact) {
return ( return (
<Card hover className="relative group">
<Link href={`/teams/${teamId}/okrs/${okr.id}`}> <Link href={`/teams/${teamId}/okrs/${okr.id}`}>
<Card hover className="h-full"> <CardHeader className="pb-2">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0 flex items-start gap-3">
<span className="text-xl flex-shrink-0">🎯</span>
<div className="flex-1 min-w-0">
<CardTitle className="text-lg leading-snug mb-1.5 line-clamp-2">
{okr.objective}
</CardTitle>
{okr.teamMember && (
<div className="flex items-center gap-1.5">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getGravatarUrl(okr.teamMember.user.email, 96)}
alt={okr.teamMember.user.name || okr.teamMember.user.email}
width={16}
height={16}
className="rounded-full flex-shrink-0"
/>
<span className="text-xs text-muted line-clamp-1">
{okr.teamMember.user.name || okr.teamMember.user.email}
</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0 relative z-10">
{isAdmin && (
<button
onClick={handleDelete}
className="h-5 w-5 p-0 flex items-center justify-center rounded hover:bg-destructive/10 transition-colors flex-shrink-0"
style={{
color: 'var(--destructive)',
border: '1px solid color-mix(in srgb, var(--destructive) 40%, transparent)',
backgroundColor: 'color-mix(in srgb, var(--destructive) 5%, transparent)',
}}
disabled={isPending}
title="Supprimer l'OKR"
>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
)}
<Badge
style={{
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
color: 'var(--purple)',
fontSize: '11px',
padding: '2px 6px',
}}
>
{okr.period}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 pb-3">
<div className="flex items-center gap-4">
{/* Progress Bar */}
<div className="flex-1 min-w-0">
<div className="mb-1 flex items-center justify-between text-xs">
<span className="text-muted">Progression</span>
<span className="font-medium" style={{ color: progressColor }}>
{progress}%
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-card-column">
<div
className="h-full transition-all"
style={{
width: `${progress}%`,
backgroundColor: progressColor,
}}
/>
</div>
</div>
{/* Status */}
<div className="flex items-center gap-2 flex-shrink-0">
<Badge style={getOKRStatusColor(okr.status)} className="text-xs px-2 py-0.5">
{OKR_STATUS_LABELS[okr.status]}
</Badge>
{okr.keyResults && okr.keyResults.length > 0 && (
<span className="text-xs text-muted whitespace-nowrap">
{okr.keyResults.length} KR{okr.keyResults.length !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
</CardContent>
</Link>
</Card>
);
}
return (
<Card hover className="h-full relative group">
<CardHeader> <CardHeader>
<div className="flex items-start justify-between gap-2">
<Link href={`/teams/${teamId}/okrs/${okr.id}`} className="flex-1 min-w-0">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1 pr-2">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<span className="text-xl">🎯</span> <span className="text-xl">🎯</span>
{okr.objective} {okr.objective}
@@ -104,6 +249,39 @@ export function OKRCard({ okr, teamId }: OKRCardProps) {
</div> </div>
)} )}
</div> </div>
</div>
</Link>
{/* Action Zone */}
<div className="flex items-center gap-2 flex-shrink-0 relative z-10">
{isAdmin && (
<button
onClick={handleDelete}
className="h-6 w-6 p-0 flex items-center justify-center rounded hover:bg-destructive/10 transition-colors flex-shrink-0"
style={{
color: 'var(--destructive)',
border: '1px solid color-mix(in srgb, var(--destructive) 40%, transparent)',
backgroundColor: 'color-mix(in srgb, var(--destructive) 5%, transparent)',
}}
disabled={isPending}
title="Supprimer l'OKR"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
)}
<Badge <Badge
style={{ style={{
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)', backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
@@ -113,7 +291,9 @@ export function OKRCard({ okr, teamId }: OKRCardProps) {
{okr.period} {okr.period}
</Badge> </Badge>
</div> </div>
</div>
</CardHeader> </CardHeader>
<Link href={`/teams/${teamId}/okrs/${okr.id}`}>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
{/* Progress Bar */} {/* Progress Bar */}
@@ -138,9 +318,7 @@ export function OKRCard({ okr, teamId }: OKRCardProps) {
{/* Status */} {/* Status */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-muted">Statut:</span> <span className="text-sm text-muted">Statut:</span>
<Badge style={getOKRStatusColor(okr.status)}> <Badge style={getOKRStatusColor(okr.status)}>{OKR_STATUS_LABELS[okr.status]}</Badge>
{OKR_STATUS_LABELS[okr.status]}
</Badge>
</div> </div>
{/* Key Results List */} {/* Key Results List */}
@@ -153,7 +331,8 @@ export function OKRCard({ okr, teamId }: OKRCardProps) {
{okr.keyResults {okr.keyResults
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
.map((kr: KeyResult) => { .map((kr: KeyResult) => {
const krProgress = kr.targetValue > 0 ? (kr.currentValue / kr.targetValue) * 100 : 0; const krProgress =
kr.targetValue > 0 ? (kr.currentValue / kr.targetValue) * 100 : 0;
const krProgressColor = const krProgressColor =
krProgress >= 100 krProgress >= 100
? 'var(--success)' ? 'var(--success)'
@@ -164,7 +343,9 @@ export function OKRCard({ okr, teamId }: OKRCardProps) {
return ( return (
<div key={kr.id} className="space-y-1"> <div key={kr.id} className="space-y-1">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<span className="text-sm text-foreground flex-1 line-clamp-2">{kr.title}</span> <span className="text-sm text-foreground flex-1 line-clamp-2">
{kr.title}
</span>
<Badge <Badge
style={{ style={{
...getKeyResultStatusColor(kr.status), ...getKeyResultStatusColor(kr.status),
@@ -202,8 +383,7 @@ export function OKRCard({ okr, teamId }: OKRCardProps) {
)} )}
</div> </div>
</CardContent> </CardContent>
</Card>
</Link> </Link>
</Card>
); );
} }

View File

@@ -5,7 +5,7 @@ import { Input } from '@/components/ui';
import { Textarea } from '@/components/ui'; import { Textarea } from '@/components/ui';
import { Button } from '@/components/ui'; import { Button } from '@/components/ui';
import { Select } from '@/components/ui'; import { Select } from '@/components/ui';
import type { CreateOKRInput, CreateKeyResultInput, TeamMember } from '@/lib/types'; import type { CreateOKRInput, CreateKeyResultInput, TeamMember, KeyResult } from '@/lib/types';
import { PERIOD_SUGGESTIONS } from '@/lib/types'; import { PERIOD_SUGGESTIONS } from '@/lib/types';
// Calcule les dates de début et de fin pour un trimestre donné // Calcule les dates de début et de fin pour un trimestre donné
@@ -57,11 +57,35 @@ function getQuarterDates(period: string): { startDate: string; endDate: string }
}; };
} }
interface KeyResultEditInput extends CreateKeyResultInput {
id?: string; // If present, it's an existing Key Result to update
}
type KeyResultUpdate = {
id: string;
title?: string;
targetValue?: number;
unit?: string;
order?: number;
};
type OKRFormSubmitData =
| (CreateOKRInput & {
startDate: Date | string;
endDate: Date | string;
keyResultsUpdates?: {
create?: CreateKeyResultInput[];
update?: KeyResultUpdate[];
delete?: string[];
};
})
| CreateOKRInput;
interface OKRFormProps { interface OKRFormProps {
teamMembers: TeamMember[]; teamMembers: TeamMember[];
onSubmit: (data: CreateOKRInput) => Promise<void>; onSubmit: (data: OKRFormSubmitData) => Promise<void>;
onCancel: () => void; onCancel: () => void;
initialData?: Partial<CreateOKRInput>; initialData?: Partial<CreateOKRInput> & { keyResults?: KeyResult[] };
} }
export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFormProps) { export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFormProps) {
@@ -76,11 +100,23 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
const [endDate, setEndDate] = useState( const [endDate, setEndDate] = useState(
initialData?.endDate ? new Date(initialData.endDate).toISOString().split('T')[0] : '' initialData?.endDate ? new Date(initialData.endDate).toISOString().split('T')[0] : ''
); );
const [keyResults, setKeyResults] = useState<CreateKeyResultInput[]>( // Initialize Key Results from existing ones if in edit mode, otherwise start with one empty
initialData?.keyResults || [ const [keyResults, setKeyResults] = useState<KeyResultEditInput[]>(() => {
{ title: '', targetValue: 100, unit: '%', order: 0 }, if (initialData?.keyResults && initialData.keyResults.length > 0) {
] return initialData.keyResults.map((kr): KeyResultEditInput => {
); const result = {
title: kr.title,
targetValue: kr.targetValue,
unit: kr.unit || '%',
order: kr.order,
};
// @ts-expect-error - id is added to extend CreateKeyResultInput to KeyResultEditInput
result.id = kr.id;
return result as KeyResultEditInput;
});
}
return [{ title: '', targetValue: 100, unit: '%', order: 0 }];
});
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
// Mise à jour automatique des dates quand la période change // Mise à jour automatique des dates quand la période change
@@ -113,7 +149,11 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
setKeyResults(keyResults.filter((_, i) => i !== index).map((kr, i) => ({ ...kr, order: i }))); setKeyResults(keyResults.filter((_, i) => i !== index).map((kr, i) => ({ ...kr, order: i })));
}; };
const updateKeyResult = (index: number, field: keyof CreateKeyResultInput, value: any) => { const updateKeyResult = (
index: number,
field: keyof KeyResultEditInput,
value: string | number
) => {
const updated = [...keyResults]; const updated = [...keyResults];
updated[index] = { ...updated[index], [field]: value }; updated[index] = { ...updated[index], [field]: value };
setKeyResults(updated); setKeyResults(updated);
@@ -127,6 +167,7 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
return; return;
} }
// Validate Key Results
if (keyResults.some((kr) => !kr.title || kr.targetValue <= 0)) { if (keyResults.some((kr) => !kr.title || kr.targetValue <= 0)) {
alert('Tous les Key Results doivent avoir un titre et une valeur cible > 0'); alert('Tous les Key Results doivent avoir un titre et une valeur cible > 0');
return; return;
@@ -150,15 +191,113 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
return; return;
} }
const isEditMode = !!initialData?.teamMemberId;
if (isEditMode) {
// Type guard for Key Results with id
type KeyResultWithId = KeyResultEditInput & { id: string };
const hasId = (kr: KeyResultEditInput): kr is KeyResultWithId => !!kr.id;
// In edit mode, separate existing Key Results from new ones
const existingKeyResults: KeyResultWithId[] = keyResults.filter(hasId);
const originalKeyResults: KeyResult[] = initialData?.keyResults || [];
const originalIds = new Set(originalKeyResults.map((kr: KeyResult) => kr.id));
const currentIds = new Set(existingKeyResults.map((kr: KeyResultWithId) => kr.id));
// Find deleted Key Results
const deletedIds = Array.from(originalIds).filter((id) => !currentIds.has(id));
// Find updated Key Results (compare with original)
const updated = existingKeyResults
.map((kr: KeyResultWithId) => {
const original = originalKeyResults.find((okr: KeyResult) => okr.id === kr.id);
if (!original) return null;
const changes: {
id: string;
title?: string;
targetValue?: number;
unit?: string;
order?: number;
} = { id: kr.id };
if (original.title !== kr.title) changes.title = kr.title;
if (original.targetValue !== kr.targetValue) changes.targetValue = kr.targetValue;
if (original.unit !== kr.unit) changes.unit = kr.unit;
if (original.order !== kr.order) changes.order = kr.order;
return Object.keys(changes).length > 1 ? changes : null; // More than just 'id'
})
.filter(
(
u
): u is {
id: string;
title?: string;
targetValue?: number;
unit?: string;
order?: number;
} => u !== null
);
// Update order for all Key Results based on their position
const allKeyResultsWithOrder = keyResults.map((kr, i) => ({ ...kr, order: i }));
const existingWithOrder = allKeyResultsWithOrder.filter(hasId) as KeyResultWithId[];
const newWithOrder = allKeyResultsWithOrder.filter((kr) => !kr.id);
// Update order for existing Key Results that changed position
const orderUpdates = existingWithOrder
.map((kr) => {
const original = originalKeyResults.find((okr: KeyResult) => okr.id === kr.id);
if (!original || original.order === kr.order) return null;
return { id: kr.id, order: kr.order };
})
.filter((u): u is { id: string; order: number } => u !== null);
// Merge order updates with other updates
const allUpdates = [...updated];
orderUpdates.forEach((orderUpdate) => {
const existingUpdate = allUpdates.find((u) => u.id === orderUpdate.id);
if (existingUpdate) {
existingUpdate.order = orderUpdate.order;
} else {
allUpdates.push(orderUpdate);
}
});
await onSubmit({ await onSubmit({
teamMemberId, teamMemberId,
objective, objective,
description: description || undefined, description: description || undefined,
period: finalPeriod, period: finalPeriod,
startDate: startDateObj.toISOString() as any, startDate: startDateObj.toISOString() as Date | string,
endDate: endDateObj.toISOString() as any, endDate: endDateObj.toISOString() as Date | string,
keyResults: [], // Not used in edit mode
keyResultsUpdates: {
create:
newWithOrder.length > 0
? newWithOrder.map((kr) => ({
title: kr.title,
targetValue: kr.targetValue,
unit: kr.unit || '%',
order: kr.order,
}))
: undefined,
update: allUpdates.length > 0 ? allUpdates : undefined,
delete: deletedIds.length > 0 ? deletedIds : undefined,
},
} as unknown as OKRFormSubmitData);
} else {
// In create mode, just send Key Results normally
await onSubmit({
teamMemberId,
objective,
description: description || undefined,
period: finalPeriod,
startDate: startDateObj,
endDate: endDateObj,
keyResults: keyResults.map((kr, i) => ({ ...kr, order: i })), keyResults: keyResults.map((kr, i) => ({ ...kr, order: i })),
}); });
}
} catch (error) { } catch (error) {
console.error('Error submitting OKR:', error); console.error('Error submitting OKR:', error);
} finally { } finally {
@@ -257,7 +396,8 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
</div> </div>
{period && period !== 'custom' && period !== '' && ( {period && period !== 'custom' && period !== '' && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Les dates sont automatiquement définies selon le trimestre sélectionné. Vous pouvez les modifier si nécessaire. Les dates sont automatiquement définies selon le trimestre sélectionné. Vous pouvez les
modifier si nécessaire.
</p> </p>
)} )}
@@ -267,13 +407,22 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
<label className="block text-sm font-medium text-foreground"> <label className="block text-sm font-medium text-foreground">
Key Results * ({keyResults.length}/5) Key Results * ({keyResults.length}/5)
</label> </label>
<Button type="button" onClick={addKeyResult} variant="outline" size="sm" disabled={keyResults.length >= 5}> <Button
type="button"
onClick={addKeyResult}
variant="outline"
size="sm"
disabled={keyResults.length >= 5}
>
+ Ajouter + Ajouter
</Button> </Button>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{keyResults.map((kr, index) => ( {keyResults.map((kr, index) => (
<div key={index} className="rounded-lg border border-border bg-card p-4"> <div
key={kr.id || `new-${index}`}
className="rounded-lg border border-border bg-card p-4"
>
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-foreground">Key Result {index + 1}</span> <span className="text-sm font-medium text-foreground">Key Result {index + 1}</span>
{keyResults.length > 1 && ( {keyResults.length > 1 && (
@@ -331,10 +480,15 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
disabled={submitting} disabled={submitting}
className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent" className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"
> >
{submitting ? 'Création...' : 'Créer l\'OKR'} {submitting
? initialData?.teamMemberId
? 'Modification...'
: 'Création...'
: initialData?.teamMemberId
? "Modifier l'OKR"
: "Créer l'OKR"}
</Button> </Button>
</div> </div>
</form> </form>
); );
} }

View File

@@ -1,12 +1,14 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { OKRCard } from './OKRCard'; import { OKRCard } from './OKRCard';
import { Card, ToggleGroup, type ToggleOption } from '@/components/ui'; import { Card, ToggleGroup, Button } from '@/components/ui';
import { getGravatarUrl } from '@/lib/gravatar'; import { getGravatarUrl } from '@/lib/gravatar';
import { getCurrentQuarterPeriod, isCurrentQuarterPeriod } from '@/lib/okr-utils';
import type { OKR } from '@/lib/types'; import type { OKR } from '@/lib/types';
type ViewMode = 'grid' | 'grouped'; type ViewMode = 'grid' | 'grouped';
type CardViewMode = 'detailed' | 'compact';
interface OKRsListProps { interface OKRsListProps {
okrsData: Array<{ okrsData: Array<{
@@ -18,13 +20,43 @@ interface OKRsListProps {
okrs: Array<OKR & { progress?: number }>; okrs: Array<OKR & { progress?: number }>;
}>; }>;
teamId: string; teamId: string;
isAdmin?: boolean;
} }
export function OKRsList({ okrsData, teamId }: OKRsListProps) { const CARD_VIEW_STORAGE_KEY = 'okr-card-view-mode';
export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
const [viewMode, setViewMode] = useState<ViewMode>('grouped'); const [viewMode, setViewMode] = useState<ViewMode>('grouped');
const [cardViewMode, setCardViewMode] = useState<CardViewMode>(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(CARD_VIEW_STORAGE_KEY);
return (stored as CardViewMode) || 'detailed';
}
return 'detailed';
});
const [showAllPeriods, setShowAllPeriods] = useState(false);
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem(CARD_VIEW_STORAGE_KEY, cardViewMode);
}
}, [cardViewMode]);
const currentQuarterPeriod = getCurrentQuarterPeriod();
// Filter OKRs based on period filter
const filteredOKRsData = useMemo(() => {
if (showAllPeriods) {
return okrsData;
}
return okrsData.map((tm) => ({
...tm,
okrs: tm.okrs.filter((okr) => isCurrentQuarterPeriod(okr.period)),
}));
}, [okrsData, showAllPeriods]);
// Flatten OKRs for grid view // Flatten OKRs for grid view
const allOKRs = okrsData.flatMap((tm) => const allOKRs = filteredOKRsData.flatMap((tm) =>
tm.okrs.map((okr) => ({ tm.okrs.map((okr) => ({
...okr, ...okr,
teamMember: { teamMember: {
@@ -35,21 +67,92 @@ export function OKRsList({ okrsData, teamId }: OKRsListProps) {
if (allOKRs.length === 0) { if (allOKRs.length === 0) {
return ( return (
<div>
{/* View Toggle */}
<div className="mb-6 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<h2 className="text-2xl font-bold text-foreground">OKRs</h2>
{!showAllPeriods && (
<span className="text-sm text-muted">({currentQuarterPeriod})</span>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
onClick={() => setShowAllPeriods(!showAllPeriods)}
className="text-sm"
>
{showAllPeriods ? `Afficher ${currentQuarterPeriod} uniquement` : 'Afficher tous les OKR'}
</Button>
</div>
</div>
<Card className="p-12 text-center"> <Card className="p-12 text-center">
<div className="text-5xl mb-4">🎯</div> <div className="text-5xl mb-4">🎯</div>
<h3 className="text-xl font-semibold text-foreground mb-2">Aucun OKR défini</h3> <h3 className="text-xl font-semibold text-foreground mb-2">
{showAllPeriods ? 'Aucun OKR défini' : `Aucun OKR pour ${currentQuarterPeriod}`}
</h3>
<p className="text-muted"> <p className="text-muted">
Aucun OKR n'a encore été défini pour cette équipe {showAllPeriods
? "Aucun OKR n'a encore été défini pour cette équipe"
: `Aucun OKR n'a été défini pour le trimestre ${currentQuarterPeriod}. Cliquez sur "Afficher tous les OKR" pour voir les OKR d'autres périodes.`}
</p> </p>
</Card> </Card>
</div>
); );
} }
return ( return (
<div> <div>
{/* View Toggle */} {/* View Toggle */}
<div className="mb-6 flex items-center justify-between"> <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> <h2 className="text-2xl font-bold text-foreground">OKRs</h2>
{!showAllPeriods && (
<span className="text-sm text-muted">({currentQuarterPeriod})</span>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
onClick={() => setShowAllPeriods(!showAllPeriods)}
className="text-sm"
>
{showAllPeriods ? `Afficher ${currentQuarterPeriod} uniquement` : 'Afficher tous les OKR'}
</Button>
<ToggleGroup
value={cardViewMode}
onChange={setCardViewMode}
options={[
{
value: 'detailed',
label: 'Détaillée',
icon: (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
),
},
{
value: 'compact',
label: 'Mini',
icon: (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
),
},
]}
/>
<ToggleGroup <ToggleGroup
value={viewMode} value={viewMode}
onChange={setViewMode} onChange={setViewMode}
@@ -59,7 +162,12 @@ export function OKRsList({ okrsData, teamId }: OKRsListProps) {
label: 'Par membre', label: 'Par membre',
icon: ( icon: (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg> </svg>
), ),
}, },
@@ -80,11 +188,12 @@ export function OKRsList({ okrsData, teamId }: OKRsListProps) {
]} ]}
/> />
</div> </div>
</div>
{/* Grouped View */} {/* Grouped View */}
{viewMode === 'grouped' ? ( {viewMode === 'grouped' ? (
<div className="space-y-8"> <div className="space-y-8">
{okrsData {filteredOKRsData
.filter((tm) => tm.okrs.length > 0) .filter((tm) => tm.okrs.length > 0)
.map((teamMember) => ( .map((teamMember) => (
<div key={teamMember.user.id}> <div key={teamMember.user.id}>
@@ -112,7 +221,9 @@ export function OKRsList({ okrsData, teamId }: OKRsListProps) {
</div> </div>
{/* OKRs Grid */} {/* OKRs Grid */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div
className={`grid gap-6 ${cardViewMode === 'compact' ? 'md:grid-cols-2 lg:grid-cols-3' : 'md:grid-cols-2 lg:grid-cols-3'}`}
>
{teamMember.okrs.map((okr) => ( {teamMember.okrs.map((okr) => (
<OKRCard <OKRCard
key={okr.id} key={okr.id}
@@ -123,6 +234,8 @@ export function OKRsList({ okrsData, teamId }: OKRsListProps) {
}, },
}} }}
teamId={teamId} teamId={teamId}
isAdmin={isAdmin}
compact={cardViewMode === 'compact'}
/> />
))} ))}
</div> </div>
@@ -131,13 +244,20 @@ export function OKRsList({ okrsData, teamId }: OKRsListProps) {
</div> </div>
) : ( ) : (
/* Grid View */ /* Grid View */
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div
className={`grid gap-6 ${cardViewMode === 'compact' ? 'md:grid-cols-2 lg:grid-cols-3' : 'md:grid-cols-2 lg:grid-cols-3'}`}
>
{allOKRs.map((okr) => ( {allOKRs.map((okr) => (
<OKRCard key={okr.id} okr={okr} teamId={teamId} /> <OKRCard
key={okr.id}
okr={okr}
teamId={teamId}
isAdmin={isAdmin}
compact={cardViewMode === 'compact'}
/>
))} ))}
</div> </div>
)} )}
</div> </div>
); );
} }

View File

@@ -0,0 +1,122 @@
'use client';
import { useState, useEffect } from 'react';
import { OKRCard } from './OKRCard';
import { ToggleGroup } from '@/components/ui';
import type { OKR } from '@/lib/types';
type CardViewMode = 'detailed' | 'compact';
interface ObjectivesListProps {
okrsByPeriod: Record<
string,
Array<OKR & { progress?: number; team: { id: string; name: string } }>
>;
periods: string[];
}
const CARD_VIEW_STORAGE_KEY = 'okr-card-view-mode';
export function ObjectivesList({ okrsByPeriod, periods }: ObjectivesListProps) {
const [cardViewMode, setCardViewMode] = useState<CardViewMode>(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(CARD_VIEW_STORAGE_KEY);
return (stored as CardViewMode) || 'detailed';
}
return 'detailed';
});
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem(CARD_VIEW_STORAGE_KEY, cardViewMode);
}
}, [cardViewMode]);
return (
<div className="space-y-8">
{/* Global View Toggle */}
<div className="flex items-center justify-end mb-4">
<ToggleGroup
value={cardViewMode}
onChange={setCardViewMode}
options={[
{
value: 'detailed',
label: 'Détaillée',
icon: (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
),
},
{
value: 'compact',
label: 'Mini',
icon: (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
),
},
]}
/>
</div>
{periods.map((period) => {
const periodOKRs = okrsByPeriod[period];
const totalProgress =
periodOKRs.reduce((sum, okr) => sum + (okr.progress || 0), 0) / periodOKRs.length;
return (
<div key={period} className="space-y-4">
{/* Period Header */}
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-3">
<span
className="rounded-lg px-3 py-1 text-sm font-medium"
style={{
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
color: 'var(--purple)',
}}
>
{period}
</span>
<span className="text-sm text-muted">
{periodOKRs.length} OKR{periodOKRs.length !== 1 ? 's' : ''}
</span>
</div>
<div className="text-sm font-medium text-foreground">
Progression moyenne:{' '}
<span style={{ color: 'var(--primary)' }}>{Math.round(totalProgress)}%</span>
</div>
</div>
{/* OKRs Grid */}
<div
className={`grid gap-6 ${cardViewMode === 'compact' ? 'md:grid-cols-2 lg:grid-cols-3' : 'md:grid-cols-2 lg:grid-cols-3'}`}
>
{periodOKRs.map((okr) => (
<OKRCard
key={okr.id}
okr={okr}
teamId={okr.team.id}
compact={cardViewMode === 'compact'}
/>
))}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -2,4 +2,4 @@ export { OKRCard } from './OKRCard';
export { OKRForm } from './OKRForm'; export { OKRForm } from './OKRForm';
export { KeyResultItem } from './KeyResultItem'; export { KeyResultItem } from './KeyResultItem';
export { OKRsList } from './OKRsList'; export { OKRsList } from './OKRsList';
export { ObjectivesList } from './ObjectivesList';

View File

@@ -62,9 +62,9 @@ interface QuadrantHelpProps {
category: SwotCategory; category: SwotCategory;
} }
export function QuadrantHelp({ category }: QuadrantHelpProps) { // eslint-disable-next-line @typescript-eslint/no-unused-vars
export function QuadrantHelp({ category: _category }: QuadrantHelpProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const content = HELP_CONTENT[category];
return ( return (
<button <button

View File

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

View File

@@ -24,7 +24,7 @@ export function DeleteTeamButton({ teamId, teamName }: DeleteTeamButtonProps) {
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
alert(error.error || 'Erreur lors de la suppression de l\'équipe'); alert(error.error || "Erreur lors de la suppression de l'équipe");
return; return;
} }
@@ -32,7 +32,7 @@ export function DeleteTeamButton({ teamId, teamName }: DeleteTeamButtonProps) {
router.refresh(); router.refresh();
} catch (error) { } catch (error) {
console.error('Error deleting team:', error); console.error('Error deleting team:', error);
alert('Erreur lors de la suppression de l\'équipe'); alert("Erreur lors de la suppression de l'équipe");
} }
}); });
}; };
@@ -44,17 +44,23 @@ export function DeleteTeamButton({ teamId, teamName }: DeleteTeamButtonProps) {
variant="outline" variant="outline"
className="text-destructive border-destructive hover:bg-destructive/10" className="text-destructive border-destructive hover:bg-destructive/10"
> >
Supprimer lquipe Supprimer l&apos;équipe
</Button> </Button>
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Supprimer l'équipe" size="sm"> <Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title="Supprimer l'équipe"
size="sm"
>
<div className="space-y-4"> <div className="space-y-4">
<p className="text-muted"> <p className="text-muted">
Êtes-vous sûr de vouloir supprimer l&apos;équipe{' '} Êtes-vous sûr de vouloir supprimer l&apos;équipe{' '}
<strong className="text-foreground">&quot;{teamName}&quot;</strong> ? <strong className="text-foreground">&quot;{teamName}&quot;</strong> ?
</p> </p>
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
Cette action est irréversible. Tous les membres, OKRs et données associées seront supprimés. Cette action est irréversible. Tous les membres, OKRs et données associées seront
supprimés.
</p> </p>
<ModalFooter> <ModalFooter>
<Button variant="ghost" onClick={() => setShowModal(false)} disabled={isPending}> <Button variant="ghost" onClick={() => setShowModal(false)} disabled={isPending}>
@@ -69,4 +75,3 @@ export function DeleteTeamButton({ teamId, teamName }: DeleteTeamButtonProps) {
</> </>
); );
} }

View File

@@ -1,5 +1,5 @@
export { TeamCard } from './TeamCard'; export { TeamCard } from './TeamCard';
export { TeamDetailClient } from './TeamDetailClient';
export { MembersList } from './MembersList'; export { MembersList } from './MembersList';
export { AddMemberModal } from './AddMemberModal'; export { AddMemberModal } from './AddMemberModal';
export { DeleteTeamButton } from './DeleteTeamButton'; export { DeleteTeamButton } from './DeleteTeamButton';

View File

@@ -0,0 +1,28 @@
'use client';
import { EditableTitle } from './EditableTitle';
import { updateWeatherSession } from '@/actions/weather';
interface EditableWeatherTitleProps {
sessionId: string;
initialTitle: string;
isOwner: boolean;
}
export function EditableWeatherTitle({
sessionId,
initialTitle,
isOwner,
}: EditableWeatherTitleProps) {
return (
<EditableTitle
sessionId={sessionId}
initialTitle={initialTitle}
isOwner={isOwner}
onUpdate={async (id, title) => {
const result = await updateWeatherSession(id, { title });
return result;
}}
/>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
import { EditableTitle } from './EditableTitle';
import { updateWeeklyCheckInSession } from '@/actions/weekly-checkin';
interface EditableWeeklyCheckInTitleProps {
sessionId: string;
initialTitle: string;
isOwner: boolean;
}
export function EditableWeeklyCheckInTitle({
sessionId,
initialTitle,
isOwner,
}: EditableWeeklyCheckInTitleProps) {
return (
<EditableTitle
sessionId={sessionId}
initialTitle={initialTitle}
isOwner={isOwner}
onUpdate={async (id, title) => {
const result = await updateWeeklyCheckInSession(id, { title });
return result;
}}
/>
);
}

View File

@@ -7,6 +7,8 @@ export { EditableTitle } from './EditableTitle';
export { EditableSessionTitle } from './EditableSessionTitle'; export { EditableSessionTitle } from './EditableSessionTitle';
export { EditableMotivatorTitle } from './EditableMotivatorTitle'; export { EditableMotivatorTitle } from './EditableMotivatorTitle';
export { EditableYearReviewTitle } from './EditableYearReviewTitle'; export { EditableYearReviewTitle } from './EditableYearReviewTitle';
export { EditableWeeklyCheckInTitle } from './EditableWeeklyCheckInTitle';
export { EditableWeatherTitle } from './EditableWeatherTitle';
export { Input } from './Input'; export { Input } from './Input';
export { Modal, ModalFooter } from './Modal'; export { Modal, ModalFooter } from './Modal';
export { Select } from './Select'; export { Select } from './Select';

View File

@@ -0,0 +1,146 @@
'use client';
import { useMemo } from 'react';
import { WeatherCard } from './WeatherCard';
interface WeatherEntry {
id: string;
userId: string;
performanceEmoji: string | null;
moralEmoji: string | null;
fluxEmoji: string | null;
valueCreationEmoji: string | null;
notes: string | null;
user: {
id: string;
name: string | null;
email: string;
};
}
interface Share {
id: string;
userId: string;
user: {
id: string;
name: string | null;
email: string;
};
}
interface WeatherBoardProps {
sessionId: string;
currentUserId: string;
currentUser: {
id: string;
name: string | null;
email: string;
};
entries: WeatherEntry[];
shares: Share[];
owner: {
id: string;
name: string | null;
email: string;
};
canEdit: boolean;
}
export function WeatherBoard({
sessionId,
currentUserId,
entries,
shares,
owner,
canEdit,
}: WeatherBoardProps) {
// 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]);
// Create entries map for quick lookup
const entriesMap = useMemo(() => {
const map = new Map<string, WeatherEntry>();
entries.forEach((entry) => {
map.set(entry.userId, entry);
});
return map;
}, [entries]);
// Create entries for all users (with placeholder entries for users without entries)
const allEntries = useMemo(() => {
return allUsers.map((user) => {
const existingEntry = entriesMap.get(user.id);
if (existingEntry) {
return existingEntry;
}
// Create placeholder entry for user without entry
return {
id: '',
userId: user.id,
performanceEmoji: null,
moralEmoji: null,
fluxEmoji: null,
valueCreationEmoji: null,
notes: null,
user,
};
});
}, [allUsers, entriesMap]);
// Sort: current user first, then owner, then others
const sortedEntries = useMemo(() => {
return [...allEntries].sort((a, b) => {
if (a.userId === currentUserId) return -1;
if (b.userId === currentUserId) return 1;
if (a.userId === owner.id) return -1;
if (b.userId === owner.id) return 1;
return (a.user.name || a.user.email).localeCompare(b.user.name || b.user.email, 'fr');
});
}, [allEntries, currentUserId, owner.id]);
return (
<div className="overflow-x-auto rounded-lg border border-border bg-card">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-card-column">
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Membre</th>
<th className="px-4 py-3 text-center text-sm font-medium text-foreground">
Performance
</th>
<th className="px-4 py-3 text-center text-sm font-medium text-foreground">Moral</th>
<th className="px-4 py-3 text-center text-sm font-medium text-foreground">Flux</th>
<th className="px-4 py-3 text-center text-sm font-medium text-foreground">
Création de valeur
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground min-w-[300px]">
Notes
</th>
</tr>
</thead>
<tbody>
{sortedEntries.map((entry) => (
<WeatherCard
key={entry.userId}
sessionId={sessionId}
currentUserId={currentUserId}
entry={entry}
canEdit={canEdit}
/>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,283 @@
'use client';
import { useState, useTransition, useEffect } from 'react';
import { createOrUpdateWeatherEntry } from '@/actions/weather';
import { Avatar } from '@/components/ui/Avatar';
import { Textarea } from '@/components/ui/Textarea';
const WEATHER_EMOJIS = [
{ emoji: '', label: 'Aucun' },
{ emoji: '☀️', label: 'Soleil' },
{ emoji: '🌤️', label: 'Soleil derrière nuage' },
{ emoji: '⛅', label: 'Soleil et nuages' },
{ emoji: '☁️', label: 'Nuages' },
{ emoji: '🌦️', label: 'Soleil et pluie' },
{ emoji: '🌧️', label: 'Pluie' },
{ emoji: '⛈️', label: 'Orage et pluie' },
{ emoji: '🌩️', label: 'Éclair' },
{ emoji: '❄️', label: 'Neige' },
{ emoji: '🌨️', label: 'Neige qui tombe' },
{ emoji: '🌪️', label: 'Tornade' },
{ emoji: '🌫️', label: 'Brouillard' },
{ emoji: '🌈', label: 'Arc-en-ciel' },
{ emoji: '🌊', label: 'Vague' },
{ emoji: '🔥', label: 'Feu' },
{ emoji: '💨', label: 'Vent' },
{ emoji: '⭐', label: 'Étoile' },
{ emoji: '🌟', label: 'Étoile brillante' },
{ emoji: '✨', label: 'Étincelles' },
];
interface WeatherEntry {
id: string;
userId: string;
performanceEmoji: string | null;
moralEmoji: string | null;
fluxEmoji: string | null;
valueCreationEmoji: string | null;
notes: string | null;
user: {
id: string;
name: string | null;
email: string;
};
}
interface WeatherCardProps {
sessionId: string;
currentUserId: string;
entry: WeatherEntry;
canEdit: boolean;
}
export function WeatherCard({ sessionId, currentUserId, entry, canEdit }: WeatherCardProps) {
const [isPending, startTransition] = useTransition();
const [notes, setNotes] = useState(entry.notes || '');
const [performanceEmoji, setPerformanceEmoji] = useState(entry.performanceEmoji || null);
const [moralEmoji, setMoralEmoji] = useState(entry.moralEmoji || null);
const [fluxEmoji, setFluxEmoji] = useState(entry.fluxEmoji || null);
const [valueCreationEmoji, setValueCreationEmoji] = useState(entry.valueCreationEmoji || null);
const isCurrentUser = entry.userId === currentUserId;
const canEditThis = canEdit && isCurrentUser;
// Sync local state with props when they change (e.g., from SSE refresh)
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setNotes(entry.notes || '');
setPerformanceEmoji(entry.performanceEmoji || null);
setMoralEmoji(entry.moralEmoji || null);
setFluxEmoji(entry.fluxEmoji || null);
setValueCreationEmoji(entry.valueCreationEmoji || null);
}, [entry.notes, entry.performanceEmoji, entry.moralEmoji, entry.fluxEmoji, entry.valueCreationEmoji]);
function handleEmojiChange(axis: 'performance' | 'moral' | 'flux' | 'valueCreation', emoji: string | null) {
if (!canEditThis) return;
// Calculate new values
const newPerformanceEmoji = axis === 'performance' ? emoji : performanceEmoji;
const newMoralEmoji = axis === 'moral' ? emoji : moralEmoji;
const newFluxEmoji = axis === 'flux' ? emoji : fluxEmoji;
const newValueCreationEmoji = axis === 'valueCreation' ? emoji : valueCreationEmoji;
// Update local state immediately
if (axis === 'performance') {
setPerformanceEmoji(emoji);
} else if (axis === 'moral') {
setMoralEmoji(emoji);
} else if (axis === 'flux') {
setFluxEmoji(emoji);
} else if (axis === 'valueCreation') {
setValueCreationEmoji(emoji);
}
// Save to server with new values
startTransition(async () => {
await createOrUpdateWeatherEntry(sessionId, {
performanceEmoji: newPerformanceEmoji,
moralEmoji: newMoralEmoji,
fluxEmoji: newFluxEmoji,
valueCreationEmoji: newValueCreationEmoji,
notes,
});
});
}
function handleNotesChange(newNotes: string) {
if (!canEditThis) return;
setNotes(newNotes);
}
function handleNotesBlur() {
if (!canEditThis) return;
startTransition(async () => {
await createOrUpdateWeatherEntry(sessionId, {
performanceEmoji,
moralEmoji,
fluxEmoji,
valueCreationEmoji,
notes,
});
});
}
// For current user without entry, we need to get user info from somewhere
// For now, we'll use a placeholder - in real app, you'd pass user info as prop
const user = entry.user;
return (
<tr className={`border-b border-border ${isPending ? 'opacity-50' : ''}`}>
{/* User column */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Avatar email={user.email} name={user.name} size={32} />
<span className="text-sm font-medium text-foreground">
{user.name || user.email || 'Vous'}
</span>
</div>
</td>
{/* Performance */}
<td className="w-24 px-2 py-3">
{canEditThis ? (
<div className="relative mx-auto w-fit">
<select
value={performanceEmoji || ''}
onChange={(e) => handleEmojiChange('performance', e.target.value || null)}
className="w-16 appearance-none rounded-lg border border-border bg-card px-2 py-2.5 pr-8 text-center text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{WEATHER_EMOJIS.map(({ emoji }) => (
<option key={emoji || 'none'} value={emoji}>
{emoji}
</option>
))}
</select>
<div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
<svg
className="h-3 w-3 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
) : (
<div className="text-2xl text-center">{performanceEmoji || '-'}</div>
)}
</td>
{/* Moral */}
<td className="w-24 px-2 py-3">
{canEditThis ? (
<div className="relative mx-auto w-fit">
<select
value={moralEmoji || ''}
onChange={(e) => handleEmojiChange('moral', e.target.value || null)}
className="w-16 appearance-none rounded-lg border border-border bg-card px-2 py-2.5 pr-8 text-center text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{WEATHER_EMOJIS.map(({ emoji }) => (
<option key={emoji || 'none'} value={emoji}>
{emoji}
</option>
))}
</select>
<div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
<svg
className="h-3 w-3 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
) : (
<div className="text-2xl text-center">{moralEmoji || '-'}</div>
)}
</td>
{/* Flux */}
<td className="w-24 px-2 py-3">
{canEditThis ? (
<div className="relative mx-auto w-fit">
<select
value={fluxEmoji || ''}
onChange={(e) => handleEmojiChange('flux', e.target.value || null)}
className="w-16 appearance-none rounded-lg border border-border bg-card px-2 py-2.5 pr-8 text-center text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{WEATHER_EMOJIS.map(({ emoji }) => (
<option key={emoji || 'none'} value={emoji}>
{emoji}
</option>
))}
</select>
<div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
<svg
className="h-3 w-3 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
) : (
<div className="text-2xl text-center">{fluxEmoji || '-'}</div>
)}
</td>
{/* Création de valeur */}
<td className="w-24 px-2 py-3">
{canEditThis ? (
<div className="relative mx-auto w-fit">
<select
value={valueCreationEmoji || ''}
onChange={(e) => handleEmojiChange('valueCreation', e.target.value || null)}
className="w-16 appearance-none rounded-lg border border-border bg-card px-2 py-2.5 pr-8 text-center text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{WEATHER_EMOJIS.map(({ emoji }) => (
<option key={emoji || 'none'} value={emoji}>
{emoji}
</option>
))}
</select>
<div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
<svg
className="h-3 w-3 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
) : (
<div className="text-2xl text-center">{valueCreationEmoji || '-'}</div>
)}
</td>
{/* Notes */}
<td className="px-4 py-3 min-w-[400px]">
{canEditThis ? (
<Textarea
value={notes}
onChange={(e) => handleNotesChange(e.target.value)}
onBlur={handleNotesBlur}
placeholder="Notes globales..."
className="min-h-[120px] w-full resize-y"
rows={5}
/>
) : (
<div className="text-sm text-foreground whitespace-pre-wrap min-h-[120px]">
{notes || '-'}
</div>
)}
</td>
</tr>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { useState } from 'react';
export function WeatherInfoPanel() {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="mb-6 rounded-lg border border-border bg-card-hover">
<button
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>
<svg
className={`h-4 w-4 text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="border-t border-border px-4 py-3">
<div className="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<p className="text-xs font-medium text-foreground mb-0.5"> Performance</p>
<p className="text-xs text-muted leading-relaxed">
Votre performance personnelle et l&apos;atteinte de vos objectifs
</p>
</div>
<div>
<p className="text-xs font-medium text-foreground mb-0.5">😊 Moral</p>
<p className="text-xs text-muted leading-relaxed">
Votre moral actuel et votre ressenti
</p>
</div>
<div>
<p className="text-xs font-medium text-foreground mb-0.5">🌊 Flux</p>
<p className="text-xs text-muted leading-relaxed">
Votre flux de travail personnel et les blocages éventuels
</p>
</div>
<div>
<p className="text-xs font-medium text-foreground mb-0.5">💎 Création de valeur</p>
<p className="text-xs text-muted leading-relaxed">
Votre création de valeur et votre apport
</p>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import { useState, useCallback } from 'react';
import { useWeatherLive, type WeatherLiveEvent } from '@/hooks/useWeatherLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { WeatherShareModal } from './WeatherShareModal';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface Team {
id: string;
name: string;
description: string | null;
userRole: 'ADMIN' | 'MEMBER';
}
interface WeatherLiveWrapperProps {
sessionId: string;
sessionTitle: string;
currentUserId: string;
shares: Share[];
isOwner: boolean;
canEdit: boolean;
userTeams?: Team[];
children: React.ReactNode;
}
export function WeatherLiveWrapper({
sessionId,
sessionTitle,
currentUserId,
shares,
isOwner,
canEdit,
userTeams = [],
children,
}: WeatherLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: WeatherLiveEvent) => {
// Show who made the last change
if (event.user?.name || event.user?.email) {
setLastEventUser(event.user.name || event.user.email);
// Clear after 3 seconds
setTimeout(() => setLastEventUser(null), 3000);
}
}, []);
const { isConnected, error } = useWeatherLive({
sessionId,
currentUserId,
onEvent: handleEvent,
});
return (
<>
{/* Header toolbar */}
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-4">
<LiveIndicator isConnected={isConnected} error={error} />
{lastEventUser && (
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
<span></span>
<span>{lastEventUser} édite...</span>
</div>
)}
{!canEdit && (
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
<span>👁</span>
<span>Mode lecture</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Collaborators avatars */}
{shares.length > 0 && (
<div className="flex -space-x-2">
{shares.slice(0, 3).map((share) => (
<Avatar
key={share.id}
email={share.user.email}
name={share.user.name}
size={32}
className="border-2 border-card"
/>
))}
{shares.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
+{shares.length - 3}
</div>
)}
</div>
)}
<Button variant="outline" size="sm" onClick={() => setShareModalOpen(true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="mr-2 h-4 w-4"
>
<path d="M13 4.5a2.5 2.5 0 11.702 1.737L6.97 9.604a2.518 2.518 0 010 .792l6.733 3.367a2.5 2.5 0 11-.671 1.341l-6.733-3.367a2.5 2.5 0 110-3.475l6.733-3.366A2.52 2.52 0 0113 4.5z" />
</svg>
Partager
</Button>
</div>
</div>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<WeatherShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
userTeams={userTeams}
/>
</>
);
}

View File

@@ -0,0 +1,307 @@
'use client';
import { useState, useTransition } from 'react';
import Link from 'next/link';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Avatar } from '@/components/ui/Avatar';
import { shareWeatherSession, shareWeatherSessionToTeam, removeWeatherShare } from '@/actions/weather';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface Team {
id: string;
name: string;
description: string | null;
userRole: 'ADMIN' | 'MEMBER';
}
interface WeatherShareModalProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
sessionTitle: string;
shares: Share[];
isOwner: boolean;
userTeams?: Team[];
}
export function WeatherShareModal({
isOpen,
onClose,
sessionId,
sessionTitle,
shares,
isOwner,
userTeams = [],
}: WeatherShareModalProps) {
const [shareType, setShareType] = useState<'user' | 'team'>('user');
const [email, setEmail] = useState('');
const [teamId, setTeamId] = useState('');
const [role, setRole] = useState<ShareRole>('EDITOR');
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
async function handleShare(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
let result;
if (shareType === 'team') {
result = await shareWeatherSessionToTeam(sessionId, teamId, role);
} else {
result = await shareWeatherSession(sessionId, email, role);
}
if (result.success) {
setEmail('');
setTeamId('');
} else {
setError(result.error || 'Erreur lors du partage');
}
});
}
async function handleRemove(userId: string) {
startTransition(async () => {
await removeWeatherShare(sessionId, userId);
});
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Partager la météo">
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Météo personnelle</p>
<p className="font-medium text-foreground">{sessionTitle}</p>
</div>
{/* Share form (only for owner) */}
{isOwner && (
<form onSubmit={handleShare} className="space-y-4">
{/* Share type selector */}
<div className="flex gap-2 border-b border-border pb-3">
<button
type="button"
onClick={() => {
setShareType('user');
setEmail('');
setTeamId('');
}}
className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
shareType === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-card-hover text-muted hover:text-foreground'
}`}
>
👤 Utilisateur
</button>
<button
type="button"
onClick={() => {
setShareType('team');
setEmail('');
setTeamId('');
}}
className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
shareType === 'team'
? 'bg-primary text-primary-foreground'
: 'bg-card-hover text-muted hover:text-foreground'
}`}
>
👥 Équipe
</button>
</div>
{/* User share */}
{shareType === 'user' && (
<div className="flex gap-2">
<Input
type="email"
placeholder="Email de l'utilisateur"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1"
required
/>
<div className="relative">
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-sm text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg
className="h-4 w-4 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</div>
)}
{/* Team share */}
{shareType === 'team' && (
<div className="space-y-2">
{userTeams.length === 0 ? (
<p className="text-sm text-muted">
Vous n&apos;êtes membre d&apos;aucune équipe. Créez une équipe depuis la page{' '}
<Link href="/teams" className="text-primary hover:underline">
Équipes
</Link>
.
</p>
) : (
<>
<div className="relative">
<select
value={teamId}
onChange={(e) => setTeamId(e.target.value)}
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-sm text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
required
>
<option value="">Sélectionner une équipe</option>
{userTeams.map((team) => (
<option key={team.id} value={team.id}>
{team.name} {team.userRole === 'ADMIN' && '(Admin)'}
</option>
))}
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg
className="h-4 w-4 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<div className="relative">
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-sm text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg
className="h-4 w-4 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</>
)}
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button
type="submit"
disabled={isPending || (shareType === 'user' && !email) || (shareType === 'team' && !teamId)}
className="w-full"
>
{isPending ? 'Partage...' : shareType === 'team' ? "Partager à l'équipe" : 'Partager'}
</Button>
</form>
)}
{/* Current shares */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
{shares.length === 0 ? (
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
) : (
<ul className="space-y-2">
{shares.map((share) => (
<li
key={share.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
>
<div className="flex items-center gap-3">
<Avatar email={share.user.email} name={share.user.name} size={32} />
<div>
<p className="text-sm font-medium text-foreground">
{share.user.name || share.user.email}
</p>
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
</Badge>
{isOwner && (
<button
onClick={() => handleRemove(share.user.id)}
disabled={isPending}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
title="Retirer l'accès"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
</li>
))}
</ul>
)}
</div>
{/* Help text */}
<div className="rounded-lg bg-primary/5 p-3">
<p className="text-xs text-muted">
<strong>Éditeur</strong> : peut modifier sa météo et voir celle des autres
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</p>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,5 @@
export { WeatherBoard } from './WeatherBoard';
export { WeatherCard } from './WeatherCard';
export { WeatherLiveWrapper } from './WeatherLiveWrapper';
export { WeatherShareModal } from './WeatherShareModal';
export { WeatherInfoPanel } from './WeatherInfoPanel';

View File

@@ -0,0 +1,161 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
import { Badge } from '@/components/ui';
import type { OKR } from '@/lib/types';
import { OKR_STATUS_LABELS } from '@/lib/types';
type OKRWithTeam = OKR & {
team?: {
id: string;
name: string;
} | null;
};
interface CurrentQuarterOKRsProps {
okrs: OKRWithTeam[];
period: string;
}
export function CurrentQuarterOKRs({ okrs, period }: CurrentQuarterOKRsProps) {
const [isExpanded, setIsExpanded] = useState(true);
if (okrs.length === 0) {
return null;
}
return (
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<span>🎯</span>
<span>Objectifs du trimestre ({period})</span>
<svg
className={`w-5 h-5 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</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">
<h4 className="font-medium text-foreground">{okr.objective}</h4>
<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">{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, 3).map((kr) => {
const krProgress = kr.targetValue > 0
? Math.round((kr.currentValue / kr.targetValue) * 100)
: 0;
return (
<li key={kr.id} className="text-xs text-muted flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
<span className="flex-1">{kr.title}</span>
<span className="text-muted">
{kr.currentValue}/{kr.targetValue} {kr.unit}
</span>
<span className="text-xs">({krProgress}%)</span>
</li>
);
})}
{okr.keyResults.length > 3 && (
<li className="text-xs text-muted pl-3.5">
+{okr.keyResults.length - 3} autre{okr.keyResults.length - 3 > 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>
</CardContent>
)}
</Card>
);
}
function getOKRStatusColor(status: OKR['status']): { bg: string; color: string } {
switch (status) {
case 'NOT_STARTED':
return {
bg: 'color-mix(in srgb, #6b7280 15%, transparent)',
color: '#6b7280',
};
case 'IN_PROGRESS':
return {
bg: 'color-mix(in srgb, #3b82f6 15%, transparent)',
color: '#3b82f6',
};
case 'COMPLETED':
return {
bg: 'color-mix(in srgb, #10b981 15%, transparent)',
color: '#10b981',
};
case 'CANCELLED':
return {
bg: 'color-mix(in srgb, #ef4444 15%, transparent)',
color: '#ef4444',
};
default:
return {
bg: 'color-mix(in srgb, #6b7280 15%, transparent)',
color: '#6b7280',
};
}
}

View File

@@ -0,0 +1,94 @@
'use client';
import { useTransition } from 'react';
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
import type { WeeklyCheckInItem, WeeklyCheckInCategory } from '@prisma/client';
import { WeeklyCheckInSection } from './WeeklyCheckInSection';
import { WeeklyCheckInCard } from './WeeklyCheckInCard';
import { moveWeeklyCheckInItem, reorderWeeklyCheckInItems } from '@/actions/weekly-checkin';
import { WEEKLY_CHECK_IN_SECTIONS } from '@/lib/types';
interface WeeklyCheckInBoardProps {
sessionId: string;
items: WeeklyCheckInItem[];
}
export function WeeklyCheckInBoard({ sessionId, items }: WeeklyCheckInBoardProps) {
const [isPending, startTransition] = useTransition();
const itemsByCategory = WEEKLY_CHECK_IN_SECTIONS.reduce(
(acc, section) => {
acc[section.category] = items
.filter((item) => item.category === section.category)
.sort((a, b) => a.order - b.order);
return acc;
},
{} as Record<WeeklyCheckInCategory, WeeklyCheckInItem[]>
);
function handleDragEnd(result: DropResult) {
if (!result.destination) return;
const { source, destination, draggableId } = result;
const sourceCategory = source.droppableId as WeeklyCheckInCategory;
const destCategory = destination.droppableId as WeeklyCheckInCategory;
// If same position, do nothing
if (sourceCategory === destCategory && source.index === destination.index) {
return;
}
startTransition(async () => {
if (sourceCategory === destCategory) {
// Same category - just reorder
const categoryItems = itemsByCategory[sourceCategory];
const itemIds = categoryItems.map((item) => item.id);
const [removed] = itemIds.splice(source.index, 1);
itemIds.splice(destination.index, 0, removed);
await reorderWeeklyCheckInItems(sessionId, sourceCategory, itemIds);
} else {
// Different category - move item
await moveWeeklyCheckInItem(draggableId, sessionId, destCategory, destination.index);
}
});
}
return (
<div className={`space-y-6 ${isPending ? 'opacity-70 pointer-events-none' : ''}`}>
{/* Weekly Check-in Sections */}
<DragDropContext onDragEnd={handleDragEnd}>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{WEEKLY_CHECK_IN_SECTIONS.map((section) => (
<Droppable key={section.category} droppableId={section.category}>
{(provided, snapshot) => (
<WeeklyCheckInSection
category={section.category}
sessionId={sessionId}
isDraggingOver={snapshot.isDraggingOver}
ref={provided.innerRef}
{...provided.droppableProps}
>
{itemsByCategory[section.category].map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(dragProvided, dragSnapshot) => (
<WeeklyCheckInCard
item={item}
sessionId={sessionId}
isDragging={dragSnapshot.isDragging}
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
/>
)}
</Draggable>
))}
{provided.placeholder}
</WeeklyCheckInSection>
)}
</Droppable>
))}
</div>
</DragDropContext>
</div>
);
}

View File

@@ -0,0 +1,200 @@
'use client';
import { forwardRef, useState, useTransition } from 'react';
import type { WeeklyCheckInItem } from '@prisma/client';
import { updateWeeklyCheckInItem, deleteWeeklyCheckInItem } from '@/actions/weekly-checkin';
import { WEEKLY_CHECK_IN_BY_CATEGORY, EMOTION_BY_TYPE } from '@/lib/types';
import { Select } from '@/components/ui/Select';
interface WeeklyCheckInCardProps {
item: WeeklyCheckInItem;
sessionId: string;
isDragging: boolean;
}
export const WeeklyCheckInCard = 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];
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);
});
}
async function handleDelete() {
startTransition(async () => {
await deleteWeeklyCheckInItem(item.id, sessionId);
});
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
setContent(item.content);
setEmotion(item.emotion);
setIsEditing(false);
}
}
return (
<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);
setIsEditing(false);
}}
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
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`,
}}
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>
);
}
);
WeeklyCheckInCard.displayName = 'WeeklyCheckInCard';

View File

@@ -0,0 +1,132 @@
'use client';
import { useState, useCallback } from 'react';
import { useWeeklyCheckInLive, type WeeklyCheckInLiveEvent } from '@/hooks/useWeeklyCheckInLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { WeeklyCheckInShareModal } from './WeeklyCheckInShareModal';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface WeeklyCheckInLiveWrapperProps {
sessionId: string;
sessionTitle: string;
currentUserId: string;
shares: Share[];
isOwner: boolean;
canEdit: boolean;
children: React.ReactNode;
}
export function WeeklyCheckInLiveWrapper({
sessionId,
sessionTitle,
currentUserId,
shares,
isOwner,
canEdit,
children,
}: WeeklyCheckInLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: WeeklyCheckInLiveEvent) => {
// Show who made the last change
if (event.user?.name || event.user?.email) {
setLastEventUser(event.user.name || event.user.email);
// Clear after 3 seconds
setTimeout(() => setLastEventUser(null), 3000);
}
}, []);
const { isConnected, error } = useWeeklyCheckInLive({
sessionId,
currentUserId,
onEvent: handleEvent,
});
return (
<>
{/* Header toolbar */}
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-4">
<LiveIndicator isConnected={isConnected} error={error} />
{lastEventUser && (
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
<span></span>
<span>{lastEventUser} édite...</span>
</div>
)}
{!canEdit && (
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
<span>👁</span>
<span>Mode lecture</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Collaborators avatars */}
{shares.length > 0 && (
<div className="flex -space-x-2">
{shares.slice(0, 3).map((share) => (
<Avatar
key={share.id}
email={share.user.email}
name={share.user.name}
size={32}
className="border-2 border-card"
/>
))}
{shares.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
+{shares.length - 3}
</div>
)}
</div>
)}
<Button variant="outline" size="sm" onClick={() => setShareModalOpen(true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="mr-2 h-4 w-4"
>
<path d="M13 4.5a2.5 2.5 0 11.702 1.737L6.97 9.604a2.518 2.518 0 010 .792l6.733 3.367a2.5 2.5 0 11-.671 1.341l-6.733-3.367a2.5 2.5 0 110-3.475l6.733-3.366A2.52 2.52 0 0113 4.5z" />
</svg>
Partager
</Button>
</div>
</div>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<WeeklyCheckInShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
/>
</>
);
}

View File

@@ -0,0 +1,173 @@
'use client';
import { forwardRef, useState, useTransition, useRef, ReactNode } from 'react';
import type { WeeklyCheckInCategory } from '@prisma/client';
import { createWeeklyCheckInItem } from '@/actions/weekly-checkin';
import { WEEKLY_CHECK_IN_BY_CATEGORY, EMOTION_BY_TYPE } from '@/lib/types';
import { Select } from '@/components/ui/Select';
interface WeeklyCheckInSectionProps {
category: WeeklyCheckInCategory;
sessionId: string;
isDraggingOver: boolean;
children: ReactNode;
}
export const WeeklyCheckInSection = forwardRef<HTMLDivElement, WeeklyCheckInSectionProps>(
({ category, sessionId, isDraggingOver, children, ...props }, ref) => {
const [isAdding, setIsAdding] = useState(false);
const [newContent, setNewContent] = useState('');
const [newEmotion, setNewEmotion] = useState<'NONE'>('NONE');
const [isPending, startTransition] = useTransition();
const isSubmittingRef = useRef(false);
const config = WEEKLY_CHECK_IN_BY_CATEGORY[category];
async function handleAdd() {
if (isSubmittingRef.current || !newContent.trim()) {
setIsAdding(false);
return;
}
isSubmittingRef.current = true;
startTransition(async () => {
await createWeeklyCheckInItem(sessionId, {
content: newContent.trim(),
category,
emotion: newEmotion,
});
setNewContent('');
setNewEmotion('NONE');
setIsAdding(false);
isSubmittingRef.current = false;
});
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAdd();
} else if (e.key === 'Escape') {
setIsAdding(false);
setNewContent('');
setNewEmotion('NONE');
}
}
return (
<div
ref={ref}
className={`
rounded-xl border-2 p-4 min-h-[200px] transition-colors
bg-card border-border
${isDraggingOver ? 'ring-2 ring-primary ring-offset-2' : ''}
`}
style={{
borderLeftColor: config.color,
borderLeftWidth: '4px',
}}
{...props}
>
{/* Header */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xl">{config.icon}</span>
<div>
<h3 className="font-semibold text-foreground">{config.title}</h3>
<p className="text-xs text-muted">{config.description}</p>
</div>
</div>
<button
onClick={() => setIsAdding(true)}
className="rounded-lg p-1.5 transition-colors hover:bg-card-hover text-muted hover:text-foreground"
aria-label={`Ajouter un item ${config.title}`}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
</div>
{/* Items */}
<div className="space-y-2">
{children}
{/* Add Form */}
{isAdding && (
<div
className="rounded-lg border border-border bg-card p-2 shadow-sm"
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 add on blur if content is not empty
if (newContent.trim()) {
handleAdd();
} else {
setIsAdding(false);
setNewContent('');
setNewEmotion('NONE');
}
}}
>
<textarea
autoFocus
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Décrivez ${config.title.toLowerCase()}...`}
className="w-full resize-none rounded border-0 bg-transparent p-1 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-0"
rows={2}
disabled={isPending}
/>
<div className="mt-2 flex items-center justify-between gap-2">
<Select
value={newEmotion}
onChange={(e) => setNewEmotion(e.target.value as typeof newEmotion)}
className="text-xs flex-1"
options={Object.values(EMOTION_BY_TYPE).map((em) => ({
value: em.emotion,
label: `${em.icon} ${em.label}`,
}))}
/>
<div className="flex gap-1">
<button
onClick={() => {
setIsAdding(false);
setNewContent('');
setNewEmotion('NONE');
}}
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
disabled={isPending}
>
Annuler
</button>
<button
onMouseDown={(e) => {
e.preventDefault(); // Prevent blur from textarea
}}
onClick={handleAdd}
disabled={isPending || !newContent.trim()}
className="rounded px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10 disabled:opacity-50"
>
{isPending ? '...' : 'Ajouter'}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
);
WeeklyCheckInSection.displayName = 'WeeklyCheckInSection';

View File

@@ -0,0 +1,172 @@
'use client';
import { useState, useTransition } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Avatar } from '@/components/ui/Avatar';
import { shareWeeklyCheckInSession, removeWeeklyCheckInShare } from '@/actions/weekly-checkin';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface WeeklyCheckInShareModalProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
sessionTitle: string;
shares: Share[];
isOwner: boolean;
}
export function WeeklyCheckInShareModal({
isOpen,
onClose,
sessionId,
sessionTitle,
shares,
isOwner,
}: WeeklyCheckInShareModalProps) {
const [email, setEmail] = useState('');
const [role, setRole] = useState<ShareRole>('EDITOR');
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
async function handleShare(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
const result = await shareWeeklyCheckInSession(sessionId, email, role);
if (result.success) {
setEmail('');
} else {
setError(result.error || 'Erreur lors du partage');
}
});
}
async function handleRemove(userId: string) {
startTransition(async () => {
await removeWeeklyCheckInShare(sessionId, userId);
});
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Partager le check-in">
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Check-in hebdomadaire</p>
<p className="font-medium text-foreground">{sessionTitle}</p>
</div>
{/* Share form (only for owner) */}
{isOwner && (
<form onSubmit={handleShare} className="space-y-4">
<div className="flex gap-2">
<Input
type="email"
placeholder="Email de l'utilisateur"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1"
required
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground"
>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={isPending || !email} className="w-full">
{isPending ? 'Partage...' : 'Partager'}
</Button>
</form>
)}
{/* Current shares */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
{shares.length === 0 ? (
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
) : (
<ul className="space-y-2">
{shares.map((share) => (
<li
key={share.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
>
<div className="flex items-center gap-3">
<Avatar email={share.user.email} name={share.user.name} size={32} />
<div>
<p className="text-sm font-medium text-foreground">
{share.user.name || share.user.email}
</p>
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
</Badge>
{isOwner && (
<button
onClick={() => handleRemove(share.user.id)}
disabled={isPending}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
title="Retirer l'accès"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
</li>
))}
</ul>
)}
</div>
{/* Help text */}
<div className="rounded-lg bg-primary/5 p-3">
<p className="text-xs text-muted">
<strong>Éditeur</strong> : peut modifier les items et leurs catégories
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</p>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,6 @@
export { WeeklyCheckInBoard } from './WeeklyCheckInBoard';
export { WeeklyCheckInCard } from './WeeklyCheckInCard';
export { WeeklyCheckInSection } from './WeeklyCheckInSection';
export { WeeklyCheckInLiveWrapper } from './WeeklyCheckInLiveWrapper';
export { WeeklyCheckInShareModal } from './WeeklyCheckInShareModal';
export { CurrentQuarterOKRs } from './CurrentQuarterOKRs';

View File

@@ -0,0 +1,31 @@
import { useLive, type LiveEvent } from './useLive';
interface UseWeatherLiveOptions {
sessionId: string;
currentUserId?: string;
enabled?: boolean;
onEvent?: (event: WeatherLiveEvent) => void;
}
interface UseWeatherLiveReturn {
isConnected: boolean;
lastEvent: WeatherLiveEvent | null;
error: string | null;
}
export type WeatherLiveEvent = LiveEvent;
export function useWeatherLive({
sessionId,
currentUserId,
enabled = true,
onEvent,
}: UseWeatherLiveOptions): UseWeatherLiveReturn {
return useLive({
sessionId,
apiPath: 'weather',
currentUserId,
enabled,
onEvent,
});
}

View File

@@ -0,0 +1,31 @@
import { useLive, type LiveEvent } from './useLive';
interface UseWeeklyCheckInLiveOptions {
sessionId: string;
currentUserId?: string;
enabled?: boolean;
onEvent?: (event: WeeklyCheckInLiveEvent) => void;
}
interface UseWeeklyCheckInLiveReturn {
isConnected: boolean;
lastEvent: WeeklyCheckInLiveEvent | null;
error: string | null;
}
export type WeeklyCheckInLiveEvent = LiveEvent;
export function useWeeklyCheckInLive({
sessionId,
currentUserId,
enabled = true,
onEvent,
}: UseWeeklyCheckInLiveOptions): UseWeeklyCheckInLiveReturn {
return useLive({
sessionId,
apiPath: 'weekly-checkin',
currentUserId,
enabled,
onEvent,
});
}

21
src/lib/date-utils.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* Get ISO week number for a given date
* ISO 8601 week numbering: week starts on Monday, first week contains Jan 4
*/
export function getISOWeek(date: Date): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
/**
* Get week number and year for a given date
* Returns format: "S06-2026"
*/
export function getWeekYearLabel(date: Date = new Date()): string {
const week = getISOWeek(date);
const year = date.getFullYear();
return `S${week.toString().padStart(2, '0')}-${year}`;
}

58
src/lib/okr-utils.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* Get the current quarter period string (e.g., "Q1 2025") from a date
*/
export function getCurrentQuarterPeriod(date: Date = new Date()): string {
const year = date.getFullYear();
const month = date.getMonth() + 1; // 1-12
const quarter = Math.ceil(month / 3);
return `Q${quarter} ${year}`;
}
/**
* Check if a period string matches the current quarter
*/
export function isCurrentQuarterPeriod(period: string, date: Date = new Date()): boolean {
return period === getCurrentQuarterPeriod(date);
}
/**
* Parse a period string to extract year and quarter
* Returns { year, quarter } or null if format is not recognized
* Supports formats like "Q1 2025", "Q2 2024", etc.
*/
function parsePeriod(period: string): { year: number; quarter: number } | null {
// Match format "Q{quarter} {year}"
const match = period.match(/^Q(\d)\s+(\d{4})$/);
if (match) {
return {
year: parseInt(match[2], 10),
quarter: parseInt(match[1], 10),
};
}
return null;
}
/**
* Compare two period strings for sorting (most recent first)
* Returns negative if a should come before b, positive if after, 0 if equal
*/
export function comparePeriods(a: string, b: string): number {
const aParsed = parsePeriod(a);
const bParsed = parsePeriod(b);
// If both can be parsed, compare by year then quarter
if (aParsed && bParsed) {
// Most recent year first
const yearDiff = bParsed.year - aParsed.year;
if (yearDiff !== 0) return yearDiff;
// Most recent quarter first (same year)
return bParsed.quarter - aParsed.quarter;
}
// Fallback: if one can be parsed, prioritize it
if (aParsed && !bParsed) return -1;
if (!aParsed && bParsed) return 1;
// Both unparseable: fallback to string comparison (descending)
return b.localeCompare(a);
}

View File

@@ -571,3 +571,219 @@ function generatePeriodSuggestions(): string[] {
} }
export const PERIOD_SUGGESTIONS = generatePeriodSuggestions(); export const PERIOD_SUGGESTIONS = generatePeriodSuggestions();
// ============================================
// Weekly Check-in - Type Definitions
// ============================================
export type WeeklyCheckInCategory =
| 'WENT_WELL' // Ce qui s'est bien passé
| 'WENT_WRONG' // Ce qui s'est mal passé
| 'CURRENT_FOCUS' // Les enjeux du moment (je me concentre sur ...)
| 'NEXT_FOCUS'; // Les prochains enjeux
export type Emotion =
| 'PRIDE' // Fierté
| 'JOY' // Joie
| 'SATISFACTION' // Satisfaction
| 'GRATITUDE' // Gratitude
| 'CONFIDENCE' // Confiance
| 'FRUSTRATION' // Frustration
| 'WORRY' // Inquiétude
| 'DISAPPOINTMENT' // Déception
| 'EXCITEMENT' // Excitement
| 'ANTICIPATION' // Anticipation
| 'DETERMINATION' // Détermination
| 'NONE'; // Aucune émotion
export interface WeeklyCheckInItem {
id: string;
content: string;
category: WeeklyCheckInCategory;
emotion: Emotion;
order: number;
sessionId: string;
createdAt: Date;
updatedAt: Date;
}
export interface WeeklyCheckInSession {
id: string;
title: string;
participant: string;
date: Date;
userId: string;
items: WeeklyCheckInItem[];
createdAt: Date;
updatedAt: Date;
}
export interface CreateWeeklyCheckInSessionInput {
title: string;
participant: string;
date?: Date;
}
export interface UpdateWeeklyCheckInSessionInput {
title?: string;
participant?: string;
date?: Date;
}
export interface CreateWeeklyCheckInItemInput {
content: string;
category: WeeklyCheckInCategory;
emotion?: Emotion;
order?: number;
}
export interface UpdateWeeklyCheckInItemInput {
content?: string;
category?: WeeklyCheckInCategory;
emotion?: Emotion;
order?: number;
}
// ============================================
// Weekly Check-in - UI Config
// ============================================
export interface WeeklyCheckInSectionConfig {
category: WeeklyCheckInCategory;
title: string;
icon: string;
description: string;
color: string;
}
export const WEEKLY_CHECK_IN_SECTIONS: WeeklyCheckInSectionConfig[] = [
{
category: 'WENT_WELL',
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é',
icon: '⚠️',
description: 'Les difficultés et points d\'amélioration',
color: '#ef4444', // red
},
{
category: 'CURRENT_FOCUS',
title: 'Enjeux du moment',
icon: '🎯',
description: 'Sur quoi je me concentre actuellement',
color: '#3b82f6', // blue
},
{
category: 'NEXT_FOCUS',
title: 'Prochains enjeux',
icon: '🚀',
description: 'Ce sur quoi je vais me concentrer prochainement',
color: '#8b5cf6', // purple
},
];
export const WEEKLY_CHECK_IN_BY_CATEGORY: Record<
WeeklyCheckInCategory,
WeeklyCheckInSectionConfig
> = WEEKLY_CHECK_IN_SECTIONS.reduce(
(acc, config) => {
acc[config.category] = config;
return acc;
},
{} as Record<WeeklyCheckInCategory, WeeklyCheckInSectionConfig>
);
export interface EmotionConfig {
emotion: Emotion;
label: string;
icon: string;
color: string;
}
export const EMOTIONS_CONFIG: EmotionConfig[] = [
{
emotion: 'PRIDE',
label: 'Fierté',
icon: '🦁',
color: '#f59e0b', // amber
},
{
emotion: 'JOY',
label: 'Joie',
icon: '😊',
color: '#eab308', // yellow
},
{
emotion: 'SATISFACTION',
label: 'Satisfaction',
icon: '😌',
color: '#22c55e', // green
},
{
emotion: 'GRATITUDE',
label: 'Gratitude',
icon: '🙏',
color: '#14b8a6', // teal
},
{
emotion: 'CONFIDENCE',
label: 'Confiance',
icon: '💪',
color: '#3b82f6', // blue
},
{
emotion: 'FRUSTRATION',
label: 'Frustration',
icon: '😤',
color: '#f97316', // orange
},
{
emotion: 'WORRY',
label: 'Inquiétude',
icon: '😟',
color: '#eab308', // yellow
},
{
emotion: 'DISAPPOINTMENT',
label: 'Déception',
icon: '😞',
color: '#ef4444', // red
},
{
emotion: 'EXCITEMENT',
label: 'Excitement',
icon: '🤩',
color: '#ec4899', // pink
},
{
emotion: 'ANTICIPATION',
label: 'Anticipation',
icon: '⏳',
color: '#8b5cf6', // purple
},
{
emotion: 'DETERMINATION',
label: 'Détermination',
icon: '🔥',
color: '#dc2626', // red
},
{
emotion: 'NONE',
label: 'Aucune',
icon: '—',
color: '#6b7280', // gray
},
];
export const EMOTION_BY_TYPE: Record<Emotion, EmotionConfig> = EMOTIONS_CONFIG.reduce(
(acc, config) => {
acc[config.emotion] = config;
return acc;
},
{} as Record<Emotion, EmotionConfig>
);

View File

@@ -1,5 +1,5 @@
import { prisma } from '@/services/database'; import { prisma } from '@/services/database';
import type { CreateOKRInput, UpdateOKRInput, UpdateKeyResultInput, OKRStatus, KeyResultStatus } from '@/lib/types'; import type { UpdateOKRInput, OKRStatus, KeyResultStatus } from '@/lib/types';
export async function createOKR( export async function createOKR(
teamMemberId: string, teamMemberId: string,
@@ -183,8 +183,63 @@ export async function getUserOKRs(userId: string) {
); );
} }
export async function updateOKR(okrId: string, data: UpdateOKRInput) { export async function getUserOKRsForPeriod(userId: string, period: string) {
const okr = await prisma.oKR.update({ // Get all team members for this user
const teamMembers = await prisma.teamMember.findMany({
where: { userId },
include: {
team: {
select: {
id: true,
name: true,
},
},
okrs: {
where: {
period,
},
include: {
keyResults: {
orderBy: {
order: 'asc',
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
});
// Flatten and return OKRs with team info
return teamMembers.flatMap((tm) =>
tm.okrs.map((okr) => ({
...okr,
progress: calculateOKRProgressFromKeyResults(okr.keyResults),
team: tm.team,
}))
);
}
export async function updateOKR(
okrId: string,
data: UpdateOKRInput,
keyResultsUpdates?: {
create?: Array<{ title: string; targetValue: number; unit: string; order: number }>;
update?: Array<{
id: string;
title?: string;
targetValue?: number;
unit?: string;
order?: number;
}>;
delete?: string[];
}
) {
return prisma.$transaction(async (tx) => {
// Update OKR
await tx.oKR.update({
where: { id: okrId }, where: { id: okrId },
data: { data: {
...(data.objective !== undefined && { objective: data.objective }), ...(data.objective !== undefined && { objective: data.objective }),
@@ -194,6 +249,60 @@ export async function updateOKR(okrId: string, data: UpdateOKRInput) {
...(data.endDate !== undefined && { endDate: data.endDate }), ...(data.endDate !== undefined && { endDate: data.endDate }),
...(data.status !== undefined && { status: data.status }), ...(data.status !== undefined && { status: data.status }),
}, },
});
// Handle Key Results updates if provided
if (keyResultsUpdates) {
// Delete Key Results
if (keyResultsUpdates.delete && keyResultsUpdates.delete.length > 0) {
await tx.keyResult.deleteMany({
where: {
id: { in: keyResultsUpdates.delete },
okrId,
},
});
}
// Create new Key Results
if (keyResultsUpdates.create && keyResultsUpdates.create.length > 0) {
await Promise.all(
keyResultsUpdates.create.map((kr) =>
tx.keyResult.create({
data: {
okrId,
title: kr.title,
targetValue: kr.targetValue,
currentValue: 0,
unit: kr.unit || '%',
status: 'NOT_STARTED',
order: kr.order,
},
})
)
);
}
// Update existing Key Results
if (keyResultsUpdates.update && keyResultsUpdates.update.length > 0) {
await Promise.all(
keyResultsUpdates.update.map((kr) =>
tx.keyResult.update({
where: { id: kr.id },
data: {
...(kr.title !== undefined && { title: kr.title }),
...(kr.targetValue !== undefined && { targetValue: kr.targetValue }),
...(kr.unit !== undefined && { unit: kr.unit }),
...(kr.order !== undefined && { order: kr.order }),
},
})
)
);
}
}
// Fetch updated OKR with Key Results
const updatedOkr = await tx.oKR.findUnique({
where: { id: okrId },
include: { include: {
keyResults: { keyResults: {
orderBy: { orderBy: {
@@ -203,10 +312,15 @@ export async function updateOKR(okrId: string, data: UpdateOKRInput) {
}, },
}); });
if (!updatedOkr) {
throw new Error('OKR not found after update');
}
return { return {
...okr, ...updatedOkr,
progress: calculateOKRProgressFromKeyResults(okr.keyResults), progress: calculateOKRProgressFromKeyResults(updatedOkr.keyResults),
}; };
});
} }
export async function deleteOKR(okrId: string) { export async function deleteOKR(okrId: string) {
@@ -216,6 +330,47 @@ export async function deleteOKR(okrId: string) {
}); });
} }
export async function createKeyResult(
okrId: string,
title: string,
targetValue: number,
unit: string,
order: number
) {
return prisma.keyResult.create({
data: {
okrId,
title,
targetValue,
currentValue: 0,
unit: unit || '%',
status: 'NOT_STARTED',
order,
},
});
}
export async function updateKeyResultMetadata(
krId: string,
data: { title?: string; targetValue?: number; unit?: string; order?: number }
) {
return prisma.keyResult.update({
where: { id: krId },
data: {
...(data.title !== undefined && { title: data.title }),
...(data.targetValue !== undefined && { targetValue: data.targetValue }),
...(data.unit !== undefined && { unit: data.unit }),
...(data.order !== undefined && { order: data.order }),
},
});
}
export async function deleteKeyResult(krId: string) {
return prisma.keyResult.delete({
where: { id: krId },
});
}
export async function updateKeyResult(krId: string, currentValue: number, notes: string | null) { export async function updateKeyResult(krId: string, currentValue: number, notes: string | null) {
// Auto-update status based on progress // Auto-update status based on progress
const kr = await prisma.keyResult.findUnique({ const kr = await prisma.keyResult.findUnique({
@@ -293,7 +448,9 @@ export function calculateOKRProgress(okrId: string): Promise<number> {
}); });
} }
function calculateOKRProgressFromKeyResults(keyResults: Array<{ currentValue: number; targetValue: number }>): number { function calculateOKRProgressFromKeyResults(
keyResults: Array<{ currentValue: number; targetValue: number }>
): number {
if (keyResults.length === 0) { if (keyResults.length === 0) {
return 0; return 0;
} }
@@ -305,4 +462,3 @@ function calculateOKRProgressFromKeyResults(keyResults: Array<{ currentValue: nu
return Math.round(totalProgress / keyResults.length); return Math.round(totalProgress / keyResults.length);
} }

View File

@@ -1,5 +1,5 @@
import { prisma } from '@/services/database'; import { prisma } from '@/services/database';
import type { CreateTeamInput, UpdateTeamInput, AddTeamMemberInput, UpdateMemberRoleInput, TeamRole } from '@/lib/types'; import type { UpdateTeamInput, TeamRole } from '@/lib/types';
export async function createTeam(name: string, description: string | null, createdById: string) { export async function createTeam(name: string, description: string | null, createdById: string) {
// Create team and add creator as admin in a transaction // Create team and add creator as admin in a transaction

388
src/services/weather.ts Normal file
View File

@@ -0,0 +1,388 @@
import { prisma } from '@/services/database';
import type { ShareRole } from '@prisma/client';
// ============================================
// Weather Session CRUD
// ============================================
export async function getWeatherSessionsByUserId(userId: string) {
// Get owned sessions + shared sessions
const [owned, shared] = await Promise.all([
prisma.weatherSession.findMany({
where: { userId },
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
entries: true,
},
},
},
orderBy: { updatedAt: 'desc' },
}),
prisma.weatherSessionShare.findMany({
where: { userId },
include: {
session: {
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
entries: true,
},
},
},
},
},
}),
]);
// Mark owned sessions and merge with shared
const ownedWithRole = owned.map((s) => ({
...s,
isOwner: true as const,
role: 'OWNER' as const,
}));
const sharedWithRole = shared.map((s) => ({
...s.session,
isOwner: false as const,
role: s.role,
sharedAt: s.createdAt,
}));
const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
return allSessions;
}
export async function getWeatherSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared
const session = await prisma.weatherSession.findFirst({
where: {
id: sessionId,
OR: [
{ userId }, // Owner
{ shares: { some: { userId } } }, // Shared with user
],
},
include: {
user: { select: { id: true, name: true, email: true } },
entries: {
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'asc' },
},
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
});
if (!session) return null;
// Determine user's role
const isOwner = session.userId === userId;
const share = session.shares.find((s) => s.userId === userId);
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
const canEdit = isOwner || role === 'EDITOR';
return { ...session, isOwner, role, canEdit };
}
// Check if user can access session (owner or shared)
export async function canAccessWeatherSession(sessionId: string, userId: string) {
const count = await prisma.weatherSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId } } }],
},
});
return count > 0;
}
// Check if user can edit session (owner or EDITOR role)
export async function canEditWeatherSession(sessionId: string, userId: string) {
const count = await prisma.weatherSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }],
},
});
return count > 0;
}
export async function createWeatherSession(userId: string, data: { title: string; date?: Date }) {
return prisma.weatherSession.create({
data: {
...data,
date: data.date || new Date(),
userId,
},
include: {
entries: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
});
}
export async function updateWeatherSession(
sessionId: string,
userId: string,
data: { title?: string; date?: Date }
) {
return prisma.weatherSession.updateMany({
where: { id: sessionId, userId },
data,
});
}
export async function deleteWeatherSession(sessionId: string, userId: string) {
return prisma.weatherSession.deleteMany({
where: { id: sessionId, userId },
});
}
// ============================================
// Weather Entry CRUD
// ============================================
export async function getWeatherEntry(sessionId: string, userId: string) {
return prisma.weatherEntry.findUnique({
where: {
sessionId_userId: { sessionId, userId },
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
export async function createOrUpdateWeatherEntry(
sessionId: string,
userId: string,
data: {
performanceEmoji?: string | null;
moralEmoji?: string | null;
fluxEmoji?: string | null;
valueCreationEmoji?: string | null;
notes?: string | null;
}
) {
return prisma.weatherEntry.upsert({
where: {
sessionId_userId: { sessionId, userId },
},
update: data,
create: {
sessionId,
userId,
...data,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
export async function deleteWeatherEntry(sessionId: string, userId: string) {
return prisma.weatherEntry.deleteMany({
where: { sessionId, userId },
});
}
// ============================================
// Session Sharing
// ============================================
export async function shareWeatherSession(
sessionId: string,
ownerId: string,
targetEmail: string,
role: ShareRole = 'EDITOR'
) {
// Verify owner
const session = await prisma.weatherSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
// Find target user
const targetUser = await prisma.user.findUnique({
where: { email: targetEmail },
});
if (!targetUser) {
throw new Error('User not found');
}
// Can't share with yourself
if (targetUser.id === ownerId) {
throw new Error('Cannot share session with yourself');
}
// Create or update share
return prisma.weatherSessionShare.upsert({
where: {
sessionId_userId: { sessionId, userId: targetUser.id },
},
update: { role },
create: {
sessionId,
userId: targetUser.id,
role,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
export async function shareWeatherSessionToTeam(
sessionId: string,
ownerId: string,
teamId: string,
role: ShareRole = 'EDITOR'
) {
// Verify owner
const session = await prisma.weatherSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
// Get team members
const teamMembers = await prisma.teamMember.findMany({
where: { teamId },
include: {
user: { select: { id: true, name: true, email: true } },
},
});
if (teamMembers.length === 0) {
throw new Error('Team has no members');
}
// Share with all team members (except owner)
const shares = await Promise.all(
teamMembers
.filter((tm) => tm.userId !== ownerId) // Don't share with yourself
.map((tm) =>
prisma.weatherSessionShare.upsert({
where: {
sessionId_userId: { sessionId, userId: tm.userId },
},
update: { role },
create: {
sessionId,
userId: tm.userId,
role,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
})
)
);
return shares;
}
export async function removeWeatherShare(
sessionId: string,
ownerId: string,
shareUserId: string
) {
// Verify owner
const session = await prisma.weatherSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
return prisma.weatherSessionShare.deleteMany({
where: { sessionId, userId: shareUserId },
});
}
export async function getWeatherSessionShares(sessionId: string, userId: string) {
// Verify access
if (!(await canAccessWeatherSession(sessionId, userId))) {
throw new Error('Access denied');
}
return prisma.weatherSessionShare.findMany({
where: { sessionId },
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
// ============================================
// Session Events (for real-time sync)
// ============================================
export type WeatherSessionEventType =
| 'ENTRY_CREATED'
| 'ENTRY_UPDATED'
| 'ENTRY_DELETED'
| 'SESSION_UPDATED';
export async function createWeatherSessionEvent(
sessionId: string,
userId: string,
type: WeatherSessionEventType,
payload: Record<string, unknown>
) {
return prisma.weatherSessionEvent.create({
data: {
sessionId,
userId,
type,
payload: JSON.stringify(payload),
},
});
}
export async function getWeatherSessionEvents(sessionId: string, since?: Date) {
return prisma.weatherSessionEvent.findMany({
where: {
sessionId,
...(since && { createdAt: { gt: since } }),
},
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'asc' },
});
}
export async function getLatestWeatherEventTimestamp(sessionId: string) {
const event = await prisma.weatherSessionEvent.findFirst({
where: { sessionId },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
});
return event?.createdAt;
}

View File

@@ -0,0 +1,371 @@
import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth';
import type { ShareRole, WeeklyCheckInCategory, Emotion } from '@prisma/client';
// ============================================
// Weekly Check-in Session CRUD
// ============================================
export async function getWeeklyCheckInSessionsByUserId(userId: string) {
// Get owned sessions + shared sessions
const [owned, shared] = await Promise.all([
prisma.weeklyCheckInSession.findMany({
where: { userId },
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
items: true,
},
},
},
orderBy: { updatedAt: 'desc' },
}),
prisma.wCISessionShare.findMany({
where: { userId },
include: {
session: {
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
items: true,
},
},
},
},
},
}),
]);
// Mark owned sessions and merge with shared
const ownedWithRole = owned.map((s) => ({
...s,
isOwner: true as const,
role: 'OWNER' as const,
}));
const sharedWithRole = shared.map((s) => ({
...s.session,
isOwner: false as const,
role: s.role,
sharedAt: s.createdAt,
}));
const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
// Resolve participants to users
const sessionsWithResolved = await Promise.all(
allSessions.map(async (s) => ({
...s,
resolvedParticipant: await resolveCollaborator(s.participant),
}))
);
return sessionsWithResolved;
}
export async function getWeeklyCheckInSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared
const session = await prisma.weeklyCheckInSession.findFirst({
where: {
id: sessionId,
OR: [
{ userId }, // Owner
{ shares: { some: { userId } } }, // Shared with user
],
},
include: {
user: { select: { id: true, name: true, email: true } },
items: {
orderBy: [{ category: 'asc' }, { order: 'asc' }],
},
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
});
if (!session) return null;
// Determine user's role
const isOwner = session.userId === userId;
const share = session.shares.find((s) => s.userId === userId);
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
const canEdit = isOwner || role === 'EDITOR';
// Resolve participant to user if it's an email
const resolvedParticipant = await resolveCollaborator(session.participant);
return { ...session, isOwner, role, canEdit, resolvedParticipant };
}
// Check if user can access session (owner or shared)
export async function canAccessWeeklyCheckInSession(sessionId: string, userId: string) {
const count = await prisma.weeklyCheckInSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId } } }],
},
});
return count > 0;
}
// Check if user can edit session (owner or EDITOR role)
export async function canEditWeeklyCheckInSession(sessionId: string, userId: string) {
const count = await prisma.weeklyCheckInSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }],
},
});
return count > 0;
}
export async function createWeeklyCheckInSession(
userId: string,
data: { title: string; participant: string; date?: Date }
) {
return prisma.weeklyCheckInSession.create({
data: {
...data,
date: data.date || new Date(),
userId,
},
include: {
items: {
orderBy: [{ category: 'asc' }, { order: 'asc' }],
},
},
});
}
export async function updateWeeklyCheckInSession(
sessionId: string,
userId: string,
data: { title?: string; participant?: string; date?: Date }
) {
return prisma.weeklyCheckInSession.updateMany({
where: { id: sessionId, userId },
data,
});
}
export async function deleteWeeklyCheckInSession(sessionId: string, userId: string) {
return prisma.weeklyCheckInSession.deleteMany({
where: { id: sessionId, userId },
});
}
// ============================================
// Weekly Check-in Items CRUD
// ============================================
export async function createWeeklyCheckInItem(
sessionId: string,
data: { content: string; category: WeeklyCheckInCategory; emotion?: Emotion }
) {
// Get max order for this category in this session
const maxOrder = await prisma.weeklyCheckInItem.findFirst({
where: { sessionId, category: data.category },
orderBy: { order: 'desc' },
select: { order: true },
});
return prisma.weeklyCheckInItem.create({
data: {
...data,
emotion: data.emotion || 'NONE',
sessionId,
order: (maxOrder?.order ?? -1) + 1,
},
});
}
export async function updateWeeklyCheckInItem(
itemId: string,
data: { content?: string; category?: WeeklyCheckInCategory; emotion?: Emotion; order?: number }
) {
return prisma.weeklyCheckInItem.update({
where: { id: itemId },
data,
});
}
export async function deleteWeeklyCheckInItem(itemId: string) {
return prisma.weeklyCheckInItem.delete({
where: { id: itemId },
});
}
export async function moveWeeklyCheckInItem(
itemId: string,
newCategory: WeeklyCheckInCategory,
newOrder: number
) {
return prisma.weeklyCheckInItem.update({
where: { id: itemId },
data: {
category: newCategory,
order: newOrder,
},
});
}
export async function reorderWeeklyCheckInItems(
sessionId: string,
category: WeeklyCheckInCategory,
itemIds: string[]
) {
const updates = itemIds.map((id, index) =>
prisma.weeklyCheckInItem.update({
where: { id },
data: { order: index },
})
);
return prisma.$transaction(updates);
}
// ============================================
// Session Sharing
// ============================================
export async function shareWeeklyCheckInSession(
sessionId: string,
ownerId: string,
targetEmail: string,
role: ShareRole = 'EDITOR'
) {
// Verify owner
const session = await prisma.weeklyCheckInSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
// Find target user
const targetUser = await prisma.user.findUnique({
where: { email: targetEmail },
});
if (!targetUser) {
throw new Error('User not found');
}
// Can't share with yourself
if (targetUser.id === ownerId) {
throw new Error('Cannot share session with yourself');
}
// Create or update share
return prisma.wCISessionShare.upsert({
where: {
sessionId_userId: { sessionId, userId: targetUser.id },
},
update: { role },
create: {
sessionId,
userId: targetUser.id,
role,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
export async function removeWeeklyCheckInShare(
sessionId: string,
ownerId: string,
shareUserId: string
) {
// Verify owner
const session = await prisma.weeklyCheckInSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
return prisma.wCISessionShare.deleteMany({
where: { sessionId, userId: shareUserId },
});
}
export async function getWeeklyCheckInSessionShares(sessionId: string, userId: string) {
// Verify access
if (!(await canAccessWeeklyCheckInSession(sessionId, userId))) {
throw new Error('Access denied');
}
return prisma.wCISessionShare.findMany({
where: { sessionId },
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
// ============================================
// Session Events (for real-time sync)
// ============================================
export type WCISessionEventType =
| 'ITEM_CREATED'
| 'ITEM_UPDATED'
| 'ITEM_DELETED'
| 'ITEM_MOVED'
| 'ITEMS_REORDERED'
| 'SESSION_UPDATED';
export async function createWeeklyCheckInSessionEvent(
sessionId: string,
userId: string,
type: WCISessionEventType,
payload: Record<string, unknown>
) {
return prisma.wCISessionEvent.create({
data: {
sessionId,
userId,
type,
payload: JSON.stringify(payload),
},
});
}
export async function getWeeklyCheckInSessionEvents(sessionId: string, since?: Date) {
return prisma.wCISessionEvent.findMany({
where: {
sessionId,
...(since && { createdAt: { gt: since } }),
},
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'asc' },
});
}
export async function getLatestWeeklyCheckInEventTimestamp(sessionId: string) {
const event = await prisma.wCISessionEvent.findFirst({
where: { sessionId },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
});
return event?.createdAt;
}