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:
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user