Compare commits
3 Commits
fd65e0d5b9
...
5f661c8bfd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f661c8bfd | ||
|
|
e3a47dd7e5 | ||
|
|
35b9ac8a66 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -43,4 +43,5 @@ next-env.d.ts
|
|||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
|
|
||||||
# data
|
# 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,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,9 @@ model User {
|
|||||||
yearReviewSessions YearReviewSession[]
|
yearReviewSessions YearReviewSession[]
|
||||||
sharedYearReviewSessions YRSessionShare[]
|
sharedYearReviewSessions YRSessionShare[]
|
||||||
yearReviewSessionEvents YRSessionEvent[]
|
yearReviewSessionEvents YRSessionEvent[]
|
||||||
|
// Teams & OKRs relations
|
||||||
|
createdTeams Team[]
|
||||||
|
teamMembers TeamMember[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
@@ -274,3 +277,91 @@ model YRSessionEvent {
|
|||||||
|
|
||||||
@@index([sessionId, createdAt])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
59
src/app/api/okrs/[id]/key-results/[krId]/route.ts
Normal file
59
src/app/api/okrs/[id]/key-results/[krId]/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/app/api/okrs/[id]/route.ts
Normal file
111
src/app/api/okrs/[id]/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/app/api/teams/[id]/members/route.ts
Normal file
107
src/app/api/teams/[id]/members/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
/* Accent Colors */
|
/* Accent Colors */
|
||||||
--accent: #8b5cf6;
|
--accent: #8b5cf6;
|
||||||
--accent-hover: #7c3aed;
|
--accent-hover: #7c3aed;
|
||||||
|
--purple: #8b5cf6;
|
||||||
|
|
||||||
/* Status */
|
/* Status */
|
||||||
--success: #059669;
|
--success: #059669;
|
||||||
@@ -103,6 +104,7 @@
|
|||||||
/* Accent Colors */
|
/* Accent Colors */
|
||||||
--accent: #a78bfa;
|
--accent: #a78bfa;
|
||||||
--accent-hover: #c4b5fd;
|
--accent-hover: #c4b5fd;
|
||||||
|
--purple: #a78bfa;
|
||||||
|
|
||||||
/* Status (softened) */
|
/* Status (softened) */
|
||||||
--success: #4ade80;
|
--success: #4ade80;
|
||||||
|
|||||||
301
src/app/objectives/page.tsx
Normal file
301
src/app/objectives/page.tsx
Normal file
@@ -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<string, typeof okrs>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{periods.map((period) => {
|
||||||
|
const periodOKRs = okrsByPeriod[period];
|
||||||
|
const totalProgress =
|
||||||
|
periodOKRs.reduce((sum, okr) => sum + (okr.progress || 0), 0) / periodOKRs.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={period} className="space-y-4">
|
||||||
|
{/* Period Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
|
||||||
|
color: 'var(--purple)',
|
||||||
|
fontSize: '14px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{period}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm text-muted">
|
||||||
|
{periodOKRs.length} OKR{periodOKRs.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-foreground">
|
||||||
|
Progression moyenne: <span style={{ color: 'var(--primary)' }}>{Math.round(totalProgress)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OKRs Grid */}
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{periodOKRs.map((okr) => {
|
||||||
|
const progress = okr.progress || 0;
|
||||||
|
const progressColor =
|
||||||
|
progress >= 75 ? '#10b981' : progress >= 25 ? '#f59e0b' : '#ef4444';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link key={okr.id} href={`/teams/${okr.team.id}/okrs/${okr.id}`}>
|
||||||
|
<Card hover className="h-full flex flex-col">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<CardTitle className="text-lg flex-1 line-clamp-2">{okr.objective}</CardTitle>
|
||||||
|
<Badge style={getOKRStatusColor(okr.status)}>
|
||||||
|
{OKR_STATUS_LABELS[okr.status]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{okr.description && (
|
||||||
|
<p className="text-sm text-muted line-clamp-2">{okr.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs text-muted">
|
||||||
|
<span>👥</span>
|
||||||
|
<span>{okr.team.name}</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex flex-col">
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="mb-1 flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted">Progression</span>
|
||||||
|
<span className="font-medium" style={{ color: progressColor }}>
|
||||||
|
{progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-card-column">
|
||||||
|
<div
|
||||||
|
className="h-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${progress}%`,
|
||||||
|
backgroundColor: progressColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Results Preview */}
|
||||||
|
{okr.keyResults && okr.keyResults.length > 0 && (
|
||||||
|
<div className="mt-auto space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted uppercase tracking-wide">
|
||||||
|
Key Results ({okr.keyResults.length})
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{okr.keyResults.slice(0, 3).map((kr) => {
|
||||||
|
const krProgress =
|
||||||
|
kr.targetValue > 0 ? (kr.currentValue / kr.targetValue) * 100 : 0;
|
||||||
|
const krProgressColor =
|
||||||
|
krProgress >= 100
|
||||||
|
? '#10b981'
|
||||||
|
: krProgress >= 50
|
||||||
|
? '#f59e0b'
|
||||||
|
: '#ef4444';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={kr.id} className="space-y-1">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="text-xs text-foreground flex-1 line-clamp-1">
|
||||||
|
{kr.title}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
style={{
|
||||||
|
...getKeyResultStatusColor(kr.status),
|
||||||
|
fontSize: '9px',
|
||||||
|
padding: '1px 4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{KEY_RESULT_STATUS_LABELS[kr.status]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted">
|
||||||
|
<span>
|
||||||
|
{kr.currentValue} / {kr.targetValue} {kr.unit}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium" style={{ color: krProgressColor }}>
|
||||||
|
{Math.round(krProgress)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 w-full overflow-hidden rounded-full bg-card-column">
|
||||||
|
<div
|
||||||
|
className="h-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(krProgress, 100)}%`,
|
||||||
|
backgroundColor: krProgressColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{okr.keyResults.length > 3 && (
|
||||||
|
<div className="text-xs text-muted text-center pt-1">
|
||||||
|
+{okr.keyResults.length - 3} autre{okr.keyResults.length - 3 !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dates */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-border flex items-center justify-between text-xs text-muted">
|
||||||
|
<span>
|
||||||
|
{new Date(okr.startDate).toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span>→</span>
|
||||||
|
<span>
|
||||||
|
{new Date(okr.endDate).toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
135
src/app/page.tsx
135
src/app/page.tsx
@@ -355,6 +355,114 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 */}
|
{/* Benefits Section */}
|
||||||
<section className="rounded-2xl border border-border bg-card p-8">
|
<section className="rounded-2xl border border-border bg-card p-8">
|
||||||
<h2 className="mb-8 text-center text-2xl font-bold text-foreground">
|
<h2 className="mb-8 text-center text-2xl font-bold text-foreground">
|
||||||
@@ -552,3 +660,30 @@ function CategoryPill({
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
269
src/app/teams/[id]/okrs/[okrId]/page.tsx
Normal file
269
src/app/teams/[id]/okrs/[okrId]/page.tsx
Normal file
@@ -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<OKRWithTeamMember | null>(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 (
|
||||||
|
<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 = isAdmin || isConcernedMember;
|
||||||
|
|
||||||
|
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 */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<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} />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,6 +39,26 @@ export function Header() {
|
|||||||
Mes Ateliers
|
Mes Ateliers
|
||||||
</Link>
|
</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 */}
|
{/* Workshops Dropdown */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
209
src/components/okrs/OKRCard.tsx
Normal file
209
src/components/okrs/OKRCard.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OKRCard({ okr, teamId }: OKRCardProps) {
|
||||||
|
const progress = okr.progress || 0;
|
||||||
|
const progressColor =
|
||||||
|
progress >= 75 ? 'var(--success)' : progress >= 25 ? 'var(--accent)' : 'var(--destructive)';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/teams/${teamId}/okrs/${okr.id}`}>
|
||||||
|
<Card hover className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<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>
|
||||||
|
<Badge
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
|
||||||
|
color: 'var(--purple)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{okr.period}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<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>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
340
src/components/okrs/OKRForm.tsx
Normal file
340
src/components/okrs/OKRForm.tsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
'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 } 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 OKRFormProps {
|
||||||
|
teamMembers: TeamMember[];
|
||||||
|
onSubmit: (data: CreateOKRInput) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
initialData?: Partial<CreateOKRInput>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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] : ''
|
||||||
|
);
|
||||||
|
const [keyResults, setKeyResults] = useState<CreateKeyResultInput[]>(
|
||||||
|
initialData?.keyResults || [
|
||||||
|
{ 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 CreateKeyResultInput, value: any) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubmit({
|
||||||
|
teamMemberId,
|
||||||
|
objective,
|
||||||
|
description: description || undefined,
|
||||||
|
period: finalPeriod,
|
||||||
|
startDate: startDateObj.toISOString() as any,
|
||||||
|
endDate: endDateObj.toISOString() as any,
|
||||||
|
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={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 ? 'Création...' : 'Créer l\'OKR'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
143
src/components/okrs/OKRsList.tsx
Normal file
143
src/components/okrs/OKRsList.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { OKRCard } from './OKRCard';
|
||||||
|
import { Card, ToggleGroup, type ToggleOption } from '@/components/ui';
|
||||||
|
import { getGravatarUrl } from '@/lib/gravatar';
|
||||||
|
import type { OKR } from '@/lib/types';
|
||||||
|
|
||||||
|
type ViewMode = 'grid' | 'grouped';
|
||||||
|
|
||||||
|
interface OKRsListProps {
|
||||||
|
okrsData: Array<{
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
};
|
||||||
|
okrs: Array<OKR & { progress?: number }>;
|
||||||
|
}>;
|
||||||
|
teamId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OKRsList({ okrsData, teamId }: OKRsListProps) {
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('grouped');
|
||||||
|
|
||||||
|
// Flatten OKRs for grid view
|
||||||
|
const allOKRs = okrsData.flatMap((tm) =>
|
||||||
|
tm.okrs.map((okr) => ({
|
||||||
|
...okr,
|
||||||
|
teamMember: {
|
||||||
|
user: tm.user,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allOKRs.length === 0) {
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
Aucun OKR n'a encore été défini pour cette équipe
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* View Toggle */}
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">OKRs</h2>
|
||||||
|
<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="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Grouped View */}
|
||||||
|
{viewMode === 'grouped' ? (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{okrsData
|
||||||
|
.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 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{teamMember.okrs.map((okr) => (
|
||||||
|
<OKRCard
|
||||||
|
key={okr.id}
|
||||||
|
okr={{
|
||||||
|
...okr,
|
||||||
|
teamMember: {
|
||||||
|
user: teamMember.user,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
teamId={teamId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Grid View */
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{allOKRs.map((okr) => (
|
||||||
|
<OKRCard key={okr.id} okr={okr} teamId={teamId} />
|
||||||
|
))}
|
||||||
|
</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';
|
||||||
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
72
src/components/teams/DeleteTeamButton.tsx
Normal file
72
src/components/teams/DeleteTeamButton.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
'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 { MembersList } from './MembersList';
|
||||||
|
export { AddMemberModal } from './AddMemberModal';
|
||||||
|
export { DeleteTeamButton } from './DeleteTeamButton';
|
||||||
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -9,4 +9,7 @@ export { EditableMotivatorTitle } from './EditableMotivatorTitle';
|
|||||||
export { EditableYearReviewTitle } from './EditableYearReviewTitle';
|
export { EditableYearReviewTitle } from './EditableYearReviewTitle';
|
||||||
export { Input } from './Input';
|
export { Input } from './Input';
|
||||||
export { Modal, ModalFooter } from './Modal';
|
export { Modal, ModalFooter } from './Modal';
|
||||||
|
export { Select } from './Select';
|
||||||
export { Textarea } from './Textarea';
|
export { Textarea } from './Textarea';
|
||||||
|
export { ToggleGroup } from './ToggleGroup';
|
||||||
|
export type { ToggleOption } from './ToggleGroup';
|
||||||
|
|||||||
156
src/lib/types.ts
156
src/lib/types.ts
@@ -415,3 +415,159 @@ export const YEAR_REVIEW_BY_CATEGORY: Record<
|
|||||||
},
|
},
|
||||||
{} as Record<YearReviewCategory, YearReviewSectionConfig>
|
{} 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();
|
||||||
|
|||||||
308
src/services/okrs.ts
Normal file
308
src/services/okrs.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { prisma } from '@/services/database';
|
||||||
|
import type { CreateOKRInput, UpdateOKRInput, UpdateKeyResultInput, 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 updateOKR(okrId: string, data: UpdateOKRInput) {
|
||||||
|
const okr = await prisma.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 }),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
keyResults: {
|
||||||
|
orderBy: {
|
||||||
|
order: 'asc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...okr,
|
||||||
|
progress: calculateOKRProgressFromKeyResults(okr.keyResults),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOKR(okrId: string) {
|
||||||
|
// Cascade delete will handle key results
|
||||||
|
return prisma.oKR.delete({
|
||||||
|
where: { id: okrId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { CreateTeamInput, UpdateTeamInput, AddTeamMemberInput, UpdateMemberRoleInput, 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user