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:
@@ -39,6 +39,26 @@ export function Header() {
|
||||
Mes Ateliers
|
||||
</Link>
|
||||
|
||||
{/* Objectives Link */}
|
||||
<Link
|
||||
href="/objectives"
|
||||
className={`text-sm font-medium transition-colors ${
|
||||
isActiveLink('/objectives') ? 'text-primary' : 'text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
🎯 Mes Objectifs
|
||||
</Link>
|
||||
|
||||
{/* Teams Link */}
|
||||
<Link
|
||||
href="/teams"
|
||||
className={`text-sm font-medium transition-colors ${
|
||||
isActiveLink('/teams') ? 'text-primary' : 'text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
👥 Équipes
|
||||
</Link>
|
||||
|
||||
{/* Workshops Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
|
||||
186
src/components/okrs/KeyResultItem.tsx
Normal file
186
src/components/okrs/KeyResultItem.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Input } from '@/components/ui';
|
||||
import { Textarea } from '@/components/ui';
|
||||
import { Button } from '@/components/ui';
|
||||
import { Badge } from '@/components/ui';
|
||||
import type { KeyResult, KeyResultStatus } from '@/lib/types';
|
||||
import { KEY_RESULT_STATUS_LABELS } from '@/lib/types';
|
||||
|
||||
// Helper function for Key Result status colors
|
||||
function getKeyResultStatusColor(status: KeyResultStatus): { bg: string; color: string } {
|
||||
switch (status) {
|
||||
case 'NOT_STARTED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #6b7280 15%, transparent)', // gray-500
|
||||
color: '#6b7280',
|
||||
};
|
||||
case 'IN_PROGRESS':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #3b82f6 15%, transparent)', // blue-500
|
||||
color: '#3b82f6',
|
||||
};
|
||||
case 'COMPLETED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #10b981 15%, transparent)', // green-500
|
||||
color: '#10b981',
|
||||
};
|
||||
case 'AT_RISK':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #f59e0b 15%, transparent)', // amber-500 (orange/yellow)
|
||||
color: '#f59e0b',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #6b7280 15%, transparent)',
|
||||
color: '#6b7280',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface KeyResultItemProps {
|
||||
keyResult: KeyResult;
|
||||
okrId: string;
|
||||
canEdit: boolean;
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
export function KeyResultItem({ keyResult, okrId, canEdit, onUpdate }: KeyResultItemProps) {
|
||||
const [currentValue, setCurrentValue] = useState(keyResult.currentValue);
|
||||
const [notes, setNotes] = useState(keyResult.notes || '');
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const progress = keyResult.targetValue > 0 ? (currentValue / keyResult.targetValue) * 100 : 0;
|
||||
const progressColor =
|
||||
progress >= 100 ? 'var(--success)' : progress >= 50 ? 'var(--accent)' : 'var(--destructive)';
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setUpdating(true);
|
||||
try {
|
||||
const response = await fetch(`/api/okrs/${okrId}/key-results/${keyResult.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
currentValue: Number(currentValue),
|
||||
notes: notes || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Erreur lors de la mise à jour');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEditing(false);
|
||||
onUpdate?.();
|
||||
} catch (error) {
|
||||
console.error('Error updating key result:', error);
|
||||
alert('Erreur lors de la mise à jour');
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<div className="mb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<h4 className="font-medium text-foreground">{keyResult.title}</h4>
|
||||
<Badge style={getKeyResultStatusColor(keyResult.status)}>
|
||||
{KEY_RESULT_STATUS_LABELS[keyResult.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 flex items-center justify-between text-sm">
|
||||
<span className="text-muted">
|
||||
{currentValue} / {keyResult.targetValue} {keyResult.unit}
|
||||
</span>
|
||||
<span className="font-medium" style={{ color: progressColor }}>
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-card-column">
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min(progress, 100)}%`,
|
||||
backgroundColor: progressColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Form */}
|
||||
{canEdit && (
|
||||
<div className="space-y-3 border-t border-border pt-3">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
Valeur actuelle
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={currentValue}
|
||||
onChange={(e) => setCurrentValue(Number(e.target.value))}
|
||||
min={0}
|
||||
max={keyResult.targetValue * 2}
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Notes</label>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Ajouter des notes..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleUpdate} disabled={updating} size="sm">
|
||||
{updating ? 'Mise à jour...' : 'Enregistrer'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setCurrentValue(keyResult.currentValue);
|
||||
setNotes(keyResult.notes || '');
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
{keyResult.notes && (
|
||||
<div className="mb-2 text-sm text-muted">
|
||||
<strong>Notes:</strong> {keyResult.notes}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={() => setIsEditing(true)} variant="outline" size="sm">
|
||||
Mettre à jour la progression
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canEdit && keyResult.notes && (
|
||||
<div className="mt-3 border-t border-border pt-3 text-sm text-muted">
|
||||
<strong>Notes:</strong> {keyResult.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
209
src/components/okrs/OKRCard.tsx
Normal file
209
src/components/okrs/OKRCard.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
|
||||
import { Badge } from '@/components/ui';
|
||||
import { getGravatarUrl } from '@/lib/gravatar';
|
||||
import type { OKR, KeyResult, OKRStatus, KeyResultStatus } from '@/lib/types';
|
||||
import { OKR_STATUS_LABELS, KEY_RESULT_STATUS_LABELS } from '@/lib/types';
|
||||
|
||||
// Helper functions for status colors
|
||||
function getOKRStatusColor(status: OKRStatus): { bg: string; color: string } {
|
||||
switch (status) {
|
||||
case 'NOT_STARTED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #6b7280 15%, transparent)', // gray-500
|
||||
color: '#6b7280',
|
||||
};
|
||||
case 'IN_PROGRESS':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #3b82f6 15%, transparent)', // blue-500
|
||||
color: '#3b82f6',
|
||||
};
|
||||
case 'COMPLETED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #10b981 15%, transparent)', // green-500 (success)
|
||||
color: '#10b981',
|
||||
};
|
||||
case 'CANCELLED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #ef4444 15%, transparent)', // red-500 (destructive)
|
||||
color: '#ef4444',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #6b7280 15%, transparent)',
|
||||
color: '#6b7280',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getKeyResultStatusColor(status: KeyResultStatus): { bg: string; color: string } {
|
||||
switch (status) {
|
||||
case 'NOT_STARTED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #6b7280 12%, transparent)', // gray-500
|
||||
color: '#6b7280',
|
||||
};
|
||||
case 'IN_PROGRESS':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #3b82f6 12%, transparent)', // blue-500
|
||||
color: '#3b82f6',
|
||||
};
|
||||
case 'COMPLETED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #10b981 12%, transparent)', // green-500
|
||||
color: '#10b981',
|
||||
};
|
||||
case 'AT_RISK':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #f59e0b 12%, transparent)', // amber-500 (orange/yellow)
|
||||
color: '#f59e0b',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #6b7280 12%, transparent)',
|
||||
color: '#6b7280',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface OKRCardProps {
|
||||
okr: OKR & { teamMember?: { user: { id: string; email: string; name: string | null } } };
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
export function OKRCard({ okr, teamId }: OKRCardProps) {
|
||||
const progress = okr.progress || 0;
|
||||
const progressColor =
|
||||
progress >= 75 ? 'var(--success)' : progress >= 25 ? 'var(--accent)' : 'var(--destructive)';
|
||||
|
||||
return (
|
||||
<Link href={`/teams/${teamId}/okrs/${okr.id}`}>
|
||||
<Card hover className="h-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-xl">🎯</span>
|
||||
{okr.objective}
|
||||
</CardTitle>
|
||||
{okr.teamMember && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getGravatarUrl(okr.teamMember.user.email, 96)}
|
||||
alt={okr.teamMember.user.name || okr.teamMember.user.email}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<span className="text-sm text-muted">
|
||||
{okr.teamMember.user.name || okr.teamMember.user.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
|
||||
color: 'var(--purple)',
|
||||
}}
|
||||
>
|
||||
{okr.period}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Progress Bar */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between text-sm">
|
||||
<span className="text-muted">Progression</span>
|
||||
<span className="font-medium" style={{ color: progressColor }}>
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-card-column">
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
backgroundColor: progressColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted">Statut:</span>
|
||||
<Badge style={getOKRStatusColor(okr.status)}>
|
||||
{OKR_STATUS_LABELS[okr.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Key Results List */}
|
||||
{okr.keyResults && okr.keyResults.length > 0 && (
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
Key Results ({okr.keyResults.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{okr.keyResults
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((kr: KeyResult) => {
|
||||
const krProgress = kr.targetValue > 0 ? (kr.currentValue / kr.targetValue) * 100 : 0;
|
||||
const krProgressColor =
|
||||
krProgress >= 100
|
||||
? 'var(--success)'
|
||||
: krProgress >= 50
|
||||
? 'var(--accent)'
|
||||
: 'var(--destructive)';
|
||||
|
||||
return (
|
||||
<div key={kr.id} className="space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-sm text-foreground flex-1 line-clamp-2">{kr.title}</span>
|
||||
<Badge
|
||||
style={{
|
||||
...getKeyResultStatusColor(kr.status),
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
}}
|
||||
>
|
||||
{KEY_RESULT_STATUS_LABELS[kr.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-muted">
|
||||
<span>
|
||||
{kr.currentValue} / {kr.targetValue} {kr.unit}
|
||||
</span>
|
||||
<span className="font-medium" style={{ color: krProgressColor }}>
|
||||
{Math.round(krProgress)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-card-column">
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min(krProgress, 100)}%`,
|
||||
backgroundColor: krProgressColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
340
src/components/okrs/OKRForm.tsx
Normal file
340
src/components/okrs/OKRForm.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Input } from '@/components/ui';
|
||||
import { Textarea } from '@/components/ui';
|
||||
import { Button } from '@/components/ui';
|
||||
import { Select } from '@/components/ui';
|
||||
import type { CreateOKRInput, CreateKeyResultInput, TeamMember } from '@/lib/types';
|
||||
import { PERIOD_SUGGESTIONS } from '@/lib/types';
|
||||
|
||||
// Calcule les dates de début et de fin pour un trimestre donné
|
||||
function getQuarterDates(period: string): { startDate: string; endDate: string } | null {
|
||||
// Format attendu: "Q1 2025", "Q2 2026", etc.
|
||||
const match = period.match(/^Q(\d)\s+(\d{4})$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const quarter = parseInt(match[1], 10);
|
||||
const year = parseInt(match[2], 10);
|
||||
|
||||
let startMonth = 0; // Janvier = 0
|
||||
let endMonth = 2; // Mars = 2
|
||||
let endDay = 31;
|
||||
|
||||
switch (quarter) {
|
||||
case 1:
|
||||
startMonth = 0; // Janvier
|
||||
endMonth = 2; // Mars
|
||||
endDay = 31;
|
||||
break;
|
||||
case 2:
|
||||
startMonth = 3; // Avril
|
||||
endMonth = 5; // Juin
|
||||
endDay = 30;
|
||||
break;
|
||||
case 3:
|
||||
startMonth = 6; // Juillet
|
||||
endMonth = 8; // Septembre
|
||||
endDay = 30;
|
||||
break;
|
||||
case 4:
|
||||
startMonth = 9; // Octobre
|
||||
endMonth = 11; // Décembre
|
||||
endDay = 31;
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const startDate = new Date(year, startMonth, 1);
|
||||
const endDate = new Date(year, endMonth, endDay);
|
||||
|
||||
return {
|
||||
startDate: startDate.toISOString().split('T')[0],
|
||||
endDate: endDate.toISOString().split('T')[0],
|
||||
};
|
||||
}
|
||||
|
||||
interface OKRFormProps {
|
||||
teamMembers: TeamMember[];
|
||||
onSubmit: (data: CreateOKRInput) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
initialData?: Partial<CreateOKRInput>;
|
||||
}
|
||||
|
||||
export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFormProps) {
|
||||
const [teamMemberId, setTeamMemberId] = useState(initialData?.teamMemberId || '');
|
||||
const [objective, setObjective] = useState(initialData?.objective || '');
|
||||
const [description, setDescription] = useState(initialData?.description || '');
|
||||
const [period, setPeriod] = useState(initialData?.period || '');
|
||||
const [customPeriod, setCustomPeriod] = useState('');
|
||||
const [startDate, setStartDate] = useState(
|
||||
initialData?.startDate ? new Date(initialData.startDate).toISOString().split('T')[0] : ''
|
||||
);
|
||||
const [endDate, setEndDate] = useState(
|
||||
initialData?.endDate ? new Date(initialData.endDate).toISOString().split('T')[0] : ''
|
||||
);
|
||||
const [keyResults, setKeyResults] = useState<CreateKeyResultInput[]>(
|
||||
initialData?.keyResults || [
|
||||
{ title: '', targetValue: 100, unit: '%', order: 0 },
|
||||
]
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Mise à jour automatique des dates quand la période change
|
||||
useEffect(() => {
|
||||
if (period && period !== 'custom' && period !== '') {
|
||||
const dates = getQuarterDates(period);
|
||||
if (dates) {
|
||||
setStartDate(dates.startDate);
|
||||
setEndDate(dates.endDate);
|
||||
}
|
||||
}
|
||||
}, [period]);
|
||||
|
||||
const addKeyResult = () => {
|
||||
if (keyResults.length >= 5) {
|
||||
alert('Maximum 5 Key Results autorisés');
|
||||
return;
|
||||
}
|
||||
setKeyResults([
|
||||
...keyResults,
|
||||
{ title: '', targetValue: 100, unit: '%', order: keyResults.length },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeKeyResult = (index: number) => {
|
||||
if (keyResults.length <= 1) {
|
||||
alert('Au moins un Key Result est requis');
|
||||
return;
|
||||
}
|
||||
setKeyResults(keyResults.filter((_, i) => i !== index).map((kr, i) => ({ ...kr, order: i })));
|
||||
};
|
||||
|
||||
const updateKeyResult = (index: number, field: keyof CreateKeyResultInput, value: any) => {
|
||||
const updated = [...keyResults];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
setKeyResults(updated);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!teamMemberId || !objective || !period || !startDate || !endDate) {
|
||||
alert('Veuillez remplir tous les champs requis');
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyResults.some((kr) => !kr.title || kr.targetValue <= 0)) {
|
||||
alert('Tous les Key Results doivent avoir un titre et une valeur cible > 0');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalPeriod = period === 'custom' ? customPeriod : period;
|
||||
if (!finalPeriod) {
|
||||
alert('Veuillez spécifier une période');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Convert dates to ISO strings for JSON serialization
|
||||
const startDateObj = new Date(startDate);
|
||||
const endDateObj = new Date(endDate);
|
||||
|
||||
if (isNaN(startDateObj.getTime()) || isNaN(endDateObj.getTime())) {
|
||||
alert('Dates invalides');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await onSubmit({
|
||||
teamMemberId,
|
||||
objective,
|
||||
description: description || undefined,
|
||||
period: finalPeriod,
|
||||
startDate: startDateObj.toISOString() as any,
|
||||
endDate: endDateObj.toISOString() as any,
|
||||
keyResults: keyResults.map((kr, i) => ({ ...kr, order: i })),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error submitting OKR:', error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Team Member */}
|
||||
<Select
|
||||
label="Membre de l'équipe *"
|
||||
value={teamMemberId}
|
||||
onChange={(e) => setTeamMemberId(e.target.value)}
|
||||
options={teamMembers.map((member) => ({
|
||||
value: member.id,
|
||||
label: member.user.name || member.user.email,
|
||||
}))}
|
||||
placeholder="Sélectionner un membre"
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Objective */}
|
||||
<div>
|
||||
<Input
|
||||
label="Objective *"
|
||||
value={objective}
|
||||
onChange={(e) => setObjective(e.target.value)}
|
||||
placeholder="Ex: Améliorer la qualité du code"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<Textarea
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Description détaillée de l'objectif..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Period */}
|
||||
<div>
|
||||
<Select
|
||||
label="Période *"
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value)}
|
||||
options={[
|
||||
...PERIOD_SUGGESTIONS.map((p) => ({
|
||||
value: p,
|
||||
label: p,
|
||||
})),
|
||||
{
|
||||
value: 'custom',
|
||||
label: 'Personnalisée',
|
||||
},
|
||||
]}
|
||||
placeholder="Sélectionner une période"
|
||||
required
|
||||
/>
|
||||
{period === 'custom' && (
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
value={customPeriod}
|
||||
onChange={(e) => setCustomPeriod(e.target.value)}
|
||||
placeholder="Ex: Q1-Q2 2025"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Input
|
||||
label="Date de début *"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label="Date de fin *"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{period && period !== 'custom' && period !== '' && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Les dates sont automatiquement définies selon le trimestre sélectionné. Vous pouvez les modifier si nécessaire.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Key Results */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Key Results * ({keyResults.length}/5)
|
||||
</label>
|
||||
<Button type="button" onClick={addKeyResult} variant="outline" size="sm" disabled={keyResults.length >= 5}>
|
||||
+ Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{keyResults.map((kr, index) => (
|
||||
<div key={index} className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">Key Result {index + 1}</span>
|
||||
{keyResults.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => removeKeyResult(index)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
style={{
|
||||
color: 'var(--destructive)',
|
||||
borderColor: 'var(--destructive)',
|
||||
}}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder="Titre du Key Result"
|
||||
value={kr.title}
|
||||
onChange={(e) => updateKeyResult(index, 'title', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Valeur cible"
|
||||
value={kr.targetValue}
|
||||
onChange={(e) => updateKeyResult(index, 'targetValue', Number(e.target.value))}
|
||||
min={0}
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
placeholder="Unité (%)"
|
||||
value={kr.unit}
|
||||
onChange={(e) => updateKeyResult(index, 'unit', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" onClick={onCancel} variant="outline">
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"
|
||||
>
|
||||
{submitting ? 'Création...' : 'Créer l\'OKR'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
143
src/components/okrs/OKRsList.tsx
Normal file
143
src/components/okrs/OKRsList.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { OKRCard } from './OKRCard';
|
||||
import { Card, ToggleGroup, type ToggleOption } from '@/components/ui';
|
||||
import { getGravatarUrl } from '@/lib/gravatar';
|
||||
import type { OKR } from '@/lib/types';
|
||||
|
||||
type ViewMode = 'grid' | 'grouped';
|
||||
|
||||
interface OKRsListProps {
|
||||
okrsData: Array<{
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
};
|
||||
okrs: Array<OKR & { progress?: number }>;
|
||||
}>;
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
export function OKRsList({ okrsData, teamId }: OKRsListProps) {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grouped');
|
||||
|
||||
// Flatten OKRs for grid view
|
||||
const allOKRs = okrsData.flatMap((tm) =>
|
||||
tm.okrs.map((okr) => ({
|
||||
...okr,
|
||||
teamMember: {
|
||||
user: tm.user,
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
if (allOKRs.length === 0) {
|
||||
return (
|
||||
<Card className="p-12 text-center">
|
||||
<div className="text-5xl mb-4">🎯</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">Aucun OKR défini</h3>
|
||||
<p className="text-muted">
|
||||
Aucun OKR n'a encore été défini pour cette équipe
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* View Toggle */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-foreground">OKRs</h2>
|
||||
<ToggleGroup
|
||||
value={viewMode}
|
||||
onChange={setViewMode}
|
||||
options={[
|
||||
{
|
||||
value: 'grouped',
|
||||
label: 'Par membre',
|
||||
icon: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'grid',
|
||||
label: 'Grille',
|
||||
icon: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grouped View */}
|
||||
{viewMode === 'grouped' ? (
|
||||
<div className="space-y-8">
|
||||
{okrsData
|
||||
.filter((tm) => tm.okrs.length > 0)
|
||||
.map((teamMember) => (
|
||||
<div key={teamMember.user.id}>
|
||||
{/* Member Header */}
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getGravatarUrl(teamMember.user.email, 96)}
|
||||
alt={teamMember.user.name || teamMember.user.email}
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-full border-2 border-border"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{teamMember.user.name || 'Sans nom'}
|
||||
</h3>
|
||||
<p className="text-sm text-muted">{teamMember.user.email}</p>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<span className="text-sm text-muted">
|
||||
{teamMember.okrs.length} OKR{teamMember.okrs.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OKRs Grid */}
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{teamMember.okrs.map((okr) => (
|
||||
<OKRCard
|
||||
key={okr.id}
|
||||
okr={{
|
||||
...okr,
|
||||
teamMember: {
|
||||
user: teamMember.user,
|
||||
},
|
||||
}}
|
||||
teamId={teamId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Grid View */
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{allOKRs.map((okr) => (
|
||||
<OKRCard key={okr.id} okr={okr} teamId={teamId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
5
src/components/okrs/index.ts
Normal file
5
src/components/okrs/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { OKRCard } from './OKRCard';
|
||||
export { OKRForm } from './OKRForm';
|
||||
export { KeyResultItem } from './KeyResultItem';
|
||||
export { OKRsList } from './OKRsList';
|
||||
|
||||
160
src/components/teams/AddMemberModal.tsx
Normal file
160
src/components/teams/AddMemberModal.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal, ModalFooter } from '@/components/ui';
|
||||
import { Button } from '@/components/ui';
|
||||
import { Input } from '@/components/ui';
|
||||
import { Select } from '@/components/ui';
|
||||
import type { TeamRole } from '@/lib/types';
|
||||
import { TEAM_ROLE_LABELS } from '@/lib/types';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
interface AddMemberModalProps {
|
||||
teamId: string;
|
||||
existingMemberIds: string[];
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function AddMemberModal({ teamId, existingMemberIds, onClose, onSuccess }: AddMemberModalProps) {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [role, setRole] = useState<TeamRole>('MEMBER');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetchingUsers, setFetchingUsers] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch all users
|
||||
setFetchingUsers(true);
|
||||
fetch('/api/users')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
// Filter out existing members
|
||||
const availableUsers = data.filter((user: User) => !existingMemberIds.includes(user.id));
|
||||
setUsers(availableUsers);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching users:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
setFetchingUsers(false);
|
||||
});
|
||||
}, [existingMemberIds]);
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(user) =>
|
||||
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedUserId) {
|
||||
alert('Veuillez sélectionner un utilisateur');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/teams/${teamId}/members`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: selectedUserId, role }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Erreur lors de l\'ajout du membre');
|
||||
return;
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error adding member:', error);
|
||||
alert('Erreur lors de l\'ajout du membre');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={true} onClose={onClose} title="Ajouter un membre" size="md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
{/* User Search */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Rechercher un utilisateur
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Email ou nom..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
disabled={fetchingUsers}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User List */}
|
||||
{fetchingUsers ? (
|
||||
<div className="text-center py-4 text-muted">Chargement...</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted">
|
||||
{searchTerm ? 'Aucun utilisateur trouvé' : 'Aucun utilisateur disponible'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-60 overflow-y-auto border border-border rounded-lg">
|
||||
{filteredUsers.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedUserId(user.id)}
|
||||
className={`
|
||||
w-full text-left px-4 py-3 hover:bg-card-hover transition-colors
|
||||
${selectedUserId === user.id ? 'bg-primary/10 border-l-2 border-primary' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="font-medium text-foreground">{user.name || 'Sans nom'}</div>
|
||||
<div className="text-sm text-muted">{user.email}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Role Selection */}
|
||||
<Select
|
||||
label="Rôle"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as TeamRole)}
|
||||
options={[
|
||||
{ value: 'MEMBER', label: TEAM_ROLE_LABELS.MEMBER },
|
||||
{ value: 'ADMIN', label: TEAM_ROLE_LABELS.ADMIN },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!selectedUserId || loading}
|
||||
className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"
|
||||
>
|
||||
{loading ? 'Ajout...' : 'Ajouter'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
72
src/components/teams/DeleteTeamButton.tsx
Normal file
72
src/components/teams/DeleteTeamButton.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui';
|
||||
import { Modal, ModalFooter } from '@/components/ui';
|
||||
|
||||
interface DeleteTeamButtonProps {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
}
|
||||
|
||||
export function DeleteTeamButton({ teamId, teamName }: DeleteTeamButtonProps) {
|
||||
const router = useRouter();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleDelete = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/teams/${teamId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Erreur lors de la suppression de l\'équipe');
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/teams');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error('Error deleting team:', error);
|
||||
alert('Erreur lors de la suppression de l\'équipe');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setShowModal(true)}
|
||||
variant="outline"
|
||||
className="text-destructive border-destructive hover:bg-destructive/10"
|
||||
>
|
||||
Supprimer l'équipe
|
||||
</Button>
|
||||
|
||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Supprimer l'équipe" size="sm">
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted">
|
||||
Êtes-vous sûr de vouloir supprimer l'équipe{' '}
|
||||
<strong className="text-foreground">"{teamName}"</strong> ?
|
||||
</p>
|
||||
<p className="text-sm text-destructive">
|
||||
Cette action est irréversible. Tous les membres, OKRs et données associées seront supprimés.
|
||||
</p>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" onClick={() => setShowModal(false)} disabled={isPending}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={isPending}>
|
||||
{isPending ? 'Suppression...' : 'Supprimer'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
175
src/components/teams/MembersList.tsx
Normal file
175
src/components/teams/MembersList.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { getGravatarUrl } from '@/lib/gravatar';
|
||||
import { Badge } from '@/components/ui';
|
||||
import { Button } from '@/components/ui';
|
||||
import { AddMemberModal } from './AddMemberModal';
|
||||
import type { TeamMember, TeamRole } from '@/lib/types';
|
||||
import { TEAM_ROLE_LABELS } from '@/lib/types';
|
||||
|
||||
interface MembersListProps {
|
||||
members: TeamMember[];
|
||||
teamId: string;
|
||||
isAdmin: boolean;
|
||||
onMemberUpdate: () => void;
|
||||
}
|
||||
|
||||
export function MembersList({ members, teamId, isAdmin, onMemberUpdate }: MembersListProps) {
|
||||
const [addMemberOpen, setAddMemberOpen] = useState(false);
|
||||
const [updatingRole, setUpdatingRole] = useState<string | null>(null);
|
||||
const [removingMember, setRemovingMember] = useState<string | null>(null);
|
||||
|
||||
const handleRoleChange = async (userId: string, newRole: TeamRole) => {
|
||||
setUpdatingRole(userId);
|
||||
try {
|
||||
const response = await fetch(`/api/teams/${teamId}/members`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId, role: newRole }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Erreur lors de la mise à jour du rôle');
|
||||
return;
|
||||
}
|
||||
|
||||
onMemberUpdate();
|
||||
} catch (error) {
|
||||
console.error('Error updating role:', error);
|
||||
alert('Erreur lors de la mise à jour du rôle');
|
||||
} finally {
|
||||
setUpdatingRole(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (userId: string) => {
|
||||
if (!confirm('Êtes-vous sûr de vouloir retirer ce membre de l\'équipe ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRemovingMember(userId);
|
||||
try {
|
||||
const response = await fetch(`/api/teams/${teamId}/members?userId=${userId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Erreur lors de la suppression du membre');
|
||||
return;
|
||||
}
|
||||
|
||||
onMemberUpdate();
|
||||
} catch (error) {
|
||||
console.error('Error removing member:', error);
|
||||
alert('Erreur lors de la suppression du membre');
|
||||
} finally {
|
||||
setRemovingMember(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-foreground">Membres ({members.length})</h3>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
onClick={() => setAddMemberOpen(true)}
|
||||
className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"
|
||||
>
|
||||
Ajouter un membre
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center gap-4 rounded-xl border border-border bg-card p-4"
|
||||
>
|
||||
{/* Avatar */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getGravatarUrl(member.user.email, 96)}
|
||||
alt={member.user.name || member.user.email}
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-full border-2 border-border"
|
||||
/>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground truncate">
|
||||
{member.user.name || 'Sans nom'}
|
||||
</span>
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor:
|
||||
member.role === 'ADMIN'
|
||||
? 'color-mix(in srgb, var(--purple) 15%, transparent)'
|
||||
: 'color-mix(in srgb, var(--gray) 15%, transparent)',
|
||||
color: member.role === 'ADMIN' ? 'var(--purple)' : 'var(--gray)',
|
||||
}}
|
||||
>
|
||||
{TEAM_ROLE_LABELS[member.role]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted truncate">{member.user.email}</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-2">
|
||||
{member.role === 'MEMBER' ? (
|
||||
<Button
|
||||
onClick={() => handleRoleChange(member.userId, 'ADMIN')}
|
||||
disabled={updatingRole === member.userId}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{updatingRole === member.userId ? '...' : 'Promouvoir Admin'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleRoleChange(member.userId, 'MEMBER')}
|
||||
disabled={updatingRole === member.userId}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{updatingRole === member.userId ? '...' : 'Rétrograder'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => handleRemoveMember(member.userId)}
|
||||
disabled={removingMember === member.userId}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
style={{
|
||||
color: 'var(--destructive)',
|
||||
borderColor: 'var(--destructive)',
|
||||
}}
|
||||
>
|
||||
{removingMember === member.userId ? '...' : 'Retirer'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{addMemberOpen && (
|
||||
<AddMemberModal
|
||||
teamId={teamId}
|
||||
existingMemberIds={members.map((m) => m.userId)}
|
||||
onClose={() => setAddMemberOpen(false)}
|
||||
onSuccess={onMemberUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
64
src/components/teams/TeamCard.tsx
Normal file
64
src/components/teams/TeamCard.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui';
|
||||
import { Badge } from '@/components/ui';
|
||||
import type { Team } from '@/lib/types';
|
||||
|
||||
interface TeamCardProps {
|
||||
team: Team & { userRole?: string; userOkrCount?: number; _count?: { members: number } };
|
||||
}
|
||||
|
||||
export function TeamCard({ team }: TeamCardProps) {
|
||||
const memberCount = team._count?.members || team.members?.length || 0;
|
||||
const okrCount = team.userOkrCount || 0;
|
||||
const isAdmin = team.userRole === 'ADMIN';
|
||||
|
||||
return (
|
||||
<Link href={`/teams/${team.id}`}>
|
||||
<Card hover className="h-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">👥</span>
|
||||
<CardTitle>{team.name}</CardTitle>
|
||||
</div>
|
||||
{team.description && <CardDescription className="mt-2">{team.description}</CardDescription>}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
|
||||
color: 'var(--purple)',
|
||||
}}
|
||||
>
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 text-sm text-muted">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{memberCount} membre{memberCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-lg">🎯</span>
|
||||
<span>{okrCount} OKR{okrCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
22
src/components/teams/TeamDetailClient.tsx
Normal file
22
src/components/teams/TeamDetailClient.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { MembersList } from './MembersList';
|
||||
import type { TeamMember } from '@/lib/types';
|
||||
|
||||
interface TeamDetailClientProps {
|
||||
members: TeamMember[];
|
||||
teamId: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export function TeamDetailClient({ members, teamId, isAdmin }: TeamDetailClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleMemberUpdate = () => {
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return <MembersList members={members} teamId={teamId} isAdmin={isAdmin} onMemberUpdate={handleMemberUpdate} />;
|
||||
}
|
||||
|
||||
5
src/components/teams/index.ts
Normal file
5
src/components/teams/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { TeamCard } from './TeamCard';
|
||||
export { MembersList } from './MembersList';
|
||||
export { AddMemberModal } from './AddMemberModal';
|
||||
export { DeleteTeamButton } from './DeleteTeamButton';
|
||||
|
||||
71
src/components/ui/Select.tsx
Normal file
71
src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { forwardRef, SelectHTMLAttributes } from 'react';
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'children'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className = '', label, error, id, options, placeholder, ...props }, ref) => {
|
||||
const selectId = id || props.name;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label htmlFor={selectId} className="mb-2 block text-sm font-medium text-foreground">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<select
|
||||
ref={ref}
|
||||
id={selectId}
|
||||
className={`
|
||||
w-full appearance-none rounded-lg border bg-input px-4 py-2.5 pr-10 text-foreground
|
||||
placeholder:text-muted-foreground
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/20
|
||||
disabled:cursor-not-allowed disabled:opacity-50
|
||||
${error ? 'border-destructive focus:border-destructive' : 'border-input-border focus:border-primary'}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled={props.required}>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Custom arrow icon */}
|
||||
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<svg
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="mt-1.5 text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
|
||||
46
src/components/ui/ToggleGroup.tsx
Normal file
46
src/components/ui/ToggleGroup.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export interface ToggleOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
interface ToggleGroupProps<T extends string> {
|
||||
value: T;
|
||||
options: ToggleOption<T>[];
|
||||
onChange: (value: T) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToggleGroup<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
className = '',
|
||||
}: ToggleGroupProps<T>) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 rounded-lg border border-border bg-card p-1 ${className}`}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`
|
||||
flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors
|
||||
${value === option.value
|
||||
? 'bg-[#8b5cf6] text-white shadow-sm'
|
||||
: 'text-muted hover:text-foreground hover:bg-card-hover'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{option.icon && <span className="flex items-center">{option.icon}</span>}
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,4 +9,7 @@ export { EditableMotivatorTitle } from './EditableMotivatorTitle';
|
||||
export { EditableYearReviewTitle } from './EditableYearReviewTitle';
|
||||
export { Input } from './Input';
|
||||
export { Modal, ModalFooter } from './Modal';
|
||||
export { Select } from './Select';
|
||||
export { Textarea } from './Textarea';
|
||||
export { ToggleGroup } from './ToggleGroup';
|
||||
export type { ToggleOption } from './ToggleGroup';
|
||||
|
||||
Reference in New Issue
Block a user