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

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

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

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