From 5f661c8bfde973a5e8a48c42e5886e55858d38d6 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Wed, 7 Jan 2026 10:11:59 +0100 Subject: [PATCH] feat: introduce Teams & OKRs feature with models, types, and UI components for team management and objective tracking --- .../migration.sql | 98 +++++ prisma/schema.prisma | 91 +++++ .../api/okrs/[id]/key-results/[krId]/route.ts | 59 +++ src/app/api/okrs/[id]/route.ts | 111 ++++++ src/app/api/okrs/route.ts | 74 ++++ src/app/api/teams/[id]/members/route.ts | 107 ++++++ src/app/api/teams/[id]/route.ts | 91 +++++ src/app/api/teams/route.ts | 52 +++ src/app/api/users/route.ts | 33 ++ src/app/globals.css | 2 + src/app/objectives/page.tsx | 301 ++++++++++++++++ src/app/page.tsx | 135 +++++++ src/app/teams/[id]/okrs/[okrId]/page.tsx | 269 ++++++++++++++ src/app/teams/[id]/okrs/new/page.tsx | 82 +++++ src/app/teams/[id]/page.tsx | 85 +++++ src/app/teams/new/page.tsx | 92 +++++ src/app/teams/page.tsx | 59 +++ src/components/layout/Header.tsx | 20 ++ src/components/okrs/KeyResultItem.tsx | 186 ++++++++++ src/components/okrs/OKRCard.tsx | 209 +++++++++++ src/components/okrs/OKRForm.tsx | 340 ++++++++++++++++++ src/components/okrs/OKRsList.tsx | 143 ++++++++ src/components/okrs/index.ts | 5 + src/components/teams/AddMemberModal.tsx | 160 +++++++++ src/components/teams/DeleteTeamButton.tsx | 72 ++++ src/components/teams/MembersList.tsx | 175 +++++++++ src/components/teams/TeamCard.tsx | 64 ++++ src/components/teams/TeamDetailClient.tsx | 22 ++ src/components/teams/index.ts | 5 + src/components/ui/Select.tsx | 71 ++++ src/components/ui/ToggleGroup.tsx | 46 +++ src/components/ui/index.ts | 3 + src/lib/types.ts | 156 ++++++++ src/services/okrs.ts | 308 ++++++++++++++++ src/services/teams.ts | 267 ++++++++++++++ 35 files changed, 3993 insertions(+) create mode 100644 prisma/migrations/20260107090831_add_teams_and_okrs/migration.sql create mode 100644 src/app/api/okrs/[id]/key-results/[krId]/route.ts create mode 100644 src/app/api/okrs/[id]/route.ts create mode 100644 src/app/api/okrs/route.ts create mode 100644 src/app/api/teams/[id]/members/route.ts create mode 100644 src/app/api/teams/[id]/route.ts create mode 100644 src/app/api/teams/route.ts create mode 100644 src/app/api/users/route.ts create mode 100644 src/app/objectives/page.tsx create mode 100644 src/app/teams/[id]/okrs/[okrId]/page.tsx create mode 100644 src/app/teams/[id]/okrs/new/page.tsx create mode 100644 src/app/teams/[id]/page.tsx create mode 100644 src/app/teams/new/page.tsx create mode 100644 src/app/teams/page.tsx create mode 100644 src/components/okrs/KeyResultItem.tsx create mode 100644 src/components/okrs/OKRCard.tsx create mode 100644 src/components/okrs/OKRForm.tsx create mode 100644 src/components/okrs/OKRsList.tsx create mode 100644 src/components/okrs/index.ts create mode 100644 src/components/teams/AddMemberModal.tsx create mode 100644 src/components/teams/DeleteTeamButton.tsx create mode 100644 src/components/teams/MembersList.tsx create mode 100644 src/components/teams/TeamCard.tsx create mode 100644 src/components/teams/TeamDetailClient.tsx create mode 100644 src/components/teams/index.ts create mode 100644 src/components/ui/Select.tsx create mode 100644 src/components/ui/ToggleGroup.tsx create mode 100644 src/services/okrs.ts create mode 100644 src/services/teams.ts diff --git a/prisma/migrations/20260107090831_add_teams_and_okrs/migration.sql b/prisma/migrations/20260107090831_add_teams_and_okrs/migration.sql new file mode 100644 index 0000000..be10dcf --- /dev/null +++ b/prisma/migrations/20260107090831_add_teams_and_okrs/migration.sql @@ -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"); + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e7a927d..0023459 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,6 +25,9 @@ model User { yearReviewSessions YearReviewSession[] sharedYearReviewSessions YRSessionShare[] yearReviewSessionEvents YRSessionEvent[] + // Teams & OKRs relations + createdTeams Team[] + teamMembers TeamMember[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -274,3 +277,91 @@ 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]) +} diff --git a/src/app/api/okrs/[id]/key-results/[krId]/route.ts b/src/app/api/okrs/[id]/key-results/[krId]/route.ts new file mode 100644 index 0000000..ee5c192 --- /dev/null +++ b/src/app/api/okrs/[id]/key-results/[krId]/route.ts @@ -0,0 +1,59 @@ +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: any) { + console.error('Error updating key result:', error); + return NextResponse.json( + { error: error.message || 'Erreur lors de la mise à jour du Key Result' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/okrs/[id]/route.ts b/src/app/api/okrs/[id]/route.ts new file mode 100644 index 0000000..524bcb4 --- /dev/null +++ b/src/app/api/okrs/[id]/route.ts @@ -0,0 +1,111 @@ +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 }); + } + + return NextResponse.json(okr); + } 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 + const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id); + if (!isAdmin) { + return NextResponse.json({ error: 'Seuls les administrateurs peuvent modifier les OKRs' }, { status: 403 }); + } + + const body: UpdateOKRInput & { startDate?: string; endDate?: 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); + } + + const updated = await updateOKR(id, updateData); + + return NextResponse.json(updated); + } catch (error: any) { + console.error('Error updating OKR:', error); + return NextResponse.json( + { error: error.message || 'Erreur lors de la mise à jour de l\'OKR' }, + { 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: any) { + console.error('Error deleting OKR:', error); + return NextResponse.json( + { error: error.message || 'Erreur lors de la suppression de l\'OKR' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/okrs/route.ts b/src/app/api/okrs/route.ts new file mode 100644 index 0000000..8b7d9fd --- /dev/null +++ b/src/app/api/okrs/route.ts @@ -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 }); + } +} diff --git a/src/app/api/teams/[id]/members/route.ts b/src/app/api/teams/[id]/members/route.ts new file mode 100644 index 0000000..e946007 --- /dev/null +++ b/src/app/api/teams/[id]/members/route.ts @@ -0,0 +1,107 @@ +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: any) { + console.error('Error adding team member:', error); + return NextResponse.json( + { error: error.message || 'Erreur lors de l\'ajout du membre' }, + { 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 } + ); + } +} + diff --git a/src/app/api/teams/[id]/route.ts b/src/app/api/teams/[id]/route.ts new file mode 100644 index 0000000..62c227a --- /dev/null +++ b/src/app/api/teams/[id]/route.ts @@ -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 } + ); + } +} + diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts new file mode 100644 index 0000000..0a4c1d7 --- /dev/null +++ b/src/app/api/teams/route.ts @@ -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 } + ); + } +} + diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..3ee9781 --- /dev/null +++ b/src/app/api/users/route.ts @@ -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 } + ); + } +} + diff --git a/src/app/globals.css b/src/app/globals.css index d4364dc..1b4abba 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/app/objectives/page.tsx b/src/app/objectives/page.tsx new file mode 100644 index 0000000..14e98a1 --- /dev/null +++ b/src/app/objectives/page.tsx @@ -0,0 +1,301 @@ +import { auth } from '@/lib/auth'; +import { redirect } from 'next/navigation'; +import Link from 'next/link'; +import { getUserOKRs } from '@/services/okrs'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'; +import { Badge } from '@/components/ui'; +import { getGravatarUrl } from '@/lib/gravatar'; +import type { OKRStatus, KeyResultStatus } from '@/lib/types'; +import { OKR_STATUS_LABELS, KEY_RESULT_STATUS_LABELS } from '@/lib/types'; + +// Helper functions for status colors +function getOKRStatusColor(status: OKRStatus): { bg: string; color: string } { + switch (status) { + case 'NOT_STARTED': + return { + bg: 'color-mix(in srgb, #6b7280 15%, transparent)', + color: '#6b7280', + }; + case 'IN_PROGRESS': + return { + bg: 'color-mix(in srgb, #3b82f6 15%, transparent)', + color: '#3b82f6', + }; + case 'COMPLETED': + return { + bg: 'color-mix(in srgb, #10b981 15%, transparent)', + color: '#10b981', + }; + case 'CANCELLED': + return { + bg: 'color-mix(in srgb, #ef4444 15%, transparent)', + color: '#ef4444', + }; + default: + return { + bg: 'color-mix(in srgb, #6b7280 15%, transparent)', + color: '#6b7280', + }; + } +} + +function getKeyResultStatusColor(status: KeyResultStatus): { bg: string; color: string } { + switch (status) { + case 'NOT_STARTED': + return { + bg: 'color-mix(in srgb, #6b7280 12%, transparent)', + color: '#6b7280', + }; + case 'IN_PROGRESS': + return { + bg: 'color-mix(in srgb, #3b82f6 12%, transparent)', + color: '#3b82f6', + }; + case 'COMPLETED': + return { + bg: 'color-mix(in srgb, #10b981 12%, transparent)', + color: '#10b981', + }; + case 'AT_RISK': + return { + bg: 'color-mix(in srgb, #f59e0b 12%, transparent)', + color: '#f59e0b', + }; + default: + return { + bg: 'color-mix(in srgb, #6b7280 12%, transparent)', + color: '#6b7280', + }; + } +} + +export default async function ObjectivesPage() { + 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 + ); + + const periods = Object.keys(okrsByPeriod).sort((a, b) => { + // Sort periods: extract year and quarter/period + const aMatch = a.match(/(\d{4})/); + const bMatch = b.match(/(\d{4})/); + if (aMatch && bMatch) { + const yearDiff = parseInt(bMatch[1]) - parseInt(aMatch[1]); + if (yearDiff !== 0) return yearDiff; + } + return b.localeCompare(a); + }); + + return ( +
+ {/* Header */} +
+

+ 🎯 + Mes Objectifs +

+

+ Suivez la progression de vos OKRs à travers toutes vos équipes +

+
+ + {okrs.length === 0 ? ( + +
🎯
+

Aucun OKR défini

+

+ Vous n'avez pas encore d'OKR défini. Contactez un administrateur d'équipe pour + en créer. +

+ + + Voir mes équipes + + +
+ ) : ( +
+ {periods.map((period) => { + const periodOKRs = okrsByPeriod[period]; + const totalProgress = + periodOKRs.reduce((sum, okr) => sum + (okr.progress || 0), 0) / periodOKRs.length; + + return ( +
+ {/* Period Header */} +
+
+ + {period} + + + {periodOKRs.length} OKR{periodOKRs.length !== 1 ? 's' : ''} + +
+
+ Progression moyenne: {Math.round(totalProgress)}% +
+
+ + {/* OKRs Grid */} +
+ {periodOKRs.map((okr) => { + const progress = okr.progress || 0; + const progressColor = + progress >= 75 ? '#10b981' : progress >= 25 ? '#f59e0b' : '#ef4444'; + + return ( + + + +
+ {okr.objective} + + {OKR_STATUS_LABELS[okr.status]} + +
+ {okr.description && ( +

{okr.description}

+ )} +
+ 👥 + {okr.team.name} +
+
+ + {/* Progress Bar */} +
+
+ Progression + + {progress}% + +
+
+
+
+
+ + {/* Key Results Preview */} + {okr.keyResults && okr.keyResults.length > 0 && ( +
+
+ Key Results ({okr.keyResults.length}) +
+
+ {okr.keyResults.slice(0, 3).map((kr) => { + const krProgress = + kr.targetValue > 0 ? (kr.currentValue / kr.targetValue) * 100 : 0; + const krProgressColor = + krProgress >= 100 + ? '#10b981' + : krProgress >= 50 + ? '#f59e0b' + : '#ef4444'; + + return ( +
+
+ + {kr.title} + + + {KEY_RESULT_STATUS_LABELS[kr.status]} + +
+
+ + {kr.currentValue} / {kr.targetValue} {kr.unit} + + + {Math.round(krProgress)}% + +
+
+
+
+
+ ); + })} + {okr.keyResults.length > 3 && ( +
+ +{okr.keyResults.length - 3} autre{okr.keyResults.length - 3 !== 1 ? 's' : ''} +
+ )} +
+
+ )} + + {/* Dates */} +
+ + {new Date(okr.startDate).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + })} + + + + {new Date(okr.endDate).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + +
+ + + + ); + })} +
+
+ ); + })} +
+ )} +
+ ); +} + diff --git a/src/app/page.tsx b/src/app/page.tsx index d95a762..2f572e2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -355,6 +355,114 @@ export default function Home() { + {/* OKRs Deep Dive Section */} +
+
+ 🎯 +
+

OKRs & Équipes

+

Définissez et suivez les objectifs de votre équipe

+
+
+ +
+ {/* Why */} +
+

+ 💡 + Pourquoi utiliser les OKRs ? +

+

+ 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. +

+
    +
  • + + Aligner les objectifs individuels avec ceux de l'équipe +
  • +
  • + + Suivre la progression en temps réel avec des métriques claires +
  • +
  • + + Favoriser la transparence et la visibilité des objectifs de chacun +
  • +
  • + + Créer une culture de responsabilisation et de résultats +
  • +
+
+ + {/* Features */} +
+

+ + Fonctionnalités principales +

+
+ + + + +
+
+ + {/* How it works */} +
+

+ ⚙️ + Comment ça marche ? +

+
+ + + + +
+
+
+
+ {/* Benefits Section */}

@@ -552,3 +660,30 @@ function CategoryPill({ ); } + +function FeaturePill({ + icon, + name, + color, + description, +}: { + icon: string; + name: string; + color: string; + description: string; +}) { + return ( +
+ {icon} +
+

+ {name} +

+

{description}

+
+
+ ); +} diff --git a/src/app/teams/[id]/okrs/[okrId]/page.tsx b/src/app/teams/[id]/okrs/[okrId]/page.tsx new file mode 100644 index 0000000..67f2eb5 --- /dev/null +++ b/src/app/teams/[id]/okrs/[okrId]/page.tsx @@ -0,0 +1,269 @@ +'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; + }; + }; +}; + +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(null); + const [loading, setLoading] = useState(true); + const [isAdmin, setIsAdmin] = useState(false); + const [isConcernedMember, setIsConcernedMember] = useState(false); + + 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); + // Check if current user is admin or the concerned member + // This will be properly checked server-side, but we set flags for UI + setIsAdmin(data.teamMember?.team?.id ? true : false); + setIsConcernedMember(data.teamMember?.userId ? true : false); + }) + .catch((error) => { + 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 ( +
+
Chargement...
+
+ ); + } + + if (!okr) { + return ( +
+
OKR non trouvé
+
+ ); + } + + const progress = okr.progress || 0; + const progressColor = + progress >= 75 ? 'var(--success)' : progress >= 25 ? 'var(--accent)' : 'var(--destructive)'; + const canEdit = isAdmin || isConcernedMember; + + return ( +
+
+ + ← Retour à l'équipe + +
+ + + +
+
+ + 🎯 + {okr.objective} + + {okr.description &&

{okr.description}

} + {okr.teamMember && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {okr.teamMember.user.name + + {okr.teamMember.user.name || okr.teamMember.user.email} + +
+ )} +
+
+ + {okr.period} + + + {OKR_STATUS_LABELS[okr.status]} + +
+
+
+ + {/* Progress Bar */} +
+
+ Progression globale + + {progress}% + +
+
+
+
+
+ + {/* Dates */} +
+
+ Début: {new Date(okr.startDate).toLocaleDateString('fr-FR')} +
+
+ Fin: {new Date(okr.endDate).toLocaleDateString('fr-FR')} +
+
+ + {/* Actions */} + {isAdmin && ( +
+ +
+ )} + + + + {/* Key Results */} +
+

+ Key Results ({okr.keyResults?.length || 0}) +

+
+ {okr.keyResults && okr.keyResults.length > 0 ? ( + okr.keyResults.map((kr) => ( + + )) + ) : ( + + Aucun Key Result défini + + )} +
+
+
+ ); +} + diff --git a/src/app/teams/[id]/okrs/new/page.tsx b/src/app/teams/[id]/okrs/new/page.tsx new file mode 100644 index 0000000..2e7a113 --- /dev/null +++ b/src/app/teams/[id]/okrs/new/page.tsx @@ -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([]); + 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 ( +
+
Chargement...
+
+ ); + } + + return ( +
+
+ + ← Retour à l'équipe + +
+ + +

Créer un OKR

+ router.push(`/teams/${teamId}`)} + /> +
+
+ ); +} + diff --git a/src/app/teams/[id]/page.tsx b/src/app/teams/[id]/page.tsx new file mode 100644 index 0000000..9db143b --- /dev/null +++ b/src/app/teams/[id]/page.tsx @@ -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 ( +
+ {/* Header */} +
+
+ + ← Retour aux équipes + +
+
+
+

+ 👥 + {team.name} +

+ {team.description &&

{team.description}

} +
+ {isAdmin && ( +
+ + + + +
+ )} +
+
+ + {/* Members Section */} + + + + + {/* OKRs Section */} + +
+ ); +} + diff --git a/src/app/teams/new/page.tsx b/src/app/teams/new/page.tsx new file mode 100644 index 0000000..2e50a76 --- /dev/null +++ b/src/app/teams/new/page.tsx @@ -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 ( +
+
+ + ← Retour aux équipes + +
+ + +

Créer une équipe

+
+ setName(e.target.value)} + placeholder="Ex: Équipe Produit" + required + /> +