Compare commits
12 Commits
fd65e0d5b9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
163caa398c | ||
|
|
3a2eb83197 | ||
|
|
e848e85b63 | ||
|
|
53ee344ae7 | ||
|
|
67d685d346 | ||
|
|
47703db348 | ||
|
|
86c26b5af8 | ||
|
|
97045342b7 | ||
|
|
ca9b68ebbd | ||
|
|
5f661c8bfd | ||
|
|
e3a47dd7e5 | ||
|
|
35b9ac8a66 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -43,4 +43,5 @@ next-env.d.ts
|
||||
/src/generated/prisma
|
||||
|
||||
# data
|
||||
data/
|
||||
data/
|
||||
*.db
|
||||
BIN
data/dev.db
BIN
data/dev.db
Binary file not shown.
BIN
data/prod.db
BIN
data/prod.db
Binary file not shown.
@@ -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");
|
||||
@@ -0,0 +1,98 @@
|
||||
-- CreateEnum
|
||||
CREATE TABLE "TeamRole" (
|
||||
"value" TEXT NOT NULL PRIMARY KEY
|
||||
);
|
||||
INSERT INTO "TeamRole" ("value") VALUES ('ADMIN'), ('MEMBER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TABLE "OKRStatus" (
|
||||
"value" TEXT NOT NULL PRIMARY KEY
|
||||
);
|
||||
INSERT INTO "OKRStatus" ("value") VALUES ('NOT_STARTED'), ('IN_PROGRESS'), ('COMPLETED'), ('CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TABLE "KeyResultStatus" (
|
||||
"value" TEXT NOT NULL PRIMARY KEY
|
||||
);
|
||||
INSERT INTO "KeyResultStatus" ("value") VALUES ('NOT_STARTED'), ('IN_PROGRESS'), ('COMPLETED'), ('AT_RISK');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Team" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"createdById" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Team_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamMember" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"teamId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'MEMBER',
|
||||
"joinedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OKR" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"teamMemberId" TEXT NOT NULL,
|
||||
"objective" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"period" TEXT NOT NULL,
|
||||
"startDate" DATETIME NOT NULL,
|
||||
"endDate" DATETIME NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'NOT_STARTED',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "OKR_teamMemberId_fkey" FOREIGN KEY ("teamMemberId") REFERENCES "TeamMember" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "KeyResult" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"okrId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"targetValue" REAL NOT NULL,
|
||||
"currentValue" REAL NOT NULL DEFAULT 0,
|
||||
"unit" TEXT NOT NULL DEFAULT '%',
|
||||
"status" TEXT NOT NULL DEFAULT 'NOT_STARTED',
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"notes" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "KeyResult_okrId_fkey" FOREIGN KEY ("okrId") REFERENCES "OKR" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Team_createdById_idx" ON "Team"("createdById");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamMember_teamId_idx" ON "TeamMember"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamMember_userId_idx" ON "TeamMember"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TeamMember_teamId_userId_key" ON "TeamMember"("teamId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OKR_teamMemberId_idx" ON "OKR"("teamMemberId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OKR_teamMemberId_period_idx" ON "OKR"("teamMemberId", "period");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OKR_status_idx" ON "OKR"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KeyResult_okrId_idx" ON "KeyResult"("okrId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KeyResult_okrId_order_idx" ON "KeyResult"("okrId", "order");
|
||||
|
||||
@@ -25,6 +25,18 @@ model User {
|
||||
yearReviewSessions YearReviewSession[]
|
||||
sharedYearReviewSessions YRSessionShare[]
|
||||
yearReviewSessionEvents YRSessionEvent[]
|
||||
// Weekly Check-in relations
|
||||
weeklyCheckInSessions WeeklyCheckInSession[]
|
||||
sharedWeeklyCheckInSessions WCISessionShare[]
|
||||
weeklyCheckInSessionEvents WCISessionEvent[]
|
||||
// Weather Workshop relations
|
||||
weatherSessions WeatherSession[]
|
||||
sharedWeatherSessions WeatherSessionShare[]
|
||||
weatherSessionEvents WeatherSessionEvent[]
|
||||
weatherEntries WeatherEntry[]
|
||||
// Teams & OKRs relations
|
||||
createdTeams Team[]
|
||||
teamMembers TeamMember[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
@@ -274,3 +286,242 @@ model YRSessionEvent {
|
||||
|
||||
@@index([sessionId, createdAt])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Teams & OKRs
|
||||
// ============================================
|
||||
|
||||
enum TeamRole {
|
||||
ADMIN
|
||||
MEMBER
|
||||
}
|
||||
|
||||
enum OKRStatus {
|
||||
NOT_STARTED
|
||||
IN_PROGRESS
|
||||
COMPLETED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum KeyResultStatus {
|
||||
NOT_STARTED
|
||||
IN_PROGRESS
|
||||
COMPLETED
|
||||
AT_RISK
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
createdById String
|
||||
creator User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||
members TeamMember[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([createdById])
|
||||
}
|
||||
|
||||
model TeamMember {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role TeamRole @default(MEMBER)
|
||||
okrs OKR[]
|
||||
joinedAt DateTime @default(now())
|
||||
|
||||
@@unique([teamId, userId])
|
||||
@@index([teamId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model OKR {
|
||||
id String @id @default(cuid())
|
||||
teamMemberId String
|
||||
teamMember TeamMember @relation(fields: [teamMemberId], references: [id], onDelete: Cascade)
|
||||
objective String
|
||||
description String?
|
||||
period String // Q1 2025, Q2 2025, H1 2025, 2025, etc.
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
status OKRStatus @default(NOT_STARTED)
|
||||
keyResults KeyResult[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([teamMemberId])
|
||||
@@index([teamMemberId, period])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model KeyResult {
|
||||
id String @id @default(cuid())
|
||||
okrId String
|
||||
okr OKR @relation(fields: [okrId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
targetValue Float
|
||||
currentValue Float @default(0)
|
||||
unit String @default("%") // %, nombre, etc.
|
||||
status KeyResultStatus @default(NOT_STARTED)
|
||||
order Int @default(0)
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([okrId])
|
||||
@@index([okrId, order])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Weekly Check-in Workshop
|
||||
// ============================================
|
||||
|
||||
enum WeeklyCheckInCategory {
|
||||
WENT_WELL // Ce qui s'est bien passé
|
||||
WENT_WRONG // Ce qui s'est mal passé
|
||||
CURRENT_FOCUS // Les enjeux du moment (je me concentre sur ...)
|
||||
NEXT_FOCUS // Les prochains enjeux
|
||||
}
|
||||
|
||||
enum Emotion {
|
||||
PRIDE // Fierté
|
||||
JOY // Joie
|
||||
SATISFACTION // Satisfaction
|
||||
GRATITUDE // Gratitude
|
||||
CONFIDENCE // Confiance
|
||||
FRUSTRATION // Frustration
|
||||
WORRY // Inquiétude
|
||||
DISAPPOINTMENT // Déception
|
||||
EXCITEMENT // Excitement
|
||||
ANTICIPATION // Anticipation
|
||||
DETERMINATION // Détermination
|
||||
NONE // Aucune émotion
|
||||
}
|
||||
|
||||
model WeeklyCheckInSession {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
participant String // Nom du participant
|
||||
date DateTime @default(now())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
items WeeklyCheckInItem[]
|
||||
shares WCISessionShare[]
|
||||
events WCISessionEvent[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([date])
|
||||
}
|
||||
|
||||
model WeeklyCheckInItem {
|
||||
id String @id @default(cuid())
|
||||
content String
|
||||
category WeeklyCheckInCategory
|
||||
emotion Emotion @default(NONE)
|
||||
order Int @default(0)
|
||||
sessionId String
|
||||
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([sessionId])
|
||||
@@index([sessionId, category])
|
||||
}
|
||||
|
||||
model WCISessionShare {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role ShareRole @default(EDITOR)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([sessionId, userId])
|
||||
@@index([sessionId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model WCISessionEvent {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
type String // ITEM_CREATED, ITEM_UPDATED, ITEM_DELETED, etc.
|
||||
payload String // JSON payload
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([sessionId, createdAt])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Weather Workshop
|
||||
// ============================================
|
||||
|
||||
model WeatherSession {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
date DateTime @default(now())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
entries WeatherEntry[]
|
||||
shares WeatherSessionShare[]
|
||||
events WeatherSessionEvent[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([date])
|
||||
}
|
||||
|
||||
model WeatherEntry {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
session WeatherSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
performanceEmoji String? // Emoji météo pour Performance
|
||||
moralEmoji String? // Emoji météo pour Moral
|
||||
fluxEmoji String? // Emoji météo pour Flux
|
||||
valueCreationEmoji String? // Emoji météo pour Création de valeur
|
||||
notes String? // Notes globales
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([sessionId, userId]) // Un seul entry par membre par session
|
||||
@@index([sessionId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model WeatherSessionShare {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
session WeatherSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role ShareRole @default(EDITOR)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([sessionId, userId])
|
||||
@@index([sessionId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model WeatherSessionEvent {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
session WeatherSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
type String // ENTRY_CREATED, ENTRY_UPDATED, ENTRY_DELETED, SESSION_UPDATED, etc.
|
||||
payload String // JSON payload
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([sessionId, createdAt])
|
||||
}
|
||||
|
||||
225
src/actions/weather.ts
Normal file
225
src/actions/weather.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { auth } from '@/lib/auth';
|
||||
import * as weatherService from '@/services/weather';
|
||||
|
||||
// ============================================
|
||||
// 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);
|
||||
|
||||
// Emit event for real-time sync
|
||||
await weatherService.createWeatherSessionEvent(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
'SESSION_UPDATED',
|
||||
data
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
// Emit event for real-time sync
|
||||
const eventType = entry.createdAt.getTime() === entry.updatedAt.getTime() ? 'ENTRY_CREATED' : 'ENTRY_UPDATED';
|
||||
await weatherService.createWeatherSessionEvent(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
eventType,
|
||||
{
|
||||
entryId: entry.id,
|
||||
userId: entry.userId,
|
||||
...data,
|
||||
}
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
// Emit event for real-time sync
|
||||
await weatherService.createWeatherSessionEvent(
|
||||
sessionId,
|
||||
authSession.user.id,
|
||||
'ENTRY_DELETED',
|
||||
{ userId: authSession.user.id }
|
||||
);
|
||||
|
||||
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' };
|
||||
}
|
||||
}
|
||||
333
src/actions/weekly-checkin.ts
Normal file
333
src/actions/weekly-checkin.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
60
src/app/api/okrs/[id]/key-results/[krId]/route.ts
Normal file
60
src/app/api/okrs/[id]/key-results/[krId]/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { updateKeyResult } from '@/services/okrs';
|
||||
import { getOKR } from '@/services/okrs';
|
||||
import { isTeamMember, isTeamAdmin } from '@/services/teams';
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; krId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id, krId } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get OKR to check permissions
|
||||
const okr = await getOKR(id);
|
||||
if (!okr) {
|
||||
return NextResponse.json({ error: 'OKR non trouvé' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user is a member of the team
|
||||
const isMember = await isTeamMember(okr.teamMember.team.id, session.user.id);
|
||||
if (!isMember) {
|
||||
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if user is admin or the concerned member
|
||||
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
|
||||
const isConcernedMember = okr.teamMember.userId === session.user.id;
|
||||
|
||||
if (!isAdmin && !isConcernedMember) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Seuls les administrateurs et le membre concerné peuvent mettre à jour les Key Results' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { currentValue, notes } = body;
|
||||
|
||||
if (currentValue === undefined) {
|
||||
return NextResponse.json({ error: 'Valeur actuelle requise' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await updateKeyResult(krId, Number(currentValue), notes || null);
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Error updating key result:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de la mise à jour du Key Result';
|
||||
return NextResponse.json(
|
||||
{ error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
144
src/app/api/okrs/[id]/route.ts
Normal file
144
src/app/api/okrs/[id]/route.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getOKR, updateOKR, deleteOKR } from '@/services/okrs';
|
||||
import { isTeamMember, isTeamAdmin } from '@/services/teams';
|
||||
import type { UpdateOKRInput } from '@/lib/types';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
const okr = await getOKR(id);
|
||||
|
||||
if (!okr) {
|
||||
return NextResponse.json({ error: 'OKR non trouvé' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user is a member of the team
|
||||
const isMember = await isTeamMember(okr.teamMember.team.id, session.user.id);
|
||||
if (!isMember) {
|
||||
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
|
||||
const isConcernedMember = okr.teamMember.userId === session.user.id;
|
||||
|
||||
return NextResponse.json({
|
||||
...okr,
|
||||
permissions: {
|
||||
isAdmin,
|
||||
isConcernedMember,
|
||||
canEdit: isAdmin || isConcernedMember,
|
||||
canDelete: isAdmin,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching OKR:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erreur lors de la récupération de l\'OKR' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
const okr = await getOKR(id);
|
||||
if (!okr) {
|
||||
return NextResponse.json({ error: 'OKR non trouvé' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user is admin of the team or the concerned member
|
||||
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
|
||||
const isConcernedMember = okr.teamMember.userId === session.user.id;
|
||||
if (!isAdmin && !isConcernedMember) {
|
||||
return NextResponse.json({ error: 'Seuls les administrateurs et le membre concerné peuvent modifier les OKRs' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body: UpdateOKRInput & {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
keyResultsUpdates?: {
|
||||
create?: Array<{ title: string; targetValue: number; unit: string; order: number }>;
|
||||
update?: Array<{ id: string; title?: string; targetValue?: number; unit?: string; order?: number }>;
|
||||
delete?: string[];
|
||||
};
|
||||
} = await request.json();
|
||||
|
||||
// Convert date strings to Date objects if provided
|
||||
const updateData: UpdateOKRInput = { ...body };
|
||||
if (body.startDate) {
|
||||
updateData.startDate = new Date(body.startDate);
|
||||
}
|
||||
if (body.endDate) {
|
||||
updateData.endDate = new Date(body.endDate);
|
||||
}
|
||||
|
||||
// Remove keyResultsUpdates from updateData as it's not part of UpdateOKRInput
|
||||
const { keyResultsUpdates, ...okrUpdateData } = body;
|
||||
const finalUpdateData: UpdateOKRInput = { ...okrUpdateData };
|
||||
if (finalUpdateData.startDate && typeof finalUpdateData.startDate === 'string') {
|
||||
finalUpdateData.startDate = new Date(finalUpdateData.startDate);
|
||||
}
|
||||
if (finalUpdateData.endDate && typeof finalUpdateData.endDate === 'string') {
|
||||
finalUpdateData.endDate = new Date(finalUpdateData.endDate);
|
||||
}
|
||||
|
||||
const updated = await updateOKR(id, finalUpdateData, keyResultsUpdates);
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Error updating OKR:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de la mise à jour de l\'OKR';
|
||||
return NextResponse.json(
|
||||
{ error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
const okr = await getOKR(id);
|
||||
if (!okr) {
|
||||
return NextResponse.json({ error: 'OKR non trouvé' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user is admin of the team
|
||||
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json({ error: 'Seuls les administrateurs peuvent supprimer les OKRs' }, { status: 403 });
|
||||
}
|
||||
|
||||
await deleteOKR(id);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting OKR:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de la suppression de l\'OKR';
|
||||
return NextResponse.json(
|
||||
{ error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
74
src/app/api/okrs/route.ts
Normal file
74
src/app/api/okrs/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { createOKR } from '@/services/okrs';
|
||||
import { getTeamMemberById, isTeamAdmin } from '@/services/teams';
|
||||
import type { CreateOKRInput, CreateKeyResultInput } from '@/lib/types';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { teamMemberId, objective, description, period, startDate, endDate, keyResults } =
|
||||
body as CreateOKRInput & {
|
||||
startDate: string | Date;
|
||||
endDate: string | Date;
|
||||
};
|
||||
|
||||
if (!teamMemberId || !objective || !period || !startDate || !endDate || !keyResults) {
|
||||
return NextResponse.json({ error: 'Champs requis manquants' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get team member to check permissions
|
||||
const teamMember = await getTeamMemberById(teamMemberId);
|
||||
if (!teamMember) {
|
||||
return NextResponse.json({ error: "Membre de l'équipe non trouvé" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user is admin of the team
|
||||
const isAdmin = await isTeamAdmin(teamMember.team.id, session.user.id);
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Seuls les administrateurs peuvent créer des OKRs' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Convert dates to Date objects if they are strings
|
||||
const startDateObj = startDate instanceof Date ? startDate : new Date(startDate);
|
||||
const endDateObj = endDate instanceof Date ? endDate : new Date(endDate);
|
||||
|
||||
// Validate dates
|
||||
if (isNaN(startDateObj.getTime()) || isNaN(endDateObj.getTime())) {
|
||||
return NextResponse.json({ error: 'Dates invalides' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Ensure all key results have a unit and order
|
||||
const keyResultsWithUnit = keyResults.map((kr: CreateKeyResultInput, index: number) => ({
|
||||
...kr,
|
||||
unit: kr.unit || '%',
|
||||
order: kr.order !== undefined ? kr.order : index,
|
||||
}));
|
||||
|
||||
const okr = await createOKR(
|
||||
teamMemberId,
|
||||
objective,
|
||||
description || null,
|
||||
period,
|
||||
startDateObj,
|
||||
endDateObj,
|
||||
keyResultsWithUnit
|
||||
);
|
||||
|
||||
return NextResponse.json(okr, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating OKR:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Erreur lors de la création de l'OKR";
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||
}
|
||||
}
|
||||
108
src/app/api/teams/[id]/members/route.ts
Normal file
108
src/app/api/teams/[id]/members/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { addTeamMember, removeTeamMember, updateMemberRole, isTeamAdmin } from '@/services/teams';
|
||||
import type { AddTeamMemberInput, UpdateMemberRoleInput } from '@/lib/types';
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const isAdmin = await isTeamAdmin(id, session.user.id);
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json({ error: 'Seuls les administrateurs peuvent ajouter des membres' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body: AddTeamMemberInput = await request.json();
|
||||
const { userId, role } = body;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'ID utilisateur requis' }, { status: 400 });
|
||||
}
|
||||
|
||||
const member = await addTeamMember(id, userId, role || 'MEMBER');
|
||||
|
||||
return NextResponse.json(member, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error adding team member:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de l\'ajout du membre';
|
||||
return NextResponse.json(
|
||||
{ error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const isAdmin = await isTeamAdmin(id, session.user.id);
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json({ error: 'Seuls les administrateurs peuvent modifier les rôles' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body: UpdateMemberRoleInput & { userId: string } = await request.json();
|
||||
const { userId, role } = body;
|
||||
|
||||
if (!userId || !role) {
|
||||
return NextResponse.json({ error: 'ID utilisateur et rôle requis' }, { status: 400 });
|
||||
}
|
||||
|
||||
const member = await updateMemberRole(id, userId, role);
|
||||
|
||||
return NextResponse.json(member);
|
||||
} catch (error) {
|
||||
console.error('Error updating member role:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erreur lors de la mise à jour du rôle' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const isAdmin = await isTeamAdmin(id, session.user.id);
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json({ error: 'Seuls les administrateurs peuvent retirer des membres' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const userId = searchParams.get('userId');
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'ID utilisateur requis' }, { status: 400 });
|
||||
}
|
||||
|
||||
await removeTeamMember(id, userId);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error removing team member:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erreur lors de la suppression du membre' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
91
src/app/api/teams/[id]/route.ts
Normal file
91
src/app/api/teams/[id]/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getTeam, updateTeam, deleteTeam, isTeamAdmin, isTeamMember } from '@/services/teams';
|
||||
import type { UpdateTeamInput } from '@/lib/types';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
const team = await getTeam(id);
|
||||
|
||||
if (!team) {
|
||||
return NextResponse.json({ error: 'Équipe non trouvée' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user is a member
|
||||
const isMember = await isTeamMember(id, session.user.id);
|
||||
if (!isMember) {
|
||||
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(team);
|
||||
} catch (error) {
|
||||
console.error('Error fetching team:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erreur lors de la récupération de l\'équipe' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const isAdmin = await isTeamAdmin(id, session.user.id);
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json({ error: 'Seuls les administrateurs peuvent modifier l\'équipe' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body: UpdateTeamInput = await request.json();
|
||||
const team = await updateTeam(id, body);
|
||||
|
||||
return NextResponse.json(team);
|
||||
} catch (error) {
|
||||
console.error('Error updating team:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erreur lors de la mise à jour de l\'équipe' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const isAdmin = await isTeamAdmin(id, session.user.id);
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json({ error: 'Seuls les administrateurs peuvent supprimer l\'équipe' }, { status: 403 });
|
||||
}
|
||||
|
||||
await deleteTeam(id);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting team:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erreur lors de la suppression de l\'équipe' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
52
src/app/api/teams/route.ts
Normal file
52
src/app/api/teams/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getUserTeams, createTeam } from '@/services/teams';
|
||||
import type { CreateTeamInput } from '@/lib/types';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
const teams = await getUserTeams(session.user.id);
|
||||
|
||||
return NextResponse.json(teams);
|
||||
} catch (error) {
|
||||
console.error('Error fetching teams:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erreur lors de la récupération des équipes' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body: CreateTeamInput = await request.json();
|
||||
const { name, description } = body;
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: 'Le nom de l\'équipe est requis' }, { status: 400 });
|
||||
}
|
||||
|
||||
const team = await createTeam(name, description || null, session.user.id);
|
||||
|
||||
return NextResponse.json(team, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating team:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erreur lors de la création de l\'équipe' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
33
src/app/api/users/route.ts
Normal file
33
src/app/api/users/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { prisma } from '@/services/database';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(users);
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erreur lors de la récupération des utilisateurs' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
122
src/app/api/weather/[id]/subscribe/route.ts
Normal file
122
src/app/api/weather/[id]/subscribe/route.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
122
src/app/api/weekly-checkin/[id]/subscribe/route.ts
Normal file
122
src/app/api/weekly-checkin/[id]/subscribe/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@
|
||||
/* Accent Colors */
|
||||
--accent: #8b5cf6;
|
||||
--accent-hover: #7c3aed;
|
||||
--purple: #8b5cf6;
|
||||
|
||||
/* Status */
|
||||
--success: #059669;
|
||||
@@ -103,6 +104,7 @@
|
||||
/* Accent Colors */
|
||||
--accent: #a78bfa;
|
||||
--accent-hover: #c4b5fd;
|
||||
--purple: #a78bfa;
|
||||
|
||||
/* Status (softened) */
|
||||
--success: #4ade80;
|
||||
|
||||
65
src/app/objectives/page.tsx
Normal file
65
src/app/objectives/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { getUserOKRs } from '@/services/okrs';
|
||||
import { Card } from '@/components/ui';
|
||||
import { ObjectivesList } from '@/components/okrs/ObjectivesList';
|
||||
import { comparePeriods } from '@/lib/okr-utils';
|
||||
|
||||
export default async function ObjectivesPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const okrs = await getUserOKRs(session.user.id);
|
||||
|
||||
// Group OKRs by period
|
||||
const okrsByPeriod = okrs.reduce(
|
||||
(acc, okr) => {
|
||||
const period = okr.period;
|
||||
if (!acc[period]) {
|
||||
acc[period] = [];
|
||||
}
|
||||
acc[period].push(okr);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof okrs>
|
||||
);
|
||||
|
||||
const periods = Object.keys(okrsByPeriod).sort(comparePeriods);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-2">
|
||||
<span className="text-3xl">🎯</span>
|
||||
Mes Objectifs
|
||||
</h1>
|
||||
<p className="mt-2 text-muted">
|
||||
Suivez la progression de vos OKRs à travers toutes vos équipes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{okrs.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<div className="text-5xl mb-4">🎯</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">Aucun OKR défini</h3>
|
||||
<p className="text-muted mb-6">
|
||||
Vous n'avez pas encore d'OKR défini. Contactez un administrateur d'équipe
|
||||
pour en créer.
|
||||
</p>
|
||||
<Link href="/teams">
|
||||
<span className="inline-block rounded-lg bg-[var(--purple)] px-4 py-2 text-white hover:opacity-90">
|
||||
Voir mes équipes
|
||||
</span>
|
||||
</Link>
|
||||
</Card>
|
||||
) : (
|
||||
<ObjectivesList okrsByPeriod={okrsByPeriod} periods={periods} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
343
src/app/page.tsx
343
src/app/page.tsx
@@ -68,6 +68,38 @@ export default function Home() {
|
||||
accentColor="#f59e0b"
|
||||
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>
|
||||
</section>
|
||||
|
||||
@@ -355,6 +387,290 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Weekly Check-in Deep Dive Section */}
|
||||
<section className="mb-16">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<span className="text-4xl">📝</span>
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-foreground">Weekly Check-in</h2>
|
||||
<p className="text-green-500 font-medium">Le point hebdomadaire avec vos collaborateurs</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-2">
|
||||
{/* Why */}
|
||||
<div className="rounded-xl border border-border bg-card p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<span className="text-2xl">💡</span>
|
||||
Pourquoi faire un check-in hebdomadaire ?
|
||||
</h3>
|
||||
<p className="text-muted mb-4">
|
||||
Le Weekly Check-in est un rituel de management qui permet de maintenir un lien régulier
|
||||
avec vos collaborateurs. Il favorise la communication, l'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'échange ouvert où 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'œ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'é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 */}
|
||||
<section className="mb-16">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<span className="text-4xl">🎯</span>
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-foreground">OKRs & Équipes</h2>
|
||||
<p className="text-purple-500 font-medium">Définissez et suivez les objectifs de votre équipe</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-2">
|
||||
{/* Why */}
|
||||
<div className="rounded-xl border border-border bg-card p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<span className="text-2xl">💡</span>
|
||||
Pourquoi utiliser les OKRs ?
|
||||
</h3>
|
||||
<p className="text-muted mb-4">
|
||||
Les OKRs (Objectives and Key Results) sont un cadre de gestion d'objectifs qui permet
|
||||
d'aligner les efforts de l'équipe autour d'objectifs communs et mesurables.
|
||||
Cette méthode favorise la transparence, la responsabilisation et la performance collective.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-muted">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-500">•</span>
|
||||
Aligner les objectifs individuels avec ceux de l'équipe
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-500">•</span>
|
||||
Suivre la progression en temps réel avec des métriques claires
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-500">•</span>
|
||||
Favoriser la transparence et la visibilité des objectifs de chacun
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-500">•</span>
|
||||
Créer une culture de responsabilisation et de résultats
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="rounded-xl border border-border bg-card p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<span className="text-2xl">✨</span>
|
||||
Fonctionnalités principales
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<FeaturePill
|
||||
icon="👥"
|
||||
name="Gestion d'équipes"
|
||||
color="#8b5cf6"
|
||||
description="Créez des équipes et gérez les membres avec des rôles admin/membre"
|
||||
/>
|
||||
<FeaturePill
|
||||
icon="🎯"
|
||||
name="OKRs par période"
|
||||
color="#3b82f6"
|
||||
description="Définissez des OKRs pour des trimestres ou périodes personnalisées"
|
||||
/>
|
||||
<FeaturePill
|
||||
icon="📊"
|
||||
name="Key Results mesurables"
|
||||
color="#10b981"
|
||||
description="Suivez la progression de chaque Key Result avec des valeurs et pourcentages"
|
||||
/>
|
||||
<FeaturePill
|
||||
icon="👁️"
|
||||
name="Visibilité transparente"
|
||||
color="#f59e0b"
|
||||
description="Tous les membres de l'équipe peuvent voir les OKRs de chacun"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How it works */}
|
||||
<div className="rounded-xl border border-border bg-card p-6 lg:col-span-2">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<span className="text-2xl">⚙️</span>
|
||||
Comment ça marche ?
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<StepCard
|
||||
number={1}
|
||||
title="Créer une équipe"
|
||||
description="Formez votre équipe et ajoutez les membres avec leurs rôles (admin ou membre)"
|
||||
/>
|
||||
<StepCard
|
||||
number={2}
|
||||
title="Définir les OKRs"
|
||||
description="Pour chaque membre, créez un Objectif avec plusieurs Key Results mesurables"
|
||||
/>
|
||||
<StepCard
|
||||
number={3}
|
||||
title="Suivre la progression"
|
||||
description="Mettez à jour régulièrement les valeurs des Key Results pour suivre l'avancement"
|
||||
/>
|
||||
<StepCard
|
||||
number={4}
|
||||
title="Visualiser et analyser"
|
||||
description="Consultez les OKRs par membre ou en grille, avec les progressions et statuts colorés"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benefits Section */}
|
||||
<section className="rounded-2xl border border-border bg-card p-8">
|
||||
<h2 className="mb-8 text-center text-2xl font-bold text-foreground">
|
||||
@@ -552,3 +868,30 @@ function CategoryPill({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturePill({
|
||||
icon,
|
||||
name,
|
||||
color,
|
||||
description,
|
||||
}: {
|
||||
icon: string;
|
||||
name: string;
|
||||
color: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-3 px-4 py-3 rounded-lg"
|
||||
style={{ backgroundColor: `${color}10`, border: `1px solid ${color}30` }}
|
||||
>
|
||||
<span className="text-xl">{icon}</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-sm mb-0.5" style={{ color }}>
|
||||
{name}
|
||||
</p>
|
||||
<p className="text-xs text-muted">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,10 +15,20 @@ import {
|
||||
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
|
||||
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
|
||||
import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review';
|
||||
import { deleteWeeklyCheckInSession, updateWeeklyCheckInSession } from '@/actions/weekly-checkin';
|
||||
import { deleteWeatherSession, updateWeatherSession } from '@/actions/weather';
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
@@ -84,12 +94,42 @@ interface YearReviewSession {
|
||||
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 {
|
||||
swotSessions: SwotSession[];
|
||||
motivatorSessions: MotivatorSession[];
|
||||
yearReviewSessions: YearReviewSession[];
|
||||
weeklyCheckInSessions: WeeklyCheckInSession[];
|
||||
weatherSessions: WeatherSession[];
|
||||
}
|
||||
|
||||
// Helper to get resolved collaborator from any session
|
||||
@@ -98,6 +138,19 @@ function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
|
||||
return (session as SwotSession).resolvedCollaborator;
|
||||
} else if (session.workshopType === 'year-review') {
|
||||
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 {
|
||||
return (session as MotivatorSession).resolvedParticipant;
|
||||
}
|
||||
@@ -141,6 +194,8 @@ export function WorkshopTabs({
|
||||
swotSessions,
|
||||
motivatorSessions,
|
||||
yearReviewSessions,
|
||||
weeklyCheckInSessions,
|
||||
weatherSessions,
|
||||
}: WorkshopTabsProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
@@ -165,6 +220,8 @@ export function WorkshopTabs({
|
||||
...swotSessions,
|
||||
...motivatorSessions,
|
||||
...yearReviewSessions,
|
||||
...weeklyCheckInSessions,
|
||||
...weatherSessions,
|
||||
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
|
||||
// Filter based on active tab (for non-byPerson tabs)
|
||||
@@ -175,7 +232,11 @@ export function WorkshopTabs({
|
||||
? swotSessions
|
||||
: activeTab === 'motivators'
|
||||
? motivatorSessions
|
||||
: yearReviewSessions;
|
||||
: activeTab === 'year-review'
|
||||
? yearReviewSessions
|
||||
: activeTab === 'weekly-checkin'
|
||||
? weeklyCheckInSessions
|
||||
: weatherSessions;
|
||||
|
||||
// Separate by ownership
|
||||
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
|
||||
@@ -226,6 +287,20 @@ export function WorkshopTabs({
|
||||
label="Year Review"
|
||||
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>
|
||||
|
||||
{/* Sessions */}
|
||||
@@ -338,23 +413,43 @@ function SessionCard({ session }: { session: AnySession }) {
|
||||
? (session as SwotSession).collaborator
|
||||
: session.workshopType === 'year-review'
|
||||
? (session as YearReviewSession).participant
|
||||
: (session as MotivatorSession).participant
|
||||
: session.workshopType === 'weather'
|
||||
? ''
|
||||
: (session as MotivatorSession).participant
|
||||
);
|
||||
|
||||
const isSwot = session.workshopType === 'swot';
|
||||
const isYearReview = session.workshopType === 'year-review';
|
||||
const isWeeklyCheckIn = session.workshopType === 'weekly-checkin';
|
||||
const isWeather = session.workshopType === 'weather';
|
||||
const href = isSwot
|
||||
? `/sessions/${session.id}`
|
||||
: isYearReview
|
||||
? `/year-review/${session.id}`
|
||||
: `/motivators/${session.id}`;
|
||||
const icon = isSwot ? '📊' : isYearReview ? '📅' : '🎯';
|
||||
: isWeeklyCheckIn
|
||||
? `/weekly-checkin/${session.id}`
|
||||
: isWeather
|
||||
? `/weather/${session.id}`
|
||||
: `/motivators/${session.id}`;
|
||||
const icon = isSwot ? '📊' : isYearReview ? '📅' : isWeeklyCheckIn ? '📝' : isWeather ? '🌤️' : '🎯';
|
||||
const participant = isSwot
|
||||
? (session as SwotSession).collaborator
|
||||
: isYearReview
|
||||
? (session as YearReviewSession).participant
|
||||
: (session as MotivatorSession).participant;
|
||||
const accentColor = isSwot ? '#06b6d4' : isYearReview ? '#f59e0b' : '#8b5cf6';
|
||||
: isWeeklyCheckIn
|
||||
? (session as WeeklyCheckInSession).participant
|
||||
: isWeather
|
||||
? (session as WeatherSession).user.name || (session as WeatherSession).user.email
|
||||
: (session as MotivatorSession).participant;
|
||||
const accentColor = isSwot
|
||||
? '#06b6d4'
|
||||
: isYearReview
|
||||
? '#f59e0b'
|
||||
: isWeeklyCheckIn
|
||||
? '#10b981'
|
||||
: isWeather
|
||||
? '#3b82f6'
|
||||
: '#8b5cf6';
|
||||
|
||||
const handleDelete = () => {
|
||||
startTransition(async () => {
|
||||
@@ -362,7 +457,11 @@ function SessionCard({ session }: { session: AnySession }) {
|
||||
? await deleteSwotSession(session.id)
|
||||
: isYearReview
|
||||
? await deleteYearReviewSession(session.id)
|
||||
: await deleteMotivatorSession(session.id);
|
||||
: isWeeklyCheckIn
|
||||
? await deleteWeeklyCheckInSession(session.id)
|
||||
: isWeather
|
||||
? await deleteWeatherSession(session.id)
|
||||
: await deleteMotivatorSession(session.id);
|
||||
|
||||
if (result.success) {
|
||||
setShowDeleteModal(false);
|
||||
@@ -381,10 +480,17 @@ function SessionCard({ session }: { session: AnySession }) {
|
||||
title: editTitle,
|
||||
participant: editParticipant,
|
||||
})
|
||||
: await updateMotivatorSession(session.id, {
|
||||
title: editTitle,
|
||||
participant: editParticipant,
|
||||
});
|
||||
: isWeeklyCheckIn
|
||||
? await updateWeeklyCheckInSession(session.id, {
|
||||
title: editTitle,
|
||||
participant: editParticipant,
|
||||
})
|
||||
: isWeather
|
||||
? await updateWeatherSession(session.id, { title: editTitle })
|
||||
: await updateMotivatorSession(session.id, {
|
||||
title: editTitle,
|
||||
participant: editParticipant,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setShowEditModal(false);
|
||||
@@ -401,6 +507,8 @@ function SessionCard({ session }: { session: AnySession }) {
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const editParticipantLabel = isSwot ? 'Collaborateur' : isWeather ? '' : 'Participant';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative group">
|
||||
@@ -456,6 +564,28 @@ function SessionCard({ session }: { session: AnySession }) {
|
||||
<span>·</span>
|
||||
<span>Année {(session as YearReviewSession).year}</span>
|
||||
</>
|
||||
) : isWeeklyCheckIn ? (
|
||||
<>
|
||||
<span>{(session as WeeklyCheckInSession)._count.items} items</span>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{new Date((session as WeeklyCheckInSession).date).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : isWeather ? (
|
||||
<>
|
||||
<span>{(session as WeatherSession)._count.entries} membres</span>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{new Date((session as WeatherSession).date).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{(session as MotivatorSession)._count.cards}/10</span>
|
||||
)}
|
||||
@@ -570,15 +700,17 @@ function SessionCard({ session }: { session: AnySession }) {
|
||||
htmlFor="edit-participant"
|
||||
className="block text-sm font-medium text-foreground mb-1"
|
||||
>
|
||||
{isSwot ? 'Collaborateur' : 'Participant'}
|
||||
{editParticipantLabel}
|
||||
</label>
|
||||
<Input
|
||||
id="edit-participant"
|
||||
value={editParticipant}
|
||||
onChange={(e) => setEditParticipant(e.target.value)}
|
||||
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'}
|
||||
required
|
||||
/>
|
||||
{!isWeather && (
|
||||
<Input
|
||||
id="edit-participant"
|
||||
value={editParticipant}
|
||||
onChange={(e) => setEditParticipant(e.target.value)}
|
||||
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
@@ -591,7 +723,7 @@ function SessionCard({ session }: { session: AnySession }) {
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || !editTitle.trim() || !editParticipant.trim()}
|
||||
disabled={isPending || !editTitle.trim() || (!isWeather && !editParticipant.trim())}
|
||||
>
|
||||
{isPending ? 'Enregistrement...' : 'Enregistrer'}
|
||||
</Button>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { auth } from '@/lib/auth';
|
||||
import { getSessionsByUserId } from '@/services/sessions';
|
||||
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
|
||||
import { getYearReviewSessionsByUserId } from '@/services/year-review';
|
||||
import { getWeeklyCheckInSessionsByUserId } from '@/services/weekly-checkin';
|
||||
import { getWeatherSessionsByUserId } from '@/services/weather';
|
||||
import { Card, Button } from '@/components/ui';
|
||||
import { WorkshopTabs } from './WorkshopTabs';
|
||||
|
||||
@@ -33,12 +35,15 @@ export default async function SessionsPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch SWOT, Moving Motivators, and Year Review sessions
|
||||
const [swotSessions, motivatorSessions, yearReviewSessions] = await Promise.all([
|
||||
getSessionsByUserId(session.user.id),
|
||||
getMotivatorSessionsByUserId(session.user.id),
|
||||
getYearReviewSessionsByUserId(session.user.id),
|
||||
]);
|
||||
// Fetch SWOT, Moving Motivators, Year Review, Weekly Check-in, and Weather sessions
|
||||
const [swotSessions, motivatorSessions, yearReviewSessions, weeklyCheckInSessions, weatherSessions] =
|
||||
await Promise.all([
|
||||
getSessionsByUserId(session.user.id),
|
||||
getMotivatorSessionsByUserId(session.user.id),
|
||||
getYearReviewSessionsByUserId(session.user.id),
|
||||
getWeeklyCheckInSessionsByUserId(session.user.id),
|
||||
getWeatherSessionsByUserId(session.user.id),
|
||||
]);
|
||||
|
||||
// Add type to each session for unified display
|
||||
const allSwotSessions = swotSessions.map((s) => ({
|
||||
@@ -56,11 +61,23 @@ export default async function SessionsPage() {
|
||||
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
|
||||
const allSessions = [
|
||||
...allSwotSessions,
|
||||
...allMotivatorSessions,
|
||||
...allYearReviewSessions,
|
||||
...allWeeklyCheckInSessions,
|
||||
...allWeatherSessions,
|
||||
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
|
||||
const hasNoSessions = allSessions.length === 0;
|
||||
@@ -87,11 +104,23 @@ export default async function SessionsPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/year-review/new">
|
||||
<Button>
|
||||
<Button variant="outline">
|
||||
<span>📅</span>
|
||||
Nouveau Year Review
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
@@ -104,9 +133,10 @@ export default async function SessionsPage() {
|
||||
</h2>
|
||||
<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
|
||||
découvrir les motivations, ou un Year Review pour faire le bilan de l'année.
|
||||
découvrir les motivations, un Year Review pour faire le bilan de l'année, ou un
|
||||
Weekly Check-in pour le suivi hebdomadaire.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<div className="flex gap-3 justify-center flex-wrap">
|
||||
<Link href="/sessions/new">
|
||||
<Button variant="outline">
|
||||
<span>📊</span>
|
||||
@@ -120,11 +150,23 @@ export default async function SessionsPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/year-review/new">
|
||||
<Button>
|
||||
<Button variant="outline">
|
||||
<span>📅</span>
|
||||
Créer un Year Review
|
||||
</Button>
|
||||
</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>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -133,6 +175,8 @@ export default async function SessionsPage() {
|
||||
swotSessions={allSwotSessions}
|
||||
motivatorSessions={allMotivatorSessions}
|
||||
yearReviewSessions={allYearReviewSessions}
|
||||
weeklyCheckInSessions={allWeeklyCheckInSessions}
|
||||
weatherSessions={allWeatherSessions}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
167
src/app/teams/[id]/okrs/[okrId]/edit/page.tsx
Normal file
167
src/app/teams/[id]/okrs/[okrId]/edit/page.tsx
Normal 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'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'OKR
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">Modifier l'OKR</h1>
|
||||
<OKRForm
|
||||
teamMembers={teamMembers}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => router.push(`/teams/${teamId}/okrs/${okrId}`)}
|
||||
initialData={initialData}
|
||||
/>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
281
src/app/teams/[id]/okrs/[okrId]/page.tsx
Normal file
281
src/app/teams/[id]/okrs/[okrId]/page.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { KeyResultItem } from '@/components/okrs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
|
||||
import { Button } from '@/components/ui';
|
||||
import { Badge } from '@/components/ui';
|
||||
import { getGravatarUrl } from '@/lib/gravatar';
|
||||
import type { OKR, OKRStatus } from '@/lib/types';
|
||||
import { OKR_STATUS_LABELS } from '@/lib/types';
|
||||
|
||||
// Helper function for OKR status colors
|
||||
function getOKRStatusColor(status: OKRStatus): { bg: string; color: string } {
|
||||
switch (status) {
|
||||
case 'NOT_STARTED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #6b7280 15%, transparent)', // gray-500
|
||||
color: '#6b7280',
|
||||
};
|
||||
case 'IN_PROGRESS':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #3b82f6 15%, transparent)', // blue-500
|
||||
color: '#3b82f6',
|
||||
};
|
||||
case 'COMPLETED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #10b981 15%, transparent)', // green-500
|
||||
color: '#10b981',
|
||||
};
|
||||
case 'CANCELLED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #ef4444 15%, transparent)', // red-500
|
||||
color: '#ef4444',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #6b7280 15%, transparent)',
|
||||
color: '#6b7280',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type OKRWithTeamMember = OKR & {
|
||||
teamMember: {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
};
|
||||
userId: string;
|
||||
team: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
permissions?: {
|
||||
isAdmin: boolean;
|
||||
isConcernedMember: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export default function OKRDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const teamId = params.id as string;
|
||||
const okrId = params.okrId as string;
|
||||
const [okr, setOkr] = useState<OKRWithTeamMember | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch OKR
|
||||
fetch(`/api/okrs/${okrId}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error('OKR not found');
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setOkr(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching OKR:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [okrId]);
|
||||
|
||||
const handleKeyResultUpdate = () => {
|
||||
// Refresh OKR data
|
||||
fetch(`/api/okrs/${okrId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setOkr(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error refreshing OKR:', error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer cet OKR ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/okrs/${okrId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Erreur lors de la suppression');
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/teams/${teamId}`);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4 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>
|
||||
);
|
||||
}
|
||||
|
||||
const progress = okr.progress || 0;
|
||||
const progressColor =
|
||||
progress >= 75 ? 'var(--success)' : progress >= 25 ? 'var(--accent)' : 'var(--destructive)';
|
||||
const canEdit = okr.permissions?.canEdit ?? false;
|
||||
const canDelete = okr.permissions?.canDelete ?? false;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<Link href={`/teams/${teamId}`} className="text-muted hover:text-foreground">
|
||||
← Retour à l'équipe
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-2xl flex items-center gap-2">
|
||||
<span className="text-2xl">🎯</span>
|
||||
{okr.objective}
|
||||
</CardTitle>
|
||||
{okr.description && <p className="mt-2 text-muted">{okr.description}</p>}
|
||||
{okr.teamMember && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getGravatarUrl(okr.teamMember.user.email, 96)}
|
||||
alt={okr.teamMember.user.name || okr.teamMember.user.email}
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<span className="text-sm text-muted">
|
||||
{okr.teamMember.user.name || okr.teamMember.user.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
|
||||
color: 'var(--purple)',
|
||||
}}
|
||||
>
|
||||
{okr.period}
|
||||
</Badge>
|
||||
<Badge style={getOKRStatusColor(okr.status)}>
|
||||
{OKR_STATUS_LABELS[okr.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 flex items-center justify-between text-sm">
|
||||
<span className="text-muted">Progression globale</span>
|
||||
<span className="font-medium" style={{ color: progressColor }}>
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 w-full overflow-hidden rounded-full bg-card-column">
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
backgroundColor: progressColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="flex gap-4 text-sm text-muted">
|
||||
<div>
|
||||
<strong>Début:</strong> {new Date(okr.startDate).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Fin:</strong> {new Date(okr.endDate).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{(canEdit || canDelete) && (
|
||||
<div className="mt-4 flex gap-2">
|
||||
{canEdit && (
|
||||
<Button
|
||||
onClick={() => router.push(`/teams/${teamId}/okrs/${okrId}/edit`)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Éditer
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
style={{
|
||||
color: 'var(--destructive)',
|
||||
borderColor: 'var(--destructive)',
|
||||
}}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Key Results */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-foreground mb-4">
|
||||
Key Results ({okr.keyResults?.length || 0})
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{okr.keyResults && okr.keyResults.length > 0 ? (
|
||||
okr.keyResults.map((kr) => (
|
||||
<KeyResultItem
|
||||
key={kr.id}
|
||||
keyResult={kr}
|
||||
okrId={okrId}
|
||||
canEdit={canEdit}
|
||||
onUpdate={handleKeyResultUpdate}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Card className="p-8 text-center text-muted">
|
||||
Aucun Key Result défini
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
82
src/app/teams/[id]/okrs/new/page.tsx
Normal file
82
src/app/teams/[id]/okrs/new/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { OKRForm } from '@/components/okrs';
|
||||
import { Card } from '@/components/ui';
|
||||
import type { CreateOKRInput, TeamMember } from '@/lib/types';
|
||||
|
||||
export default function NewOKRPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const teamId = params.id as string;
|
||||
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch team members
|
||||
fetch(`/api/teams/${teamId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setTeamMembers(data.members || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching team:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [teamId]);
|
||||
|
||||
const handleSubmit = async (data: CreateOKRInput) => {
|
||||
// Ensure dates are properly serialized
|
||||
const payload = {
|
||||
...data,
|
||||
startDate: typeof data.startDate === 'string' ? data.startDate : data.startDate.toISOString(),
|
||||
endDate: typeof data.endDate === 'string' ? data.endDate : data.endDate.toISOString(),
|
||||
};
|
||||
|
||||
const response = await fetch('/api/okrs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Erreur lors de la création de l'OKR');
|
||||
}
|
||||
|
||||
router.push(`/teams/${teamId}`);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||
<div className="text-center">Chargement...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<Link href={`/teams/${teamId}`} className="text-muted hover:text-foreground">
|
||||
← Retour à l'équipe
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">Créer un OKR</h1>
|
||||
<OKRForm
|
||||
teamMembers={teamMembers}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => router.push(`/teams/${teamId}`)}
|
||||
/>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
85
src/app/teams/[id]/page.tsx
Normal file
85
src/app/teams/[id]/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { getTeam, isTeamAdmin } from '@/services/teams';
|
||||
import { getTeamOKRs } from '@/services/okrs';
|
||||
import { TeamDetailClient } from '@/components/teams/TeamDetailClient';
|
||||
import { DeleteTeamButton } from '@/components/teams/DeleteTeamButton';
|
||||
import { OKRsList } from '@/components/okrs';
|
||||
import { Button } from '@/components/ui';
|
||||
import { Card } from '@/components/ui';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { TeamMember } from '@/lib/types';
|
||||
|
||||
interface TeamDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function TeamDetailPage({ params }: TeamDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const team = await getTeam(id);
|
||||
|
||||
if (!team) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Check if user is a member
|
||||
const isMember = team.members.some((m) => m.userId === session.user?.id);
|
||||
if (!isMember) {
|
||||
redirect('/teams');
|
||||
}
|
||||
|
||||
const isAdmin = await isTeamAdmin(id, session.user.id);
|
||||
const okrsData = await getTeamOKRs(id);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Link href="/teams" className="text-muted hover:text-foreground">
|
||||
← Retour aux équipes
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-2">
|
||||
<span className="text-3xl">👥</span>
|
||||
{team.name}
|
||||
</h1>
|
||||
{team.description && <p className="mt-2 text-muted">{team.description}</p>}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/teams/${id}/okrs/new`}>
|
||||
<Button className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent">
|
||||
Définir un OKR
|
||||
</Button>
|
||||
</Link>
|
||||
<DeleteTeamButton teamId={id} teamName={team.name} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Members Section */}
|
||||
<Card className="mb-8 p-6">
|
||||
<TeamDetailClient
|
||||
members={team.members as unknown as TeamMember[]}
|
||||
teamId={id}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* OKRs Section */}
|
||||
<OKRsList okrsData={okrsData} teamId={id} isAdmin={isAdmin} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
92
src/app/teams/new/page.tsx
Normal file
92
src/app/teams/new/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Input } from '@/components/ui';
|
||||
import { Textarea } from '@/components/ui';
|
||||
import { Button } from '@/components/ui';
|
||||
import { Card } from '@/components/ui';
|
||||
|
||||
export default function NewTeamPage() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
alert('Le nom de l\'équipe est requis');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const response = await fetch('/api/teams', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim(), description: description.trim() || null }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Erreur lors de la création de l\'équipe');
|
||||
return;
|
||||
}
|
||||
|
||||
const team = await response.json();
|
||||
router.push(`/teams/${team.id}`);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error('Error creating team:', error);
|
||||
alert('Erreur lors de la création de l\'équipe');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<Link href="/teams" className="text-muted hover:text-foreground">
|
||||
← Retour aux équipes
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">Créer une équipe</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Nom de l'équipe *"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Ex: Équipe Produit"
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Description de l'équipe..."
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" onClick={() => router.back()} variant="outline">
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"
|
||||
>
|
||||
{submitting ? 'Création...' : 'Créer l\'équipe'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
59
src/app/teams/page.tsx
Normal file
59
src/app/teams/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { TeamCard } from '@/components/teams';
|
||||
import { Button } from '@/components/ui';
|
||||
import { getUserTeams } from '@/services/teams';
|
||||
|
||||
export default async function TeamsPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const teams = await getUserTeams(session.user.id);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Équipes</h1>
|
||||
<p className="mt-1 text-muted">
|
||||
{teams.length} équipe{teams.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Link href="/teams/new">
|
||||
<Button className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent">
|
||||
Créer une équipe
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Teams Grid */}
|
||||
{teams.length > 0 ? (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{teams.map((team: (typeof teams)[number]) => (
|
||||
<TeamCard key={team.id} team={team as Parameters<typeof TeamCard>[0]['team']} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-border bg-card py-16">
|
||||
<div className="text-4xl">👥</div>
|
||||
<div className="mt-4 text-lg font-medium text-foreground">Aucune équipe</div>
|
||||
<div className="mt-1 text-sm text-muted">
|
||||
Créez votre première équipe pour commencer à définir des OKRs
|
||||
</div>
|
||||
<Link href="/teams/new" className="mt-6">
|
||||
<Button className="!bg-[var(--purple)] !text-white hover:!bg-[var(--purple)]/90">
|
||||
Créer une équipe
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
102
src/app/weather/[id]/page.tsx
Normal file
102
src/app/weather/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
src/app/weather/new/page.tsx
Normal file
142
src/app/weather/new/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } 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('');
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const dateStr = formData.get('date') as string;
|
||||
const date = dateStr ? new Date(dateStr) : 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}`);
|
||||
}
|
||||
|
||||
// Default date to today
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Update title when date changes
|
||||
useEffect(() => {
|
||||
setTitle(getWeekYearLabel(new Date(selectedDate)));
|
||||
}, [selectedDate]);
|
||||
|
||||
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={(e) => setTitle(e.target.value)}
|
||||
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"
|
||||
defaultValue={today}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
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'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>
|
||||
);
|
||||
}
|
||||
101
src/app/weekly-checkin/[id]/page.tsx
Normal file
101
src/app/weekly-checkin/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
src/app/weekly-checkin/new/page.tsx
Normal file
156
src/app/weekly-checkin/new/page.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } 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('');
|
||||
|
||||
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 dateStr = formData.get('date') as string;
|
||||
const date = dateStr ? new Date(dateStr) : 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}`);
|
||||
}
|
||||
|
||||
// Default date to today
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Update title when date changes
|
||||
useEffect(() => {
|
||||
setTitle(getWeekYearLabel(new Date(selectedDate)));
|
||||
}, [selectedDate]);
|
||||
|
||||
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={(e) => setTitle(e.target.value)}
|
||||
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"
|
||||
defaultValue={today}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
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'est bien passé</strong> : Notez les réussites et points
|
||||
positifs de la semaine
|
||||
</li>
|
||||
<li>
|
||||
<strong>Ce qui s'est mal passé</strong> : Identifiez les difficultés et
|
||||
points d'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>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,26 @@ export function Header() {
|
||||
Mes Ateliers
|
||||
</Link>
|
||||
|
||||
{/* Objectives Link */}
|
||||
<Link
|
||||
href="/objectives"
|
||||
className={`text-sm font-medium transition-colors ${
|
||||
isActiveLink('/objectives') ? 'text-primary' : 'text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
🎯 Mes Objectifs
|
||||
</Link>
|
||||
|
||||
{/* Teams Link */}
|
||||
<Link
|
||||
href="/teams"
|
||||
className={`text-sm font-medium transition-colors ${
|
||||
isActiveLink('/teams') ? 'text-primary' : 'text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
👥 Équipes
|
||||
</Link>
|
||||
|
||||
{/* Workshops Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
|
||||
186
src/components/okrs/KeyResultItem.tsx
Normal file
186
src/components/okrs/KeyResultItem.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Input } from '@/components/ui';
|
||||
import { Textarea } from '@/components/ui';
|
||||
import { Button } from '@/components/ui';
|
||||
import { Badge } from '@/components/ui';
|
||||
import type { KeyResult, KeyResultStatus } from '@/lib/types';
|
||||
import { KEY_RESULT_STATUS_LABELS } from '@/lib/types';
|
||||
|
||||
// Helper function for Key Result status colors
|
||||
function getKeyResultStatusColor(status: KeyResultStatus): { bg: string; color: string } {
|
||||
switch (status) {
|
||||
case 'NOT_STARTED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #6b7280 15%, transparent)', // gray-500
|
||||
color: '#6b7280',
|
||||
};
|
||||
case 'IN_PROGRESS':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #3b82f6 15%, transparent)', // blue-500
|
||||
color: '#3b82f6',
|
||||
};
|
||||
case 'COMPLETED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #10b981 15%, transparent)', // green-500
|
||||
color: '#10b981',
|
||||
};
|
||||
case 'AT_RISK':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #f59e0b 15%, transparent)', // amber-500 (orange/yellow)
|
||||
color: '#f59e0b',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #6b7280 15%, transparent)',
|
||||
color: '#6b7280',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface KeyResultItemProps {
|
||||
keyResult: KeyResult;
|
||||
okrId: string;
|
||||
canEdit: boolean;
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
export function KeyResultItem({ keyResult, okrId, canEdit, onUpdate }: KeyResultItemProps) {
|
||||
const [currentValue, setCurrentValue] = useState(keyResult.currentValue);
|
||||
const [notes, setNotes] = useState(keyResult.notes || '');
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const progress = keyResult.targetValue > 0 ? (currentValue / keyResult.targetValue) * 100 : 0;
|
||||
const progressColor =
|
||||
progress >= 100 ? 'var(--success)' : progress >= 50 ? 'var(--accent)' : 'var(--destructive)';
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setUpdating(true);
|
||||
try {
|
||||
const response = await fetch(`/api/okrs/${okrId}/key-results/${keyResult.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
currentValue: Number(currentValue),
|
||||
notes: notes || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Erreur lors de la mise à jour');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEditing(false);
|
||||
onUpdate?.();
|
||||
} catch (error) {
|
||||
console.error('Error updating key result:', error);
|
||||
alert('Erreur lors de la mise à jour');
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<div className="mb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<h4 className="font-medium text-foreground">{keyResult.title}</h4>
|
||||
<Badge style={getKeyResultStatusColor(keyResult.status)}>
|
||||
{KEY_RESULT_STATUS_LABELS[keyResult.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 flex items-center justify-between text-sm">
|
||||
<span className="text-muted">
|
||||
{currentValue} / {keyResult.targetValue} {keyResult.unit}
|
||||
</span>
|
||||
<span className="font-medium" style={{ color: progressColor }}>
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-card-column">
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min(progress, 100)}%`,
|
||||
backgroundColor: progressColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Form */}
|
||||
{canEdit && (
|
||||
<div className="space-y-3 border-t border-border pt-3">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
Valeur actuelle
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={currentValue}
|
||||
onChange={(e) => setCurrentValue(Number(e.target.value))}
|
||||
min={0}
|
||||
max={keyResult.targetValue * 2}
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Notes</label>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Ajouter des notes..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleUpdate} disabled={updating} size="sm">
|
||||
{updating ? 'Mise à jour...' : 'Enregistrer'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setCurrentValue(keyResult.currentValue);
|
||||
setNotes(keyResult.notes || '');
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
{keyResult.notes && (
|
||||
<div className="mb-2 text-sm text-muted">
|
||||
<strong>Notes:</strong> {keyResult.notes}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={() => setIsEditing(true)} variant="outline" size="sm">
|
||||
Mettre à jour la progression
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canEdit && keyResult.notes && (
|
||||
<div className="mt-3 border-t border-border pt-3 text-sm text-muted">
|
||||
<strong>Notes:</strong> {keyResult.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
389
src/components/okrs/OKRCard.tsx
Normal file
389
src/components/okrs/OKRCard.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
'use client';
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
|
||||
import { Badge } from '@/components/ui';
|
||||
import { getGravatarUrl } from '@/lib/gravatar';
|
||||
import type { OKR, KeyResult, 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)', // gray-500
|
||||
color: '#6b7280',
|
||||
};
|
||||
case 'IN_PROGRESS':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #3b82f6 15%, transparent)', // blue-500
|
||||
color: '#3b82f6',
|
||||
};
|
||||
case 'COMPLETED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #10b981 15%, transparent)', // green-500 (success)
|
||||
color: '#10b981',
|
||||
};
|
||||
case 'CANCELLED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #ef4444 15%, transparent)', // red-500 (destructive)
|
||||
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)', // gray-500
|
||||
color: '#6b7280',
|
||||
};
|
||||
case 'IN_PROGRESS':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #3b82f6 12%, transparent)', // blue-500
|
||||
color: '#3b82f6',
|
||||
};
|
||||
case 'COMPLETED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #10b981 12%, transparent)', // green-500
|
||||
color: '#10b981',
|
||||
};
|
||||
case 'AT_RISK':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #f59e0b 12%, transparent)', // amber-500 (orange/yellow)
|
||||
color: '#f59e0b',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #6b7280 12%, transparent)',
|
||||
color: '#6b7280',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface OKRCardProps {
|
||||
okr: OKR & { teamMember?: { user: { id: string; email: string; name: string | null } } };
|
||||
teamId: string;
|
||||
isAdmin?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function OKRCard({ okr, teamId, isAdmin = false, compact = false }: OKRCardProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const progress = okr.progress || 0;
|
||||
const progressColor =
|
||||
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 (
|
||||
<Card hover className="relative group">
|
||||
<Link href={`/teams/${teamId}/okrs/${okr.id}`}>
|
||||
<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>
|
||||
<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-1 pr-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-xl">🎯</span>
|
||||
{okr.objective}
|
||||
</CardTitle>
|
||||
{okr.teamMember && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getGravatarUrl(okr.teamMember.user.email, 96)}
|
||||
alt={okr.teamMember.user.name || okr.teamMember.user.email}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<span className="text-sm text-muted">
|
||||
{okr.teamMember.user.name || okr.teamMember.user.email}
|
||||
</span>
|
||||
</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
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
|
||||
color: 'var(--purple)',
|
||||
}}
|
||||
>
|
||||
{okr.period}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Link href={`/teams/${teamId}/okrs/${okr.id}`}>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Progress Bar */}
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted">Statut:</span>
|
||||
<Badge style={getOKRStatusColor(okr.status)}>{OKR_STATUS_LABELS[okr.status]}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Key Results List */}
|
||||
{okr.keyResults && okr.keyResults.length > 0 && (
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
Key Results ({okr.keyResults.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{okr.keyResults
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((kr: KeyResult) => {
|
||||
const krProgress =
|
||||
kr.targetValue > 0 ? (kr.currentValue / kr.targetValue) * 100 : 0;
|
||||
const krProgressColor =
|
||||
krProgress >= 100
|
||||
? 'var(--success)'
|
||||
: krProgress >= 50
|
||||
? 'var(--accent)'
|
||||
: 'var(--destructive)';
|
||||
|
||||
return (
|
||||
<div key={kr.id} className="space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-sm text-foreground flex-1 line-clamp-2">
|
||||
{kr.title}
|
||||
</span>
|
||||
<Badge
|
||||
style={{
|
||||
...getKeyResultStatusColor(kr.status),
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
}}
|
||||
>
|
||||
{KEY_RESULT_STATUS_LABELS[kr.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<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.5 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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Link>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
494
src/components/okrs/OKRForm.tsx
Normal file
494
src/components/okrs/OKRForm.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Input } from '@/components/ui';
|
||||
import { Textarea } from '@/components/ui';
|
||||
import { Button } from '@/components/ui';
|
||||
import { Select } from '@/components/ui';
|
||||
import type { CreateOKRInput, CreateKeyResultInput, TeamMember, KeyResult } from '@/lib/types';
|
||||
import { PERIOD_SUGGESTIONS } from '@/lib/types';
|
||||
|
||||
// Calcule les dates de début et de fin pour un trimestre donné
|
||||
function getQuarterDates(period: string): { startDate: string; endDate: string } | null {
|
||||
// Format attendu: "Q1 2025", "Q2 2026", etc.
|
||||
const match = period.match(/^Q(\d)\s+(\d{4})$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const quarter = parseInt(match[1], 10);
|
||||
const year = parseInt(match[2], 10);
|
||||
|
||||
let startMonth = 0; // Janvier = 0
|
||||
let endMonth = 2; // Mars = 2
|
||||
let endDay = 31;
|
||||
|
||||
switch (quarter) {
|
||||
case 1:
|
||||
startMonth = 0; // Janvier
|
||||
endMonth = 2; // Mars
|
||||
endDay = 31;
|
||||
break;
|
||||
case 2:
|
||||
startMonth = 3; // Avril
|
||||
endMonth = 5; // Juin
|
||||
endDay = 30;
|
||||
break;
|
||||
case 3:
|
||||
startMonth = 6; // Juillet
|
||||
endMonth = 8; // Septembre
|
||||
endDay = 30;
|
||||
break;
|
||||
case 4:
|
||||
startMonth = 9; // Octobre
|
||||
endMonth = 11; // Décembre
|
||||
endDay = 31;
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const startDate = new Date(year, startMonth, 1);
|
||||
const endDate = new Date(year, endMonth, endDay);
|
||||
|
||||
return {
|
||||
startDate: startDate.toISOString().split('T')[0],
|
||||
endDate: endDate.toISOString().split('T')[0],
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
teamMembers: TeamMember[];
|
||||
onSubmit: (data: OKRFormSubmitData) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
initialData?: Partial<CreateOKRInput> & { keyResults?: KeyResult[] };
|
||||
}
|
||||
|
||||
export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFormProps) {
|
||||
const [teamMemberId, setTeamMemberId] = useState(initialData?.teamMemberId || '');
|
||||
const [objective, setObjective] = useState(initialData?.objective || '');
|
||||
const [description, setDescription] = useState(initialData?.description || '');
|
||||
const [period, setPeriod] = useState(initialData?.period || '');
|
||||
const [customPeriod, setCustomPeriod] = useState('');
|
||||
const [startDate, setStartDate] = useState(
|
||||
initialData?.startDate ? new Date(initialData.startDate).toISOString().split('T')[0] : ''
|
||||
);
|
||||
const [endDate, setEndDate] = useState(
|
||||
initialData?.endDate ? new Date(initialData.endDate).toISOString().split('T')[0] : ''
|
||||
);
|
||||
// Initialize Key Results from existing ones if in edit mode, otherwise start with one empty
|
||||
const [keyResults, setKeyResults] = useState<KeyResultEditInput[]>(() => {
|
||||
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);
|
||||
|
||||
// Mise à jour automatique des dates quand la période change
|
||||
useEffect(() => {
|
||||
if (period && period !== 'custom' && period !== '') {
|
||||
const dates = getQuarterDates(period);
|
||||
if (dates) {
|
||||
setStartDate(dates.startDate);
|
||||
setEndDate(dates.endDate);
|
||||
}
|
||||
}
|
||||
}, [period]);
|
||||
|
||||
const addKeyResult = () => {
|
||||
if (keyResults.length >= 5) {
|
||||
alert('Maximum 5 Key Results autorisés');
|
||||
return;
|
||||
}
|
||||
setKeyResults([
|
||||
...keyResults,
|
||||
{ title: '', targetValue: 100, unit: '%', order: keyResults.length },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeKeyResult = (index: number) => {
|
||||
if (keyResults.length <= 1) {
|
||||
alert('Au moins un Key Result est requis');
|
||||
return;
|
||||
}
|
||||
setKeyResults(keyResults.filter((_, i) => i !== index).map((kr, i) => ({ ...kr, order: i })));
|
||||
};
|
||||
|
||||
const updateKeyResult = (
|
||||
index: number,
|
||||
field: keyof KeyResultEditInput,
|
||||
value: string | number
|
||||
) => {
|
||||
const updated = [...keyResults];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
setKeyResults(updated);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!teamMemberId || !objective || !period || !startDate || !endDate) {
|
||||
alert('Veuillez remplir tous les champs requis');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate Key Results
|
||||
if (keyResults.some((kr) => !kr.title || kr.targetValue <= 0)) {
|
||||
alert('Tous les Key Results doivent avoir un titre et une valeur cible > 0');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalPeriod = period === 'custom' ? customPeriod : period;
|
||||
if (!finalPeriod) {
|
||||
alert('Veuillez spécifier une période');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Convert dates to ISO strings for JSON serialization
|
||||
const startDateObj = new Date(startDate);
|
||||
const endDateObj = new Date(endDate);
|
||||
|
||||
if (isNaN(startDateObj.getTime()) || isNaN(endDateObj.getTime())) {
|
||||
alert('Dates invalides');
|
||||
setSubmitting(false);
|
||||
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({
|
||||
teamMemberId,
|
||||
objective,
|
||||
description: description || undefined,
|
||||
period: finalPeriod,
|
||||
startDate: startDateObj.toISOString() as Date | string,
|
||||
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 })),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error submitting OKR:', error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Team Member */}
|
||||
<Select
|
||||
label="Membre de l'équipe *"
|
||||
value={teamMemberId}
|
||||
onChange={(e) => setTeamMemberId(e.target.value)}
|
||||
options={teamMembers.map((member) => ({
|
||||
value: member.id,
|
||||
label: member.user.name || member.user.email,
|
||||
}))}
|
||||
placeholder="Sélectionner un membre"
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Objective */}
|
||||
<div>
|
||||
<Input
|
||||
label="Objective *"
|
||||
value={objective}
|
||||
onChange={(e) => setObjective(e.target.value)}
|
||||
placeholder="Ex: Améliorer la qualité du code"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<Textarea
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Description détaillée de l'objectif..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Period */}
|
||||
<div>
|
||||
<Select
|
||||
label="Période *"
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value)}
|
||||
options={[
|
||||
...PERIOD_SUGGESTIONS.map((p) => ({
|
||||
value: p,
|
||||
label: p,
|
||||
})),
|
||||
{
|
||||
value: 'custom',
|
||||
label: 'Personnalisée',
|
||||
},
|
||||
]}
|
||||
placeholder="Sélectionner une période"
|
||||
required
|
||||
/>
|
||||
{period === 'custom' && (
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
value={customPeriod}
|
||||
onChange={(e) => setCustomPeriod(e.target.value)}
|
||||
placeholder="Ex: Q1-Q2 2025"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Input
|
||||
label="Date de début *"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label="Date de fin *"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{period && period !== 'custom' && period !== '' && (
|
||||
<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.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Key Results */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Key Results * ({keyResults.length}/5)
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addKeyResult}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={keyResults.length >= 5}
|
||||
>
|
||||
+ Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{keyResults.map((kr, index) => (
|
||||
<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">
|
||||
<span className="text-sm font-medium text-foreground">Key Result {index + 1}</span>
|
||||
{keyResults.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => removeKeyResult(index)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
style={{
|
||||
color: 'var(--destructive)',
|
||||
borderColor: 'var(--destructive)',
|
||||
}}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder="Titre du Key Result"
|
||||
value={kr.title}
|
||||
onChange={(e) => updateKeyResult(index, 'title', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Valeur cible"
|
||||
value={kr.targetValue}
|
||||
onChange={(e) => updateKeyResult(index, 'targetValue', Number(e.target.value))}
|
||||
min={0}
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
placeholder="Unité (%)"
|
||||
value={kr.unit}
|
||||
onChange={(e) => updateKeyResult(index, 'unit', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" onClick={onCancel} variant="outline">
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"
|
||||
>
|
||||
{submitting
|
||||
? initialData?.teamMemberId
|
||||
? 'Modification...'
|
||||
: 'Création...'
|
||||
: initialData?.teamMemberId
|
||||
? "Modifier l'OKR"
|
||||
: "Créer l'OKR"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
263
src/components/okrs/OKRsList.tsx
Normal file
263
src/components/okrs/OKRsList.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { OKRCard } from './OKRCard';
|
||||
import { Card, ToggleGroup, Button } from '@/components/ui';
|
||||
import { getGravatarUrl } from '@/lib/gravatar';
|
||||
import { getCurrentQuarterPeriod, isCurrentQuarterPeriod } from '@/lib/okr-utils';
|
||||
import type { OKR } from '@/lib/types';
|
||||
|
||||
type ViewMode = 'grid' | 'grouped';
|
||||
type CardViewMode = 'detailed' | 'compact';
|
||||
|
||||
interface OKRsListProps {
|
||||
okrsData: Array<{
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
};
|
||||
okrs: Array<OKR & { progress?: number }>;
|
||||
}>;
|
||||
teamId: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
const CARD_VIEW_STORAGE_KEY = 'okr-card-view-mode';
|
||||
|
||||
export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
|
||||
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
|
||||
const allOKRs = filteredOKRsData.flatMap((tm) =>
|
||||
tm.okrs.map((okr) => ({
|
||||
...okr,
|
||||
teamMember: {
|
||||
user: tm.user,
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
if (allOKRs.length === 0) {
|
||||
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">
|
||||
<div className="text-5xl mb-4">🎯</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">
|
||||
{showAllPeriods ? 'Aucun OKR défini' : `Aucun OKR pour ${currentQuarterPeriod}`}
|
||||
</h3>
|
||||
<p className="text-muted">
|
||||
{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>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<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
|
||||
value={viewMode}
|
||||
onChange={setViewMode}
|
||||
options={[
|
||||
{
|
||||
value: 'grouped',
|
||||
label: 'Par membre',
|
||||
icon: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'grid',
|
||||
label: 'Grille',
|
||||
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 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grouped View */}
|
||||
{viewMode === 'grouped' ? (
|
||||
<div className="space-y-8">
|
||||
{filteredOKRsData
|
||||
.filter((tm) => tm.okrs.length > 0)
|
||||
.map((teamMember) => (
|
||||
<div key={teamMember.user.id}>
|
||||
{/* Member Header */}
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getGravatarUrl(teamMember.user.email, 96)}
|
||||
alt={teamMember.user.name || teamMember.user.email}
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-full border-2 border-border"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{teamMember.user.name || 'Sans nom'}
|
||||
</h3>
|
||||
<p className="text-sm text-muted">{teamMember.user.email}</p>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<span className="text-sm text-muted">
|
||||
{teamMember.okrs.length} OKR{teamMember.okrs.length !== 1 ? 's' : ''}
|
||||
</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'}`}
|
||||
>
|
||||
{teamMember.okrs.map((okr) => (
|
||||
<OKRCard
|
||||
key={okr.id}
|
||||
okr={{
|
||||
...okr,
|
||||
teamMember: {
|
||||
user: teamMember.user,
|
||||
},
|
||||
}}
|
||||
teamId={teamId}
|
||||
isAdmin={isAdmin}
|
||||
compact={cardViewMode === 'compact'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Grid View */
|
||||
<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) => (
|
||||
<OKRCard
|
||||
key={okr.id}
|
||||
okr={okr}
|
||||
teamId={teamId}
|
||||
isAdmin={isAdmin}
|
||||
compact={cardViewMode === 'compact'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
src/components/okrs/ObjectivesList.tsx
Normal file
122
src/components/okrs/ObjectivesList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/components/okrs/index.ts
Normal file
5
src/components/okrs/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { OKRCard } from './OKRCard';
|
||||
export { OKRForm } from './OKRForm';
|
||||
export { KeyResultItem } from './KeyResultItem';
|
||||
export { OKRsList } from './OKRsList';
|
||||
export { ObjectivesList } from './ObjectivesList';
|
||||
@@ -62,9 +62,9 @@ interface QuadrantHelpProps {
|
||||
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 content = HELP_CONTENT[category];
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -2,4 +2,4 @@ export { SwotBoard } from './SwotBoard';
|
||||
export { SwotQuadrant } from './SwotQuadrant';
|
||||
export { SwotCard } from './SwotCard';
|
||||
export { ActionPanel } from './ActionPanel';
|
||||
export { QuadrantHelp } from './QuadrantHelp';
|
||||
export { QuadrantHelp, QuadrantHelpPanel } from './QuadrantHelp';
|
||||
|
||||
160
src/components/teams/AddMemberModal.tsx
Normal file
160
src/components/teams/AddMemberModal.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal, ModalFooter } from '@/components/ui';
|
||||
import { Button } from '@/components/ui';
|
||||
import { Input } from '@/components/ui';
|
||||
import { Select } from '@/components/ui';
|
||||
import type { TeamRole } from '@/lib/types';
|
||||
import { TEAM_ROLE_LABELS } from '@/lib/types';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
interface AddMemberModalProps {
|
||||
teamId: string;
|
||||
existingMemberIds: string[];
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function AddMemberModal({ teamId, existingMemberIds, onClose, onSuccess }: AddMemberModalProps) {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [role, setRole] = useState<TeamRole>('MEMBER');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetchingUsers, setFetchingUsers] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch all users
|
||||
setFetchingUsers(true);
|
||||
fetch('/api/users')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
// Filter out existing members
|
||||
const availableUsers = data.filter((user: User) => !existingMemberIds.includes(user.id));
|
||||
setUsers(availableUsers);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching users:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
setFetchingUsers(false);
|
||||
});
|
||||
}, [existingMemberIds]);
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(user) =>
|
||||
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedUserId) {
|
||||
alert('Veuillez sélectionner un utilisateur');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/teams/${teamId}/members`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: selectedUserId, role }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Erreur lors de l\'ajout du membre');
|
||||
return;
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error adding member:', error);
|
||||
alert('Erreur lors de l\'ajout du membre');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={true} onClose={onClose} title="Ajouter un membre" size="md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
{/* User Search */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Rechercher un utilisateur
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Email ou nom..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
disabled={fetchingUsers}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User List */}
|
||||
{fetchingUsers ? (
|
||||
<div className="text-center py-4 text-muted">Chargement...</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted">
|
||||
{searchTerm ? 'Aucun utilisateur trouvé' : 'Aucun utilisateur disponible'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-60 overflow-y-auto border border-border rounded-lg">
|
||||
{filteredUsers.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedUserId(user.id)}
|
||||
className={`
|
||||
w-full text-left px-4 py-3 hover:bg-card-hover transition-colors
|
||||
${selectedUserId === user.id ? 'bg-primary/10 border-l-2 border-primary' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="font-medium text-foreground">{user.name || 'Sans nom'}</div>
|
||||
<div className="text-sm text-muted">{user.email}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Role Selection */}
|
||||
<Select
|
||||
label="Rôle"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as TeamRole)}
|
||||
options={[
|
||||
{ value: 'MEMBER', label: TEAM_ROLE_LABELS.MEMBER },
|
||||
{ value: 'ADMIN', label: TEAM_ROLE_LABELS.ADMIN },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!selectedUserId || loading}
|
||||
className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"
|
||||
>
|
||||
{loading ? 'Ajout...' : 'Ajouter'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
77
src/components/teams/DeleteTeamButton.tsx
Normal file
77
src/components/teams/DeleteTeamButton.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui';
|
||||
import { Modal, ModalFooter } from '@/components/ui';
|
||||
|
||||
interface DeleteTeamButtonProps {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
}
|
||||
|
||||
export function DeleteTeamButton({ teamId, teamName }: DeleteTeamButtonProps) {
|
||||
const router = useRouter();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleDelete = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/teams/${teamId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(error.error || "Erreur lors de la suppression de l'équipe");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/teams');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error('Error deleting team:', error);
|
||||
alert("Erreur lors de la suppression de l'équipe");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setShowModal(true)}
|
||||
variant="outline"
|
||||
className="text-destructive border-destructive hover:bg-destructive/10"
|
||||
>
|
||||
Supprimer l'équipe
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
title="Supprimer l'équipe"
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted">
|
||||
Êtes-vous sûr de vouloir supprimer l'équipe{' '}
|
||||
<strong className="text-foreground">"{teamName}"</strong> ?
|
||||
</p>
|
||||
<p className="text-sm text-destructive">
|
||||
Cette action est irréversible. Tous les membres, OKRs et données associées seront
|
||||
supprimés.
|
||||
</p>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" onClick={() => setShowModal(false)} disabled={isPending}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={isPending}>
|
||||
{isPending ? 'Suppression...' : 'Supprimer'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
175
src/components/teams/MembersList.tsx
Normal file
175
src/components/teams/MembersList.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { getGravatarUrl } from '@/lib/gravatar';
|
||||
import { Badge } from '@/components/ui';
|
||||
import { Button } from '@/components/ui';
|
||||
import { AddMemberModal } from './AddMemberModal';
|
||||
import type { TeamMember, TeamRole } from '@/lib/types';
|
||||
import { TEAM_ROLE_LABELS } from '@/lib/types';
|
||||
|
||||
interface MembersListProps {
|
||||
members: TeamMember[];
|
||||
teamId: string;
|
||||
isAdmin: boolean;
|
||||
onMemberUpdate: () => void;
|
||||
}
|
||||
|
||||
export function MembersList({ members, teamId, isAdmin, onMemberUpdate }: MembersListProps) {
|
||||
const [addMemberOpen, setAddMemberOpen] = useState(false);
|
||||
const [updatingRole, setUpdatingRole] = useState<string | null>(null);
|
||||
const [removingMember, setRemovingMember] = useState<string | null>(null);
|
||||
|
||||
const handleRoleChange = async (userId: string, newRole: TeamRole) => {
|
||||
setUpdatingRole(userId);
|
||||
try {
|
||||
const response = await fetch(`/api/teams/${teamId}/members`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId, role: newRole }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Erreur lors de la mise à jour du rôle');
|
||||
return;
|
||||
}
|
||||
|
||||
onMemberUpdate();
|
||||
} catch (error) {
|
||||
console.error('Error updating role:', error);
|
||||
alert('Erreur lors de la mise à jour du rôle');
|
||||
} finally {
|
||||
setUpdatingRole(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (userId: string) => {
|
||||
if (!confirm('Êtes-vous sûr de vouloir retirer ce membre de l\'équipe ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRemovingMember(userId);
|
||||
try {
|
||||
const response = await fetch(`/api/teams/${teamId}/members?userId=${userId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Erreur lors de la suppression du membre');
|
||||
return;
|
||||
}
|
||||
|
||||
onMemberUpdate();
|
||||
} catch (error) {
|
||||
console.error('Error removing member:', error);
|
||||
alert('Erreur lors de la suppression du membre');
|
||||
} finally {
|
||||
setRemovingMember(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-foreground">Membres ({members.length})</h3>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
onClick={() => setAddMemberOpen(true)}
|
||||
className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"
|
||||
>
|
||||
Ajouter un membre
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center gap-4 rounded-xl border border-border bg-card p-4"
|
||||
>
|
||||
{/* Avatar */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getGravatarUrl(member.user.email, 96)}
|
||||
alt={member.user.name || member.user.email}
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-full border-2 border-border"
|
||||
/>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground truncate">
|
||||
{member.user.name || 'Sans nom'}
|
||||
</span>
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor:
|
||||
member.role === 'ADMIN'
|
||||
? 'color-mix(in srgb, var(--purple) 15%, transparent)'
|
||||
: 'color-mix(in srgb, var(--gray) 15%, transparent)',
|
||||
color: member.role === 'ADMIN' ? 'var(--purple)' : 'var(--gray)',
|
||||
}}
|
||||
>
|
||||
{TEAM_ROLE_LABELS[member.role]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted truncate">{member.user.email}</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-2">
|
||||
{member.role === 'MEMBER' ? (
|
||||
<Button
|
||||
onClick={() => handleRoleChange(member.userId, 'ADMIN')}
|
||||
disabled={updatingRole === member.userId}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{updatingRole === member.userId ? '...' : 'Promouvoir Admin'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleRoleChange(member.userId, 'MEMBER')}
|
||||
disabled={updatingRole === member.userId}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{updatingRole === member.userId ? '...' : 'Rétrograder'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => handleRemoveMember(member.userId)}
|
||||
disabled={removingMember === member.userId}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
style={{
|
||||
color: 'var(--destructive)',
|
||||
borderColor: 'var(--destructive)',
|
||||
}}
|
||||
>
|
||||
{removingMember === member.userId ? '...' : 'Retirer'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{addMemberOpen && (
|
||||
<AddMemberModal
|
||||
teamId={teamId}
|
||||
existingMemberIds={members.map((m) => m.userId)}
|
||||
onClose={() => setAddMemberOpen(false)}
|
||||
onSuccess={onMemberUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
64
src/components/teams/TeamCard.tsx
Normal file
64
src/components/teams/TeamCard.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui';
|
||||
import { Badge } from '@/components/ui';
|
||||
import type { Team } from '@/lib/types';
|
||||
|
||||
interface TeamCardProps {
|
||||
team: Team & { userRole?: string; userOkrCount?: number; _count?: { members: number } };
|
||||
}
|
||||
|
||||
export function TeamCard({ team }: TeamCardProps) {
|
||||
const memberCount = team._count?.members || team.members?.length || 0;
|
||||
const okrCount = team.userOkrCount || 0;
|
||||
const isAdmin = team.userRole === 'ADMIN';
|
||||
|
||||
return (
|
||||
<Link href={`/teams/${team.id}`}>
|
||||
<Card hover className="h-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">👥</span>
|
||||
<CardTitle>{team.name}</CardTitle>
|
||||
</div>
|
||||
{team.description && <CardDescription className="mt-2">{team.description}</CardDescription>}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
|
||||
color: 'var(--purple)',
|
||||
}}
|
||||
>
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 text-sm text-muted">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{memberCount} membre{memberCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-lg">🎯</span>
|
||||
<span>{okrCount} OKR{okrCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
22
src/components/teams/TeamDetailClient.tsx
Normal file
22
src/components/teams/TeamDetailClient.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { MembersList } from './MembersList';
|
||||
import type { TeamMember } from '@/lib/types';
|
||||
|
||||
interface TeamDetailClientProps {
|
||||
members: TeamMember[];
|
||||
teamId: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export function TeamDetailClient({ members, teamId, isAdmin }: TeamDetailClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleMemberUpdate = () => {
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return <MembersList members={members} teamId={teamId} isAdmin={isAdmin} onMemberUpdate={handleMemberUpdate} />;
|
||||
}
|
||||
|
||||
5
src/components/teams/index.ts
Normal file
5
src/components/teams/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { TeamCard } from './TeamCard';
|
||||
export { TeamDetailClient } from './TeamDetailClient';
|
||||
export { MembersList } from './MembersList';
|
||||
export { AddMemberModal } from './AddMemberModal';
|
||||
export { DeleteTeamButton } from './DeleteTeamButton';
|
||||
28
src/components/ui/EditableWeatherTitle.tsx
Normal file
28
src/components/ui/EditableWeatherTitle.tsx
Normal 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;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
28
src/components/ui/EditableWeeklyCheckInTitle.tsx
Normal file
28
src/components/ui/EditableWeeklyCheckInTitle.tsx
Normal 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;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
71
src/components/ui/Select.tsx
Normal file
71
src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { forwardRef, SelectHTMLAttributes } from 'react';
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'children'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className = '', label, error, id, options, placeholder, ...props }, ref) => {
|
||||
const selectId = id || props.name;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label htmlFor={selectId} className="mb-2 block text-sm font-medium text-foreground">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<select
|
||||
ref={ref}
|
||||
id={selectId}
|
||||
className={`
|
||||
w-full appearance-none rounded-lg border bg-input px-4 py-2.5 pr-10 text-foreground
|
||||
placeholder:text-muted-foreground
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/20
|
||||
disabled:cursor-not-allowed disabled:opacity-50
|
||||
${error ? 'border-destructive focus:border-destructive' : 'border-input-border focus:border-primary'}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled={props.required}>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Custom arrow icon */}
|
||||
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<svg
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
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>
|
||||
{error && <p className="mt-1.5 text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
|
||||
46
src/components/ui/ToggleGroup.tsx
Normal file
46
src/components/ui/ToggleGroup.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export interface ToggleOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
interface ToggleGroupProps<T extends string> {
|
||||
value: T;
|
||||
options: ToggleOption<T>[];
|
||||
onChange: (value: T) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToggleGroup<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
className = '',
|
||||
}: ToggleGroupProps<T>) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 rounded-lg border border-border bg-card p-1 ${className}`}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`
|
||||
flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors
|
||||
${value === option.value
|
||||
? 'bg-[#8b5cf6] text-white shadow-sm'
|
||||
: 'text-muted hover:text-foreground hover:bg-card-hover'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{option.icon && <span className="flex items-center">{option.icon}</span>}
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ export { EditableTitle } from './EditableTitle';
|
||||
export { EditableSessionTitle } from './EditableSessionTitle';
|
||||
export { EditableMotivatorTitle } from './EditableMotivatorTitle';
|
||||
export { EditableYearReviewTitle } from './EditableYearReviewTitle';
|
||||
export { EditableWeeklyCheckInTitle } from './EditableWeeklyCheckInTitle';
|
||||
export { EditableWeatherTitle } from './EditableWeatherTitle';
|
||||
export { Input } from './Input';
|
||||
export { Modal, ModalFooter } from './Modal';
|
||||
export { Select } from './Select';
|
||||
export { Textarea } from './Textarea';
|
||||
export { ToggleGroup } from './ToggleGroup';
|
||||
export type { ToggleOption } from './ToggleGroup';
|
||||
146
src/components/weather/WeatherBoard.tsx
Normal file
146
src/components/weather/WeatherBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
273
src/components/weather/WeatherCard.tsx
Normal file
273
src/components/weather/WeatherCard.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } 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;
|
||||
|
||||
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="px-4 py-3">
|
||||
{canEditThis ? (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={performanceEmoji || ''}
|
||||
onChange={(e) => handleEmojiChange('performance', e.target.value || null)}
|
||||
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 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, label }) => (
|
||||
<option key={emoji || 'none'} value={emoji}>
|
||||
{emoji} {label}
|
||||
</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="text-2xl text-center">{performanceEmoji || '-'}</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Moral */}
|
||||
<td className="px-4 py-3">
|
||||
{canEditThis ? (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={moralEmoji || ''}
|
||||
onChange={(e) => handleEmojiChange('moral', e.target.value || null)}
|
||||
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 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, label }) => (
|
||||
<option key={emoji || 'none'} value={emoji}>
|
||||
{emoji} {label}
|
||||
</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="text-2xl text-center">{moralEmoji || '-'}</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Flux */}
|
||||
<td className="px-4 py-3">
|
||||
{canEditThis ? (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={fluxEmoji || ''}
|
||||
onChange={(e) => handleEmojiChange('flux', e.target.value || null)}
|
||||
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 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, label }) => (
|
||||
<option key={emoji || 'none'} value={emoji}>
|
||||
{emoji} {label}
|
||||
</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="text-2xl text-center">{fluxEmoji || '-'}</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Création de valeur */}
|
||||
<td className="px-4 py-3">
|
||||
{canEditThis ? (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={valueCreationEmoji || ''}
|
||||
onChange={(e) => handleEmojiChange('valueCreation', e.target.value || null)}
|
||||
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 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, label }) => (
|
||||
<option key={emoji || 'none'} value={emoji}>
|
||||
{emoji} {label}
|
||||
</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="text-2xl text-center">{valueCreationEmoji || '-'}</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Notes */}
|
||||
<td className="px-4 py-3 min-w-[300px]">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
56
src/components/weather/WeatherInfoPanel.tsx
Normal file
56
src/components/weather/WeatherInfoPanel.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
142
src/components/weather/WeatherLiveWrapper.tsx
Normal file
142
src/components/weather/WeatherLiveWrapper.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
307
src/components/weather/WeatherShareModal.tsx
Normal file
307
src/components/weather/WeatherShareModal.tsx
Normal 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'êtes membre d'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>
|
||||
);
|
||||
}
|
||||
5
src/components/weather/index.ts
Normal file
5
src/components/weather/index.ts
Normal 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';
|
||||
161
src/components/weekly-checkin/CurrentQuarterOKRs.tsx
Normal file
161
src/components/weekly-checkin/CurrentQuarterOKRs.tsx
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
94
src/components/weekly-checkin/WeeklyCheckInBoard.tsx
Normal file
94
src/components/weekly-checkin/WeeklyCheckInBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
src/components/weekly-checkin/WeeklyCheckInCard.tsx
Normal file
200
src/components/weekly-checkin/WeeklyCheckInCard.tsx
Normal 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';
|
||||
132
src/components/weekly-checkin/WeeklyCheckInLiveWrapper.tsx
Normal file
132
src/components/weekly-checkin/WeeklyCheckInLiveWrapper.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
173
src/components/weekly-checkin/WeeklyCheckInSection.tsx
Normal file
173
src/components/weekly-checkin/WeeklyCheckInSection.tsx
Normal 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';
|
||||
172
src/components/weekly-checkin/WeeklyCheckInShareModal.tsx
Normal file
172
src/components/weekly-checkin/WeeklyCheckInShareModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/components/weekly-checkin/index.ts
Normal file
6
src/components/weekly-checkin/index.ts
Normal 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';
|
||||
31
src/hooks/useWeatherLive.ts
Normal file
31
src/hooks/useWeatherLive.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
31
src/hooks/useWeeklyCheckInLive.ts
Normal file
31
src/hooks/useWeeklyCheckInLive.ts
Normal 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
21
src/lib/date-utils.ts
Normal 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
58
src/lib/okr-utils.ts
Normal 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);
|
||||
}
|
||||
372
src/lib/types.ts
372
src/lib/types.ts
@@ -415,3 +415,375 @@ export const YEAR_REVIEW_BY_CATEGORY: Record<
|
||||
},
|
||||
{} as Record<YearReviewCategory, YearReviewSectionConfig>
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Teams & OKRs - Type Definitions
|
||||
// ============================================
|
||||
|
||||
export type TeamRole = 'ADMIN' | 'MEMBER';
|
||||
export type OKRStatus = 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED';
|
||||
export type KeyResultStatus = 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED' | 'AT_RISK';
|
||||
|
||||
export interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
createdById: string;
|
||||
members: TeamMember[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface TeamMember {
|
||||
id: string;
|
||||
teamId: string;
|
||||
userId: string;
|
||||
user: User;
|
||||
role: TeamRole;
|
||||
okrs: OKR[];
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
export interface OKR {
|
||||
id: string;
|
||||
teamMemberId: string;
|
||||
objective: string;
|
||||
description: string | null;
|
||||
period: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
status: OKRStatus;
|
||||
keyResults: KeyResult[];
|
||||
progress?: number; // Calculé
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface KeyResult {
|
||||
id: string;
|
||||
okrId: string;
|
||||
title: string;
|
||||
targetValue: number;
|
||||
currentValue: number;
|
||||
unit: string;
|
||||
status: KeyResultStatus;
|
||||
order: number;
|
||||
notes: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Teams & OKRs - Input Types
|
||||
// ============================================
|
||||
|
||||
export interface CreateTeamInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTeamInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface AddTeamMemberInput {
|
||||
userId: string;
|
||||
role?: TeamRole;
|
||||
}
|
||||
|
||||
export interface UpdateMemberRoleInput {
|
||||
role: TeamRole;
|
||||
}
|
||||
|
||||
export interface CreateOKRInput {
|
||||
teamMemberId: string;
|
||||
objective: string;
|
||||
description?: string;
|
||||
period: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
keyResults: CreateKeyResultInput[];
|
||||
}
|
||||
|
||||
export interface UpdateOKRInput {
|
||||
objective?: string;
|
||||
description?: string;
|
||||
period?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
status?: OKRStatus;
|
||||
}
|
||||
|
||||
export interface CreateKeyResultInput {
|
||||
title: string;
|
||||
targetValue: number;
|
||||
unit?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateKeyResultInput {
|
||||
currentValue?: number;
|
||||
status?: KeyResultStatus;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Teams & OKRs - UI Config
|
||||
// ============================================
|
||||
|
||||
export const OKR_STATUS_LABELS: Record<OKRStatus, string> = {
|
||||
NOT_STARTED: 'Non démarré',
|
||||
IN_PROGRESS: 'En cours',
|
||||
COMPLETED: 'Terminé',
|
||||
CANCELLED: 'Annulé',
|
||||
};
|
||||
|
||||
export const KEY_RESULT_STATUS_LABELS: Record<KeyResultStatus, string> = {
|
||||
NOT_STARTED: 'Non démarré',
|
||||
IN_PROGRESS: 'En cours',
|
||||
COMPLETED: 'Terminé',
|
||||
AT_RISK: 'À risque',
|
||||
};
|
||||
|
||||
export const TEAM_ROLE_LABELS: Record<TeamRole, string> = {
|
||||
ADMIN: 'Admin',
|
||||
MEMBER: 'Membre',
|
||||
};
|
||||
|
||||
// Génère les périodes par défaut : trimestres de l'année en cours + trimestres de l'année suivante
|
||||
function generatePeriodSuggestions(): string[] {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const nextYear = currentYear + 1;
|
||||
const periods: string[] = [];
|
||||
|
||||
// Trimestres de l'année en cours
|
||||
for (let q = 1; q <= 4; q++) {
|
||||
periods.push(`Q${q} ${currentYear}`);
|
||||
}
|
||||
|
||||
// Trimestres de l'année suivante
|
||||
for (let q = 1; q <= 4; q++) {
|
||||
periods.push(`Q${q} ${nextYear}`);
|
||||
}
|
||||
|
||||
return periods;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
464
src/services/okrs.ts
Normal file
464
src/services/okrs.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
import { prisma } from '@/services/database';
|
||||
import type { UpdateOKRInput, OKRStatus, KeyResultStatus } from '@/lib/types';
|
||||
|
||||
export async function createOKR(
|
||||
teamMemberId: string,
|
||||
objective: string,
|
||||
description: string | null,
|
||||
period: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
keyResults: Array<{ title: string; targetValue: number; unit: string; order: number }>
|
||||
) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// Create OKR
|
||||
const okr = await tx.oKR.create({
|
||||
data: {
|
||||
teamMemberId,
|
||||
objective,
|
||||
description,
|
||||
period,
|
||||
startDate,
|
||||
endDate,
|
||||
status: 'NOT_STARTED',
|
||||
},
|
||||
});
|
||||
|
||||
// Create Key Results
|
||||
const createdKeyResults = await Promise.all(
|
||||
keyResults.map((kr, index) =>
|
||||
tx.keyResult.create({
|
||||
data: {
|
||||
okrId: okr.id,
|
||||
title: kr.title,
|
||||
targetValue: kr.targetValue,
|
||||
currentValue: 0,
|
||||
unit: kr.unit || '%',
|
||||
status: 'NOT_STARTED',
|
||||
order: kr.order !== undefined ? kr.order : index,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
...okr,
|
||||
keyResults: createdKeyResults,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOKR(okrId: string) {
|
||||
const okr = await prisma.oKR.findUnique({
|
||||
where: { id: okrId },
|
||||
include: {
|
||||
keyResults: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
teamMember: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!okr) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate progress
|
||||
const progress = calculateOKRProgressFromKeyResults(okr.keyResults);
|
||||
|
||||
return {
|
||||
...okr,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTeamMemberOKRs(teamMemberId: string) {
|
||||
const okrs = await prisma.oKR.findMany({
|
||||
where: { teamMemberId },
|
||||
include: {
|
||||
keyResults: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return okrs.map((okr) => ({
|
||||
...okr,
|
||||
progress: calculateOKRProgressFromKeyResults(okr.keyResults),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getTeamOKRs(teamId: string) {
|
||||
// Get all team members
|
||||
const teamMembers = await prisma.teamMember.findMany({
|
||||
where: { teamId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
okrs: {
|
||||
include: {
|
||||
keyResults: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return teamMembers.map((tm) => ({
|
||||
...tm,
|
||||
okrs: tm.okrs.map((okr) => ({
|
||||
...okr,
|
||||
progress: calculateOKRProgressFromKeyResults(okr.keyResults),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getUserOKRs(userId: string) {
|
||||
// Get all team members for this user
|
||||
const teamMembers = await prisma.teamMember.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
okrs: {
|
||||
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 getUserOKRsForPeriod(userId: string, period: string) {
|
||||
// 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 },
|
||||
data: {
|
||||
...(data.objective !== undefined && { objective: data.objective }),
|
||||
...(data.description !== undefined && { description: data.description || null }),
|
||||
...(data.period !== undefined && { period: data.period }),
|
||||
...(data.startDate !== undefined && { startDate: data.startDate }),
|
||||
...(data.endDate !== undefined && { endDate: data.endDate }),
|
||||
...(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: {
|
||||
keyResults: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!updatedOkr) {
|
||||
throw new Error('OKR not found after update');
|
||||
}
|
||||
|
||||
return {
|
||||
...updatedOkr,
|
||||
progress: calculateOKRProgressFromKeyResults(updatedOkr.keyResults),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteOKR(okrId: string) {
|
||||
// Cascade delete will handle key results
|
||||
return prisma.oKR.delete({
|
||||
where: { id: okrId },
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
// Auto-update status based on progress
|
||||
const kr = await prisma.keyResult.findUnique({
|
||||
where: { id: krId },
|
||||
include: {
|
||||
okr: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!kr) {
|
||||
throw new Error('Key Result not found');
|
||||
}
|
||||
|
||||
const progress = (currentValue / kr.targetValue) * 100;
|
||||
let status: KeyResultStatus = kr.status;
|
||||
|
||||
if (progress >= 100) {
|
||||
status = 'COMPLETED';
|
||||
} else if (progress > 0) {
|
||||
status = progress < 50 ? 'AT_RISK' : 'IN_PROGRESS';
|
||||
} else {
|
||||
status = 'NOT_STARTED';
|
||||
}
|
||||
|
||||
const updated = await prisma.keyResult.update({
|
||||
where: { id: krId },
|
||||
data: {
|
||||
currentValue,
|
||||
notes: notes !== undefined ? notes : kr.notes,
|
||||
status,
|
||||
},
|
||||
include: {
|
||||
okr: {
|
||||
include: {
|
||||
keyResults: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Update OKR status based on key results
|
||||
const okrProgress = calculateOKRProgressFromKeyResults(updated.okr.keyResults);
|
||||
let okrStatus: OKRStatus = updated.okr.status;
|
||||
|
||||
if (okrProgress >= 100) {
|
||||
okrStatus = 'COMPLETED';
|
||||
} else if (okrProgress > 0) {
|
||||
okrStatus = 'IN_PROGRESS';
|
||||
}
|
||||
|
||||
// Update OKR status if needed
|
||||
if (okrStatus !== updated.okr.status) {
|
||||
await prisma.oKR.update({
|
||||
where: { id: updated.okr.id },
|
||||
data: { status: okrStatus },
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function calculateOKRProgress(okrId: string): Promise<number> {
|
||||
return prisma.oKR
|
||||
.findUnique({
|
||||
where: { id: okrId },
|
||||
include: {
|
||||
keyResults: true,
|
||||
},
|
||||
})
|
||||
.then((okr) => {
|
||||
if (!okr) {
|
||||
return 0;
|
||||
}
|
||||
return calculateOKRProgressFromKeyResults(okr.keyResults);
|
||||
});
|
||||
}
|
||||
|
||||
function calculateOKRProgressFromKeyResults(
|
||||
keyResults: Array<{ currentValue: number; targetValue: number }>
|
||||
): number {
|
||||
if (keyResults.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const totalProgress = keyResults.reduce((sum, kr) => {
|
||||
const progress = kr.targetValue > 0 ? (kr.currentValue / kr.targetValue) * 100 : 0;
|
||||
return sum + Math.min(progress, 100); // Cap at 100%
|
||||
}, 0);
|
||||
|
||||
return Math.round(totalProgress / keyResults.length);
|
||||
}
|
||||
267
src/services/teams.ts
Normal file
267
src/services/teams.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { prisma } from '@/services/database';
|
||||
import type { UpdateTeamInput, TeamRole } from '@/lib/types';
|
||||
|
||||
export async function createTeam(name: string, description: string | null, createdById: string) {
|
||||
// Create team and add creator as admin in a transaction
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const team = await tx.team.create({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
createdById,
|
||||
},
|
||||
});
|
||||
|
||||
// Add creator as admin
|
||||
await tx.teamMember.create({
|
||||
data: {
|
||||
teamId: team.id,
|
||||
userId: createdById,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
return team;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTeam(teamId: string) {
|
||||
return prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
include: {
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
okrs: {
|
||||
include: {
|
||||
keyResults: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
joinedAt: 'asc',
|
||||
},
|
||||
},
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserTeams(userId: string) {
|
||||
// Get teams where user is a member (admin or regular member)
|
||||
const teamMembers = await prisma.teamMember.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
okrs: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
joinedAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return teamMembers.map((tm) => ({
|
||||
...tm.team,
|
||||
userRole: tm.role,
|
||||
userOkrCount: tm._count.okrs,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateTeam(teamId: string, data: UpdateTeamInput) {
|
||||
return prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data: {
|
||||
...(data.name !== undefined && { name: data.name }),
|
||||
...(data.description !== undefined && { description: data.description || null }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTeam(teamId: string) {
|
||||
// Cascade delete will handle members and OKRs
|
||||
return prisma.team.delete({
|
||||
where: { id: teamId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function addTeamMember(teamId: string, userId: string, role: TeamRole = 'MEMBER') {
|
||||
// Check if member already exists
|
||||
const existing = await prisma.teamMember.findUnique({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
teamId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error('Cet utilisateur est déjà membre de cette équipe');
|
||||
}
|
||||
|
||||
return prisma.teamMember.create({
|
||||
data: {
|
||||
teamId,
|
||||
userId,
|
||||
role,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeTeamMember(teamId: string, userId: string) {
|
||||
return prisma.teamMember.delete({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
teamId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateMemberRole(teamId: string, userId: string, role: TeamRole) {
|
||||
return prisma.teamMember.update({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
teamId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
data: { role },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function isTeamAdmin(teamId: string, userId: string): Promise<boolean> {
|
||||
const member = await prisma.teamMember.findUnique({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
teamId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
return member?.role === 'ADMIN';
|
||||
}
|
||||
|
||||
export async function isTeamMember(teamId: string, userId: string): Promise<boolean> {
|
||||
const member = await prisma.teamMember.findUnique({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
teamId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return !!member;
|
||||
}
|
||||
|
||||
export async function getTeamMember(teamId: string, userId: string) {
|
||||
return prisma.teamMember.findUnique({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
teamId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
okrs: {
|
||||
include: {
|
||||
keyResults: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTeamMemberById(teamMemberId: string) {
|
||||
return prisma.teamMember.findUnique({
|
||||
where: { id: teamMemberId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
388
src/services/weather.ts
Normal file
388
src/services/weather.ts
Normal 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;
|
||||
}
|
||||
371
src/services/weekly-checkin.ts
Normal file
371
src/services/weekly-checkin.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user