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
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 4m44s
This commit is contained in:
@@ -25,7 +25,19 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
|||||||
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
|
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(okr);
|
// Check permissions
|
||||||
|
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
|
||||||
|
const isConcernedMember = okr.teamMember.userId === session.user.id;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
...okr,
|
||||||
|
permissions: {
|
||||||
|
isAdmin,
|
||||||
|
isConcernedMember,
|
||||||
|
canEdit: isAdmin || isConcernedMember,
|
||||||
|
canDelete: isAdmin,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching OKR:', error);
|
console.error('Error fetching OKR:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -49,13 +61,22 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
|
|||||||
return NextResponse.json({ error: 'OKR non trouvé' }, { status: 404 });
|
return NextResponse.json({ error: 'OKR non trouvé' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is admin of the team
|
// Check if user is admin of the team or the concerned member
|
||||||
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
|
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
|
||||||
if (!isAdmin) {
|
const isConcernedMember = okr.teamMember.userId === session.user.id;
|
||||||
return NextResponse.json({ error: 'Seuls les administrateurs peuvent modifier les OKRs' }, { status: 403 });
|
if (!isAdmin && !isConcernedMember) {
|
||||||
|
return NextResponse.json({ error: 'Seuls les administrateurs et le membre concerné peuvent modifier les OKRs' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body: UpdateOKRInput & { startDate?: string; endDate?: string } = await request.json();
|
const body: UpdateOKRInput & {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
keyResultsUpdates?: {
|
||||||
|
create?: Array<{ title: string; targetValue: number; unit: string; order: number }>;
|
||||||
|
update?: Array<{ id: string; title?: string; targetValue?: number; unit?: string; order?: number }>;
|
||||||
|
delete?: string[];
|
||||||
|
};
|
||||||
|
} = await request.json();
|
||||||
|
|
||||||
// Convert date strings to Date objects if provided
|
// Convert date strings to Date objects if provided
|
||||||
const updateData: UpdateOKRInput = { ...body };
|
const updateData: UpdateOKRInput = { ...body };
|
||||||
@@ -66,7 +87,17 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
|
|||||||
updateData.endDate = new Date(body.endDate);
|
updateData.endDate = new Date(body.endDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await updateOKR(id, updateData);
|
// Remove keyResultsUpdates from updateData as it's not part of UpdateOKRInput
|
||||||
|
const { keyResultsUpdates, ...okrUpdateData } = body;
|
||||||
|
const finalUpdateData: UpdateOKRInput = { ...okrUpdateData };
|
||||||
|
if (finalUpdateData.startDate) {
|
||||||
|
finalUpdateData.startDate = new Date(finalUpdateData.startDate as any);
|
||||||
|
}
|
||||||
|
if (finalUpdateData.endDate) {
|
||||||
|
finalUpdateData.endDate = new Date(finalUpdateData.endDate as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateOKR(id, finalUpdateData, keyResultsUpdates);
|
||||||
|
|
||||||
return NextResponse.json(updated);
|
return NextResponse.json(updated);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
140
src/app/teams/[id]/okrs/[okrId]/edit/page.tsx
Normal file
140
src/app/teams/[id]/okrs/[okrId]/edit/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
'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, OKR, KeyResult } from '@/lib/types';
|
||||||
|
|
||||||
|
type OKRWithTeamMember = OKR & {
|
||||||
|
teamMember: {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
};
|
||||||
|
userId: string;
|
||||||
|
team: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EditOKRPage() {
|
||||||
|
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 [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch OKR and team members in parallel
|
||||||
|
Promise.all([
|
||||||
|
fetch(`/api/okrs/${okrId}`).then((res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('OKR not found');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}),
|
||||||
|
fetch(`/api/teams/${teamId}`).then((res) => res.json()),
|
||||||
|
])
|
||||||
|
.then(([okrData, teamData]) => {
|
||||||
|
setOkr(okrData);
|
||||||
|
setTeamMembers(teamData.members || []);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [okrId, teamId]);
|
||||||
|
|
||||||
|
const handleSubmit = async (data: CreateOKRInput & { keyResultsUpdates?: { create?: any[]; update?: any[]; delete?: string[] } }) => {
|
||||||
|
// Convert to UpdateOKRInput format
|
||||||
|
const updateData = {
|
||||||
|
objective: data.objective,
|
||||||
|
description: data.description || undefined,
|
||||||
|
period: data.period,
|
||||||
|
startDate: typeof data.startDate === 'string' ? new Date(data.startDate) : data.startDate,
|
||||||
|
endDate: typeof data.endDate === 'string' ? new Date(data.endDate) : data.endDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload: any = {
|
||||||
|
...updateData,
|
||||||
|
startDate: updateData.startDate.toISOString(),
|
||||||
|
endDate: updateData.endDate.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add Key Results updates if in edit mode
|
||||||
|
if (data.keyResultsUpdates) {
|
||||||
|
payload.keyResultsUpdates = data.keyResultsUpdates;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/okrs/${okrId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
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 mise à jour de l\'OKR');
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/teams/${teamId}/okrs/${okrId}`);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare initial data for the form
|
||||||
|
const initialData: Partial<CreateOKRInput> & { keyResults?: KeyResult[] } = {
|
||||||
|
teamMemberId: okr.teamMemberId,
|
||||||
|
objective: okr.objective,
|
||||||
|
description: okr.description || undefined,
|
||||||
|
period: okr.period,
|
||||||
|
startDate: okr.startDate,
|
||||||
|
endDate: okr.endDate,
|
||||||
|
keyResults: okr.keyResults || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link href={`/teams/${teamId}/okrs/${okrId}`} className="text-muted hover:text-foreground">
|
||||||
|
← Retour à l'OKR
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground mb-6">Modifier l'OKR</h1>
|
||||||
|
<OKRForm
|
||||||
|
teamMembers={teamMembers}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => router.push(`/teams/${teamId}/okrs/${okrId}`)}
|
||||||
|
initialData={initialData}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -55,6 +55,12 @@ type OKRWithTeamMember = OKR & {
|
|||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
permissions?: {
|
||||||
|
isAdmin: boolean;
|
||||||
|
isConcernedMember: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function OKRDetailPage() {
|
export default function OKRDetailPage() {
|
||||||
@@ -64,8 +70,6 @@ export default function OKRDetailPage() {
|
|||||||
const okrId = params.okrId as string;
|
const okrId = params.okrId as string;
|
||||||
const [okr, setOkr] = useState<OKRWithTeamMember | null>(null);
|
const [okr, setOkr] = useState<OKRWithTeamMember | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
|
||||||
const [isConcernedMember, setIsConcernedMember] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch OKR
|
// Fetch OKR
|
||||||
@@ -78,10 +82,6 @@ export default function OKRDetailPage() {
|
|||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setOkr(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) => {
|
.catch((error) => {
|
||||||
console.error('Error fetching OKR:', error);
|
console.error('Error fetching OKR:', error);
|
||||||
@@ -141,7 +141,8 @@ export default function OKRDetailPage() {
|
|||||||
const progress = okr.progress || 0;
|
const progress = okr.progress || 0;
|
||||||
const progressColor =
|
const progressColor =
|
||||||
progress >= 75 ? 'var(--success)' : progress >= 25 ? 'var(--accent)' : 'var(--destructive)';
|
progress >= 75 ? 'var(--success)' : progress >= 25 ? 'var(--accent)' : 'var(--destructive)';
|
||||||
const canEdit = isAdmin || isConcernedMember;
|
const canEdit = okr.permissions?.canEdit ?? false;
|
||||||
|
const canDelete = okr.permissions?.canDelete ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-4xl px-4 py-8">
|
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||||
@@ -222,8 +223,18 @@ export default function OKRDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
{isAdmin && (
|
{(canEdit || canDelete) && (
|
||||||
<div className="mt-4 flex gap-2">
|
<div className="mt-4 flex gap-2">
|
||||||
|
{canEdit && (
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push(`/teams/${teamId}/okrs/${okrId}/edit`)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Éditer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -235,6 +246,7 @@ export default function OKRDetailPage() {
|
|||||||
>
|
>
|
||||||
Supprimer
|
Supprimer
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default async function TeamDetailPage({ params }: TeamDetailPageProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* OKRs Section */}
|
{/* OKRs Section */}
|
||||||
<OKRsList okrsData={okrsData} teamId={id} />
|
<OKRsList okrsData={okrsData} teamId={id} isAdmin={isAdmin} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
|
||||||
import { Badge } from '@/components/ui';
|
import { Badge } from '@/components/ui';
|
||||||
|
import { Button } from '@/components/ui';
|
||||||
import { getGravatarUrl } from '@/lib/gravatar';
|
import { getGravatarUrl } from '@/lib/gravatar';
|
||||||
import type { OKR, KeyResult, OKRStatus, KeyResultStatus } from '@/lib/types';
|
import type { OKR, KeyResult, OKRStatus, KeyResultStatus } from '@/lib/types';
|
||||||
import { OKR_STATUS_LABELS, KEY_RESULT_STATUS_LABELS } from '@/lib/types';
|
import { OKR_STATUS_LABELS, KEY_RESULT_STATUS_LABELS } from '@/lib/types';
|
||||||
@@ -71,19 +74,51 @@ function getKeyResultStatusColor(status: KeyResultStatus): { bg: string; color:
|
|||||||
interface OKRCardProps {
|
interface OKRCardProps {
|
||||||
okr: OKR & { teamMember?: { user: { id: string; email: string; name: string | null } } };
|
okr: OKR & { teamMember?: { user: { id: string; email: string; name: string | null } } };
|
||||||
teamId: string;
|
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 progress = okr.progress || 0;
|
||||||
const progressColor =
|
const progressColor =
|
||||||
progress >= 75 ? 'var(--success)' : progress >= 25 ? 'var(--accent)' : 'var(--destructive)';
|
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 (
|
return (
|
||||||
<Link href={`/teams/${teamId}/okrs/${okr.id}`}>
|
<Card hover className="h-full relative group">
|
||||||
<Card hover className="h-full">
|
|
||||||
<CardHeader>
|
<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 items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1 pr-2">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<span className="text-xl">🎯</span>
|
<span className="text-xl">🎯</span>
|
||||||
{okr.objective}
|
{okr.objective}
|
||||||
@@ -104,6 +139,39 @@ export function OKRCard({ okr, teamId }: OKRCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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
|
<Badge
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
|
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
|
||||||
@@ -113,7 +181,9 @@ export function OKRCard({ okr, teamId }: OKRCardProps) {
|
|||||||
{okr.period}
|
{okr.period}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<Link href={`/teams/${teamId}/okrs/${okr.id}`}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Progress Bar */}
|
{/* Progress Bar */}
|
||||||
@@ -202,8 +272,8 @@ export function OKRCard({ okr, teamId }: OKRCardProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Input } from '@/components/ui';
|
|||||||
import { Textarea } from '@/components/ui';
|
import { Textarea } from '@/components/ui';
|
||||||
import { Button } from '@/components/ui';
|
import { Button } from '@/components/ui';
|
||||||
import { Select } 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';
|
import { PERIOD_SUGGESTIONS } from '@/lib/types';
|
||||||
|
|
||||||
// Calcule les dates de début et de fin pour un trimestre donné
|
// 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 {
|
interface OKRFormProps {
|
||||||
teamMembers: TeamMember[];
|
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;
|
onCancel: () => void;
|
||||||
initialData?: Partial<CreateOKRInput>;
|
initialData?: Partial<CreateOKRInput> & { keyResults?: KeyResult[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFormProps) {
|
export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFormProps) {
|
||||||
@@ -76,11 +80,19 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
|
|||||||
const [endDate, setEndDate] = useState(
|
const [endDate, setEndDate] = useState(
|
||||||
initialData?.endDate ? new Date(initialData.endDate).toISOString().split('T')[0] : ''
|
initialData?.endDate ? new Date(initialData.endDate).toISOString().split('T')[0] : ''
|
||||||
);
|
);
|
||||||
const [keyResults, setKeyResults] = useState<CreateKeyResultInput[]>(
|
// Initialize Key Results from existing ones if in edit mode, otherwise start with one empty
|
||||||
initialData?.keyResults || [
|
const [keyResults, setKeyResults] = useState<KeyResultEditInput[]>(() => {
|
||||||
{ title: '', targetValue: 100, unit: '%', order: 0 },
|
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);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
// Mise à jour automatique des dates quand la période change
|
// 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 })));
|
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];
|
const updated = [...keyResults];
|
||||||
updated[index] = { ...updated[index], [field]: value };
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
setKeyResults(updated);
|
setKeyResults(updated);
|
||||||
@@ -127,6 +139,7 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate Key Results
|
||||||
if (keyResults.some((kr) => !kr.title || kr.targetValue <= 0)) {
|
if (keyResults.some((kr) => !kr.title || kr.targetValue <= 0)) {
|
||||||
alert('Tous les Key Results doivent avoir un titre et une valeur cible > 0');
|
alert('Tous les Key Results doivent avoir un titre et une valeur cible > 0');
|
||||||
return;
|
return;
|
||||||
@@ -150,6 +163,76 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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({
|
await onSubmit({
|
||||||
teamMemberId,
|
teamMemberId,
|
||||||
objective,
|
objective,
|
||||||
@@ -159,6 +242,7 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
|
|||||||
endDate: endDateObj.toISOString() as any,
|
endDate: endDateObj.toISOString() as any,
|
||||||
keyResults: keyResults.map((kr, i) => ({ ...kr, order: i })),
|
keyResults: keyResults.map((kr, i) => ({ ...kr, order: i })),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error submitting OKR:', error);
|
console.error('Error submitting OKR:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -273,7 +357,7 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{keyResults.map((kr, index) => (
|
{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">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-foreground">Key Result {index + 1}</span>
|
<span className="text-sm font-medium text-foreground">Key Result {index + 1}</span>
|
||||||
{keyResults.length > 1 && (
|
{keyResults.length > 1 && (
|
||||||
@@ -331,7 +415,13 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ interface OKRsListProps {
|
|||||||
okrs: Array<OKR & { progress?: number }>;
|
okrs: Array<OKR & { progress?: number }>;
|
||||||
}>;
|
}>;
|
||||||
teamId: string;
|
teamId: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OKRsList({ okrsData, teamId }: OKRsListProps) {
|
export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('grouped');
|
const [viewMode, setViewMode] = useState<ViewMode>('grouped');
|
||||||
|
|
||||||
// Flatten OKRs for grid view
|
// Flatten OKRs for grid view
|
||||||
@@ -123,6 +124,7 @@ export function OKRsList({ okrsData, teamId }: OKRsListProps) {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
teamId={teamId}
|
teamId={teamId}
|
||||||
|
isAdmin={isAdmin}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -133,7 +135,7 @@ export function OKRsList({ okrsData, teamId }: OKRsListProps) {
|
|||||||
/* Grid View */
|
/* Grid View */
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{allOKRs.map((okr) => (
|
{allOKRs.map((okr) => (
|
||||||
<OKRCard key={okr.id} okr={okr} teamId={teamId} />
|
<OKRCard key={okr.id} okr={okr} teamId={teamId} isAdmin={isAdmin} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { prisma } from '@/services/database';
|
import { prisma } from '@/services/database';
|
||||||
import type { CreateOKRInput, UpdateOKRInput, UpdateKeyResultInput, OKRStatus, KeyResultStatus } from '@/lib/types';
|
import type { UpdateOKRInput, OKRStatus, KeyResultStatus } from '@/lib/types';
|
||||||
|
|
||||||
export async function createOKR(
|
export async function createOKR(
|
||||||
teamMemberId: string,
|
teamMemberId: string,
|
||||||
@@ -183,8 +183,24 @@ export async function getUserOKRs(userId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateOKR(okrId: string, data: UpdateOKRInput) {
|
export async function updateOKR(
|
||||||
const okr = await prisma.oKR.update({
|
okrId: string,
|
||||||
|
data: UpdateOKRInput,
|
||||||
|
keyResultsUpdates?: {
|
||||||
|
create?: Array<{ title: string; targetValue: number; unit: string; order: number }>;
|
||||||
|
update?: Array<{
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
targetValue?: number;
|
||||||
|
unit?: string;
|
||||||
|
order?: number;
|
||||||
|
}>;
|
||||||
|
delete?: string[];
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
// Update OKR
|
||||||
|
await tx.oKR.update({
|
||||||
where: { id: okrId },
|
where: { id: okrId },
|
||||||
data: {
|
data: {
|
||||||
...(data.objective !== undefined && { objective: data.objective }),
|
...(data.objective !== undefined && { objective: data.objective }),
|
||||||
@@ -194,6 +210,60 @@ export async function updateOKR(okrId: string, data: UpdateOKRInput) {
|
|||||||
...(data.endDate !== undefined && { endDate: data.endDate }),
|
...(data.endDate !== undefined && { endDate: data.endDate }),
|
||||||
...(data.status !== undefined && { status: data.status }),
|
...(data.status !== undefined && { status: data.status }),
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Key Results updates if provided
|
||||||
|
if (keyResultsUpdates) {
|
||||||
|
// Delete Key Results
|
||||||
|
if (keyResultsUpdates.delete && keyResultsUpdates.delete.length > 0) {
|
||||||
|
await tx.keyResult.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: { in: keyResultsUpdates.delete },
|
||||||
|
okrId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new Key Results
|
||||||
|
if (keyResultsUpdates.create && keyResultsUpdates.create.length > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
keyResultsUpdates.create.map((kr) =>
|
||||||
|
tx.keyResult.create({
|
||||||
|
data: {
|
||||||
|
okrId,
|
||||||
|
title: kr.title,
|
||||||
|
targetValue: kr.targetValue,
|
||||||
|
currentValue: 0,
|
||||||
|
unit: kr.unit || '%',
|
||||||
|
status: 'NOT_STARTED',
|
||||||
|
order: kr.order,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing Key Results
|
||||||
|
if (keyResultsUpdates.update && keyResultsUpdates.update.length > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
keyResultsUpdates.update.map((kr) =>
|
||||||
|
tx.keyResult.update({
|
||||||
|
where: { id: kr.id },
|
||||||
|
data: {
|
||||||
|
...(kr.title !== undefined && { title: kr.title }),
|
||||||
|
...(kr.targetValue !== undefined && { targetValue: kr.targetValue }),
|
||||||
|
...(kr.unit !== undefined && { unit: kr.unit }),
|
||||||
|
...(kr.order !== undefined && { order: kr.order }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch updated OKR with Key Results
|
||||||
|
const updatedOkr = await tx.oKR.findUnique({
|
||||||
|
where: { id: okrId },
|
||||||
include: {
|
include: {
|
||||||
keyResults: {
|
keyResults: {
|
||||||
orderBy: {
|
orderBy: {
|
||||||
@@ -203,10 +273,15 @@ export async function updateOKR(okrId: string, data: UpdateOKRInput) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!updatedOkr) {
|
||||||
|
throw new Error('OKR not found after update');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...okr,
|
...updatedOkr,
|
||||||
progress: calculateOKRProgressFromKeyResults(okr.keyResults),
|
progress: calculateOKRProgressFromKeyResults(updatedOkr.keyResults),
|
||||||
};
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteOKR(okrId: string) {
|
export async function deleteOKR(okrId: string) {
|
||||||
@@ -216,6 +291,47 @@ export async function deleteOKR(okrId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createKeyResult(
|
||||||
|
okrId: string,
|
||||||
|
title: string,
|
||||||
|
targetValue: number,
|
||||||
|
unit: string,
|
||||||
|
order: number
|
||||||
|
) {
|
||||||
|
return prisma.keyResult.create({
|
||||||
|
data: {
|
||||||
|
okrId,
|
||||||
|
title,
|
||||||
|
targetValue,
|
||||||
|
currentValue: 0,
|
||||||
|
unit: unit || '%',
|
||||||
|
status: 'NOT_STARTED',
|
||||||
|
order,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateKeyResultMetadata(
|
||||||
|
krId: string,
|
||||||
|
data: { title?: string; targetValue?: number; unit?: string; order?: number }
|
||||||
|
) {
|
||||||
|
return prisma.keyResult.update({
|
||||||
|
where: { id: krId },
|
||||||
|
data: {
|
||||||
|
...(data.title !== undefined && { title: data.title }),
|
||||||
|
...(data.targetValue !== undefined && { targetValue: data.targetValue }),
|
||||||
|
...(data.unit !== undefined && { unit: data.unit }),
|
||||||
|
...(data.order !== undefined && { order: data.order }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteKeyResult(krId: string) {
|
||||||
|
return prisma.keyResult.delete({
|
||||||
|
where: { id: krId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateKeyResult(krId: string, currentValue: number, notes: string | null) {
|
export async function updateKeyResult(krId: string, currentValue: number, notes: string | null) {
|
||||||
// Auto-update status based on progress
|
// Auto-update status based on progress
|
||||||
const kr = await prisma.keyResult.findUnique({
|
const kr = await prisma.keyResult.findUnique({
|
||||||
@@ -293,7 +409,9 @@ export function calculateOKRProgress(okrId: string): Promise<number> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateOKRProgressFromKeyResults(keyResults: Array<{ currentValue: number; targetValue: number }>): number {
|
function calculateOKRProgressFromKeyResults(
|
||||||
|
keyResults: Array<{ currentValue: number; targetValue: number }>
|
||||||
|
): number {
|
||||||
if (keyResults.length === 0) {
|
if (keyResults.length === 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -305,4 +423,3 @@ function calculateOKRProgressFromKeyResults(keyResults: Array<{ currentValue: nu
|
|||||||
|
|
||||||
return Math.round(totalProgress / keyResults.length);
|
return Math.round(totalProgress / keyResults.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user