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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,5 @@
export { OKRCard } from './OKRCard';
export { OKRForm } from './OKRForm';
export { KeyResultItem } from './KeyResultItem';
export { OKRsList } from './OKRsList';