feat: introduce Teams & OKRs feature with models, types, and UI components for team management and objective tracking
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 12m53s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 12m53s
This commit is contained in:
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: #8b5cf6;
|
||||
--accent-hover: #7c3aed;
|
||||
--purple: #8b5cf6;
|
||||
|
||||
/* Status */
|
||||
--success: #059669;
|
||||
@@ -103,6 +104,7 @@
|
||||
/* Accent Colors */
|
||||
--accent: #a78bfa;
|
||||
--accent-hover: #c4b5fd;
|
||||
--purple: #a78bfa;
|
||||
|
||||
/* Status (softened) */
|
||||
--success: #4ade80;
|
||||
|
||||
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>
|
||||
</section>
|
||||
|
||||
{/* OKRs Deep Dive Section */}
|
||||
<section className="mb-16">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<span className="text-4xl">🎯</span>
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-foreground">OKRs & Équipes</h2>
|
||||
<p className="text-purple-500 font-medium">Définissez et suivez les objectifs de votre équipe</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-2">
|
||||
{/* Why */}
|
||||
<div className="rounded-xl border border-border bg-card p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<span className="text-2xl">💡</span>
|
||||
Pourquoi utiliser les OKRs ?
|
||||
</h3>
|
||||
<p className="text-muted mb-4">
|
||||
Les OKRs (Objectives and Key Results) sont un cadre de gestion d'objectifs qui permet
|
||||
d'aligner les efforts de l'équipe autour d'objectifs communs et mesurables.
|
||||
Cette méthode favorise la transparence, la responsabilisation et la performance collective.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-muted">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-500">•</span>
|
||||
Aligner les objectifs individuels avec ceux de l'équipe
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-500">•</span>
|
||||
Suivre la progression en temps réel avec des métriques claires
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-500">•</span>
|
||||
Favoriser la transparence et la visibilité des objectifs de chacun
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-500">•</span>
|
||||
Créer une culture de responsabilisation et de résultats
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="rounded-xl border border-border bg-card p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<span className="text-2xl">✨</span>
|
||||
Fonctionnalités principales
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<FeaturePill
|
||||
icon="👥"
|
||||
name="Gestion d'équipes"
|
||||
color="#8b5cf6"
|
||||
description="Créez des équipes et gérez les membres avec des rôles admin/membre"
|
||||
/>
|
||||
<FeaturePill
|
||||
icon="🎯"
|
||||
name="OKRs par période"
|
||||
color="#3b82f6"
|
||||
description="Définissez des OKRs pour des trimestres ou périodes personnalisées"
|
||||
/>
|
||||
<FeaturePill
|
||||
icon="📊"
|
||||
name="Key Results mesurables"
|
||||
color="#10b981"
|
||||
description="Suivez la progression de chaque Key Result avec des valeurs et pourcentages"
|
||||
/>
|
||||
<FeaturePill
|
||||
icon="👁️"
|
||||
name="Visibilité transparente"
|
||||
color="#f59e0b"
|
||||
description="Tous les membres de l'équipe peuvent voir les OKRs de chacun"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How it works */}
|
||||
<div className="rounded-xl border border-border bg-card p-6 lg:col-span-2">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<span className="text-2xl">⚙️</span>
|
||||
Comment ça marche ?
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<StepCard
|
||||
number={1}
|
||||
title="Créer une équipe"
|
||||
description="Formez votre équipe et ajoutez les membres avec leurs rôles (admin ou membre)"
|
||||
/>
|
||||
<StepCard
|
||||
number={2}
|
||||
title="Définir les OKRs"
|
||||
description="Pour chaque membre, créez un Objectif avec plusieurs Key Results mesurables"
|
||||
/>
|
||||
<StepCard
|
||||
number={3}
|
||||
title="Suivre la progression"
|
||||
description="Mettez à jour régulièrement les valeurs des Key Results pour suivre l'avancement"
|
||||
/>
|
||||
<StepCard
|
||||
number={4}
|
||||
title="Visualiser et analyser"
|
||||
description="Consultez les OKRs par membre ou en grille, avec les progressions et statuts colorés"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benefits Section */}
|
||||
<section className="rounded-2xl border border-border bg-card p-8">
|
||||
<h2 className="mb-8 text-center text-2xl font-bold text-foreground">
|
||||
@@ -552,3 +660,30 @@ function CategoryPill({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturePill({
|
||||
icon,
|
||||
name,
|
||||
color,
|
||||
description,
|
||||
}: {
|
||||
icon: string;
|
||||
name: string;
|
||||
color: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-3 px-4 py-3 rounded-lg"
|
||||
style={{ backgroundColor: `${color}10`, border: `1px solid ${color}30` }}
|
||||
>
|
||||
<span className="text-xl">{icon}</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-sm mb-0.5" style={{ color }}>
|
||||
{name}
|
||||
</p>
|
||||
<p className="text-xs text-muted">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user