feat: enhance OKR management by adding permission checks for editing and deleting, and updating OKR forms to handle key results more effectively
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 4m44s

This commit is contained in:
Julien Froidefond
2026-01-07 16:48:23 +01:00
parent 5f661c8bfd
commit ca9b68ebbd
8 changed files with 562 additions and 100 deletions

View File

@@ -1,8 +1,11 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
import { Badge } from '@/components/ui';
import { Button } 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';
@@ -71,39 +74,104 @@ function getKeyResultStatusColor(status: KeyResultStatus): { bg: string; color:
interface OKRCardProps {
okr: OKR & { teamMember?: { user: { id: string; email: string; name: string | null } } };
teamId: string;
isAdmin?: boolean;
}
export function OKRCard({ okr, teamId }: OKRCardProps) {
export function OKRCard({ okr, teamId, isAdmin = false }: OKRCardProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const progress = okr.progress || 0;
const progressColor =
progress >= 75 ? 'var(--success)' : progress >= 25 ? 'var(--accent)' : 'var(--destructive)';
const handleDelete = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!confirm(`Êtes-vous sûr de vouloir supprimer l'OKR "${okr.objective}" ?`)) {
return;
}
startTransition(async () => {
try {
const response = await fetch(`/api/okrs/${okr.id}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await response.json();
alert(error.error || 'Erreur lors de la suppression de l\'OKR');
return;
}
router.refresh();
} catch (error) {
console.error('Error deleting OKR:', error);
alert('Erreur lors de la suppression de l\'OKR');
}
});
};
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>
)}
<Card hover className="h-full relative group">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<Link href={`/teams/${teamId}/okrs/${okr.id}`} className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div className="flex-1 pr-2">
<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>
</div>
</Link>
{/* Action Zone */}
<div className="flex items-center gap-2 flex-shrink-0 relative z-10">
{isAdmin && (
<button
onClick={handleDelete}
className="h-6 w-6 p-0 flex items-center justify-center rounded hover:bg-destructive/10 transition-colors flex-shrink-0"
style={{
color: 'var(--destructive)',
border: '1px solid color-mix(in srgb, var(--destructive) 40%, transparent)',
backgroundColor: 'color-mix(in srgb, var(--destructive) 5%, transparent)',
}}
disabled={isPending}
title="Supprimer l'OKR"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
)}
<Badge
style={{
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
@@ -113,7 +181,9 @@ export function OKRCard({ okr, teamId }: OKRCardProps) {
{okr.period}
</Badge>
</div>
</CardHeader>
</div>
</CardHeader>
<Link href={`/teams/${teamId}/okrs/${okr.id}`}>
<CardContent>
<div className="space-y-3">
{/* Progress Bar */}
@@ -202,8 +272,8 @@ export function OKRCard({ okr, teamId }: OKRCardProps) {
)}
</div>
</CardContent>
</Card>
</Link>
</Link>
</Card>
);
}

View File

@@ -5,7 +5,7 @@ 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 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é
@@ -57,11 +57,15 @@ function getQuarterDates(period: string): { startDate: string; endDate: string }
};
}
interface KeyResultEditInput extends CreateKeyResultInput {
id?: string; // If present, it's an existing Key Result to update
}
interface OKRFormProps {
teamMembers: TeamMember[];
onSubmit: (data: CreateOKRInput) => Promise<void>;
onSubmit: (data: CreateOKRInput & { keyResultsUpdates?: { create?: CreateKeyResultInput[]; update?: Array<{ id: string; title?: string; targetValue?: number; unit?: string; order?: number }>; delete?: string[] } }) => Promise<void>;
onCancel: () => void;
initialData?: Partial<CreateOKRInput>;
initialData?: Partial<CreateOKRInput> & { keyResults?: KeyResult[] };
}
export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFormProps) {
@@ -76,11 +80,19 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
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 },
]
);
// 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) => ({
id: kr.id,
title: kr.title,
targetValue: kr.targetValue,
unit: kr.unit,
order: kr.order,
}));
}
return [{ title: '', targetValue: 100, unit: '%', order: 0 }];
});
const [submitting, setSubmitting] = useState(false);
// Mise à jour automatique des dates quand la période change
@@ -113,7 +125,7 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
setKeyResults(keyResults.filter((_, i) => i !== index).map((kr, i) => ({ ...kr, order: i })));
};
const updateKeyResult = (index: number, field: keyof CreateKeyResultInput, value: any) => {
const updateKeyResult = (index: number, field: keyof KeyResultEditInput, value: any) => {
const updated = [...keyResults];
updated[index] = { ...updated[index], [field]: value };
setKeyResults(updated);
@@ -127,6 +139,7 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
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;
@@ -150,15 +163,86 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
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 })),
});
const isEditMode = !!initialData?.teamMemberId;
if (isEditMode) {
// In edit mode, separate existing Key Results from new ones
const existingKeyResults = keyResults.filter((kr) => kr.id);
const newKeyResults = keyResults.filter((kr) => !kr.id);
const originalKeyResults = initialData?.keyResults || [];
const originalIds = new Set(originalKeyResults.map((kr) => kr.id));
const currentIds = new Set(existingKeyResults.map((kr) => 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) => {
const original = originalKeyResults.find((okr) => 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((kr) => kr.id);
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) => 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 any,
endDate: endDateObj.toISOString() as any,
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 any);
} else {
// In create mode, just send Key Results normally
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 {
@@ -273,7 +357,7 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
</div>
<div className="space-y-4">
{keyResults.map((kr, index) => (
<div key={index} className="rounded-lg border border-border bg-card p-4">
<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 && (
@@ -331,7 +415,13 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
disabled={submitting}
className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"
>
{submitting ? 'Création...' : 'Créer l\'OKR'}
{submitting
? initialData?.teamMemberId
? 'Modification...'
: 'Création...'
: initialData?.teamMemberId
? 'Modifier l\'OKR'
: 'Créer l\'OKR'}
</Button>
</div>
</form>

View File

@@ -18,9 +18,10 @@ interface OKRsListProps {
okrs: Array<OKR & { progress?: number }>;
}>;
teamId: string;
isAdmin?: boolean;
}
export function OKRsList({ okrsData, teamId }: OKRsListProps) {
export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
const [viewMode, setViewMode] = useState<ViewMode>('grouped');
// Flatten OKRs for grid view
@@ -123,6 +124,7 @@ export function OKRsList({ okrsData, teamId }: OKRsListProps) {
},
}}
teamId={teamId}
isAdmin={isAdmin}
/>
))}
</div>
@@ -133,7 +135,7 @@ export function OKRsList({ okrsData, teamId }: OKRsListProps) {
/* 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} />
<OKRCard key={okr.id} okr={okr} teamId={teamId} isAdmin={isAdmin} />
))}
</div>
)}