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

This commit is contained in:
Julien Froidefond
2026-01-07 10:11:59 +01:00
parent e3a47dd7e5
commit 5f661c8bfd
35 changed files with 3993 additions and 0 deletions

308
src/services/okrs.ts Normal file
View 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
View 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,
},
},
},
});
}