All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m54s
495 lines
16 KiB
TypeScript
495 lines
16 KiB
TypeScript
'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, KeyResult } 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 KeyResultEditInput extends CreateKeyResultInput {
|
|
id?: string; // If present, it's an existing Key Result to update
|
|
}
|
|
|
|
type KeyResultUpdate = {
|
|
id: string;
|
|
title?: string;
|
|
targetValue?: number;
|
|
unit?: string;
|
|
order?: number;
|
|
};
|
|
|
|
type OKRFormSubmitData =
|
|
| (CreateOKRInput & {
|
|
startDate: Date | string;
|
|
endDate: Date | string;
|
|
keyResultsUpdates?: {
|
|
create?: CreateKeyResultInput[];
|
|
update?: KeyResultUpdate[];
|
|
delete?: string[];
|
|
};
|
|
})
|
|
| CreateOKRInput;
|
|
|
|
interface OKRFormProps {
|
|
teamMembers: TeamMember[];
|
|
onSubmit: (data: OKRFormSubmitData) => Promise<void>;
|
|
onCancel: () => void;
|
|
initialData?: Partial<CreateOKRInput> & { keyResults?: KeyResult[] };
|
|
}
|
|
|
|
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] : ''
|
|
);
|
|
// Initialize Key Results from existing ones if in edit mode, otherwise start with one empty
|
|
const [keyResults, setKeyResults] = useState<KeyResultEditInput[]>(() => {
|
|
if (initialData?.keyResults && initialData.keyResults.length > 0) {
|
|
return initialData.keyResults.map((kr): KeyResultEditInput => {
|
|
const result = {
|
|
title: kr.title,
|
|
targetValue: kr.targetValue,
|
|
unit: kr.unit || '%',
|
|
order: kr.order,
|
|
};
|
|
// @ts-expect-error - id is added to extend CreateKeyResultInput to KeyResultEditInput
|
|
result.id = kr.id;
|
|
return result as KeyResultEditInput;
|
|
});
|
|
}
|
|
return [{ 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 KeyResultEditInput,
|
|
value: string | number
|
|
) => {
|
|
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;
|
|
}
|
|
|
|
// Validate Key Results
|
|
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;
|
|
}
|
|
|
|
const isEditMode = !!initialData?.teamMemberId;
|
|
|
|
if (isEditMode) {
|
|
// Type guard for Key Results with id
|
|
type KeyResultWithId = KeyResultEditInput & { id: string };
|
|
const hasId = (kr: KeyResultEditInput): kr is KeyResultWithId => !!kr.id;
|
|
|
|
// In edit mode, separate existing Key Results from new ones
|
|
const existingKeyResults: KeyResultWithId[] = keyResults.filter(hasId);
|
|
const originalKeyResults: KeyResult[] = initialData?.keyResults || [];
|
|
const originalIds = new Set(originalKeyResults.map((kr: KeyResult) => kr.id));
|
|
const currentIds = new Set(existingKeyResults.map((kr: KeyResultWithId) => kr.id));
|
|
|
|
// Find deleted Key Results
|
|
const deletedIds = Array.from(originalIds).filter((id) => !currentIds.has(id));
|
|
|
|
// Find updated Key Results (compare with original)
|
|
const updated = existingKeyResults
|
|
.map((kr: KeyResultWithId) => {
|
|
const original = originalKeyResults.find((okr: KeyResult) => okr.id === kr.id);
|
|
if (!original) return null;
|
|
|
|
const changes: {
|
|
id: string;
|
|
title?: string;
|
|
targetValue?: number;
|
|
unit?: string;
|
|
order?: number;
|
|
} = { id: kr.id };
|
|
if (original.title !== kr.title) changes.title = kr.title;
|
|
if (original.targetValue !== kr.targetValue) changes.targetValue = kr.targetValue;
|
|
if (original.unit !== kr.unit) changes.unit = kr.unit;
|
|
if (original.order !== kr.order) changes.order = kr.order;
|
|
|
|
return Object.keys(changes).length > 1 ? changes : null; // More than just 'id'
|
|
})
|
|
.filter(
|
|
(
|
|
u
|
|
): u is {
|
|
id: string;
|
|
title?: string;
|
|
targetValue?: number;
|
|
unit?: string;
|
|
order?: number;
|
|
} => u !== null
|
|
);
|
|
|
|
// Update order for all Key Results based on their position
|
|
const allKeyResultsWithOrder = keyResults.map((kr, i) => ({ ...kr, order: i }));
|
|
const existingWithOrder = allKeyResultsWithOrder.filter(hasId) as KeyResultWithId[];
|
|
const newWithOrder = allKeyResultsWithOrder.filter((kr) => !kr.id);
|
|
|
|
// Update order for existing Key Results that changed position
|
|
const orderUpdates = existingWithOrder
|
|
.map((kr) => {
|
|
const original = originalKeyResults.find((okr: KeyResult) => okr.id === kr.id);
|
|
if (!original || original.order === kr.order) return null;
|
|
return { id: kr.id, order: kr.order };
|
|
})
|
|
.filter((u): u is { id: string; order: number } => u !== null);
|
|
|
|
// Merge order updates with other updates
|
|
const allUpdates = [...updated];
|
|
orderUpdates.forEach((orderUpdate) => {
|
|
const existingUpdate = allUpdates.find((u) => u.id === orderUpdate.id);
|
|
if (existingUpdate) {
|
|
existingUpdate.order = orderUpdate.order;
|
|
} else {
|
|
allUpdates.push(orderUpdate);
|
|
}
|
|
});
|
|
|
|
await onSubmit({
|
|
teamMemberId,
|
|
objective,
|
|
description: description || undefined,
|
|
period: finalPeriod,
|
|
startDate: startDateObj.toISOString() as Date | string,
|
|
endDate: endDateObj.toISOString() as Date | string,
|
|
keyResults: [], // Not used in edit mode
|
|
keyResultsUpdates: {
|
|
create:
|
|
newWithOrder.length > 0
|
|
? newWithOrder.map((kr) => ({
|
|
title: kr.title,
|
|
targetValue: kr.targetValue,
|
|
unit: kr.unit || '%',
|
|
order: kr.order,
|
|
}))
|
|
: undefined,
|
|
update: allUpdates.length > 0 ? allUpdates : undefined,
|
|
delete: deletedIds.length > 0 ? deletedIds : undefined,
|
|
},
|
|
} as unknown as OKRFormSubmitData);
|
|
} else {
|
|
// In create mode, just send Key Results normally
|
|
await onSubmit({
|
|
teamMemberId,
|
|
objective,
|
|
description: description || undefined,
|
|
period: finalPeriod,
|
|
startDate: startDateObj,
|
|
endDate: endDateObj,
|
|
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={kr.id || `new-${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
|
|
? initialData?.teamMemberId
|
|
? 'Modification...'
|
|
: 'Création...'
|
|
: initialData?.teamMemberId
|
|
? "Modifier l'OKR"
|
|
: "Créer l'OKR"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|