feat: refactor ObjectivesPage to utilize ObjectivesList component for improved rendering and simplify OKR status handling in OKRCard with compact view option
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 4m17s

This commit is contained in:
Julien Froidefond
2026-01-07 17:18:16 +01:00
parent ca9b68ebbd
commit 97045342b7
5 changed files with 354 additions and 284 deletions

View File

@@ -2,72 +2,8 @@ import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { getUserOKRs } from '@/services/okrs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
import { Badge } from '@/components/ui';
import { getGravatarUrl } from '@/lib/gravatar';
import type { 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)',
color: '#6b7280',
};
case 'IN_PROGRESS':
return {
bg: 'color-mix(in srgb, #3b82f6 15%, transparent)',
color: '#3b82f6',
};
case 'COMPLETED':
return {
bg: 'color-mix(in srgb, #10b981 15%, transparent)',
color: '#10b981',
};
case 'CANCELLED':
return {
bg: 'color-mix(in srgb, #ef4444 15%, transparent)',
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)',
color: '#6b7280',
};
case 'IN_PROGRESS':
return {
bg: 'color-mix(in srgb, #3b82f6 12%, transparent)',
color: '#3b82f6',
};
case 'COMPLETED':
return {
bg: 'color-mix(in srgb, #10b981 12%, transparent)',
color: '#10b981',
};
case 'AT_RISK':
return {
bg: 'color-mix(in srgb, #f59e0b 12%, transparent)',
color: '#f59e0b',
};
default:
return {
bg: 'color-mix(in srgb, #6b7280 12%, transparent)',
color: '#6b7280',
};
}
}
import { Card } from '@/components/ui';
import { ObjectivesList } from '@/components/okrs/ObjectivesList';
export default async function ObjectivesPage() {
const session = await auth();
@@ -120,8 +56,8 @@ export default async function ObjectivesPage() {
<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 mb-6">
Vous n&apos;avez pas encore d&apos;OKR défini. Contactez un administrateur d&apos;équipe pour
en créer.
Vous n&apos;avez pas encore d&apos;OKR défini. Contactez un administrateur d&apos;équipe
pour en créer.
</p>
<Link href="/teams">
<span className="inline-block rounded-lg bg-[var(--purple)] px-4 py-2 text-white hover:opacity-90">
@@ -130,172 +66,8 @@ export default async function ObjectivesPage() {
</Link>
</Card>
) : (
<div className="space-y-8">
{periods.map((period) => {
const periodOKRs = okrsByPeriod[period];
const totalProgress =
periodOKRs.reduce((sum, okr) => sum + (okr.progress || 0), 0) / periodOKRs.length;
return (
<div key={period} className="space-y-4">
{/* Period Header */}
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-3">
<Badge
style={{
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
color: 'var(--purple)',
fontSize: '14px',
padding: '6px 12px',
}}
>
{period}
</Badge>
<span className="text-sm text-muted">
{periodOKRs.length} OKR{periodOKRs.length !== 1 ? 's' : ''}
</span>
</div>
<div className="text-sm font-medium text-foreground">
Progression moyenne: <span style={{ color: 'var(--primary)' }}>{Math.round(totalProgress)}%</span>
</div>
</div>
{/* OKRs Grid */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{periodOKRs.map((okr) => {
const progress = okr.progress || 0;
const progressColor =
progress >= 75 ? '#10b981' : progress >= 25 ? '#f59e0b' : '#ef4444';
return (
<Link key={okr.id} href={`/teams/${okr.team.id}/okrs/${okr.id}`}>
<Card hover className="h-full flex flex-col">
<CardHeader>
<div className="flex items-start justify-between mb-2">
<CardTitle className="text-lg flex-1 line-clamp-2">{okr.objective}</CardTitle>
<Badge style={getOKRStatusColor(okr.status)}>
{OKR_STATUS_LABELS[okr.status]}
</Badge>
</div>
{okr.description && (
<p className="text-sm text-muted line-clamp-2">{okr.description}</p>
)}
<div className="mt-2 flex items-center gap-2 text-xs text-muted">
<span>👥</span>
<span>{okr.team.name}</span>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col">
{/* Progress Bar */}
<div className="mb-4">
<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>
{/* Key Results Preview */}
{okr.keyResults && okr.keyResults.length > 0 && (
<div className="mt-auto space-y-2">
<div className="text-xs font-medium text-muted uppercase tracking-wide">
Key Results ({okr.keyResults.length})
</div>
<div className="space-y-2">
{okr.keyResults.slice(0, 3).map((kr) => {
const krProgress =
kr.targetValue > 0 ? (kr.currentValue / kr.targetValue) * 100 : 0;
const krProgressColor =
krProgress >= 100
? '#10b981'
: krProgress >= 50
? '#f59e0b'
: '#ef4444';
return (
<div key={kr.id} className="space-y-1">
<div className="flex items-start justify-between gap-2">
<span className="text-xs text-foreground flex-1 line-clamp-1">
{kr.title}
</span>
<Badge
style={{
...getKeyResultStatusColor(kr.status),
fontSize: '9px',
padding: '1px 4px',
}}
>
{KEY_RESULT_STATUS_LABELS[kr.status]}
</Badge>
</div>
<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 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>
);
})}
{okr.keyResults.length > 3 && (
<div className="text-xs text-muted text-center pt-1">
+{okr.keyResults.length - 3} autre{okr.keyResults.length - 3 !== 1 ? 's' : ''}
</div>
)}
</div>
</div>
)}
{/* Dates */}
<div className="mt-4 pt-4 border-t border-border flex items-center justify-between text-xs text-muted">
<span>
{new Date(okr.startDate).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
<span></span>
<span>
{new Date(okr.endDate).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
</div>
);
})}
</div>
<ObjectivesList okrsByPeriod={okrsByPeriod} periods={periods} />
)}
</main>
);
}

View File

@@ -1,11 +1,10 @@
'use client';
import { useState, useTransition } from 'react';
import { 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';
@@ -75,9 +74,10 @@ interface OKRCardProps {
okr: OKR & { teamMember?: { user: { id: string; email: string; name: string | null } } };
teamId: string;
isAdmin?: boolean;
compact?: boolean;
}
export function OKRCard({ okr, teamId, isAdmin = false }: OKRCardProps) {
export function OKRCard({ okr, teamId, isAdmin = false, compact = false }: OKRCardProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const progress = okr.progress || 0;
@@ -100,18 +100,128 @@ export function OKRCard({ okr, teamId, isAdmin = false }: OKRCardProps) {
if (!response.ok) {
const error = await response.json();
alert(error.error || 'Erreur lors de la suppression de l\'OKR');
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');
alert("Erreur lors de la suppression de l'OKR");
}
});
};
if (compact) {
return (
<Card hover className="relative group">
<Link href={`/teams/${teamId}/okrs/${okr.id}`}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0 flex items-start gap-3">
<span className="text-xl flex-shrink-0">🎯</span>
<div className="flex-1 min-w-0">
<CardTitle className="text-lg leading-snug mb-1.5 line-clamp-2">
{okr.objective}
</CardTitle>
{okr.teamMember && (
<div className="flex items-center gap-1.5">
{/* 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={16}
height={16}
className="rounded-full flex-shrink-0"
/>
<span className="text-xs text-muted line-clamp-1">
{okr.teamMember.user.name || okr.teamMember.user.email}
</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0 relative z-10">
{isAdmin && (
<button
onClick={handleDelete}
className="h-5 w-5 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-3 w-3"
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)',
color: 'var(--purple)',
fontSize: '11px',
padding: '2px 6px',
}}
>
{okr.period}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 pb-3">
<div className="flex items-center gap-4">
{/* Progress Bar */}
<div className="flex-1 min-w-0">
<div className="mb-1 flex items-center justify-between text-xs">
<span className="text-muted">Progression</span>
<span className="font-medium" style={{ color: progressColor }}>
{progress}%
</span>
</div>
<div className="h-1.5 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 flex-shrink-0">
<Badge style={getOKRStatusColor(okr.status)} className="text-xs px-2 py-0.5">
{OKR_STATUS_LABELS[okr.status]}
</Badge>
{okr.keyResults && okr.keyResults.length > 0 && (
<span className="text-xs text-muted whitespace-nowrap">
{okr.keyResults.length} KR{okr.keyResults.length !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
</CardContent>
</Link>
</Card>
);
}
return (
<Card hover className="h-full relative group">
<CardHeader>
@@ -208,9 +318,7 @@ export function OKRCard({ okr, teamId, isAdmin = false }: OKRCardProps) {
{/* 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>
<Badge style={getOKRStatusColor(okr.status)}>{OKR_STATUS_LABELS[okr.status]}</Badge>
</div>
{/* Key Results List */}
@@ -223,7 +331,8 @@ export function OKRCard({ okr, teamId, isAdmin = false }: OKRCardProps) {
{okr.keyResults
.sort((a, b) => a.order - b.order)
.map((kr: KeyResult) => {
const krProgress = kr.targetValue > 0 ? (kr.currentValue / kr.targetValue) * 100 : 0;
const krProgress =
kr.targetValue > 0 ? (kr.currentValue / kr.targetValue) * 100 : 0;
const krProgressColor =
krProgress >= 100
? 'var(--success)'
@@ -234,7 +343,9 @@ export function OKRCard({ okr, teamId, isAdmin = false }: OKRCardProps) {
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>
<span className="text-sm text-foreground flex-1 line-clamp-2">
{kr.title}
</span>
<Badge
style={{
...getKeyResultStatusColor(kr.status),
@@ -276,4 +387,3 @@ export function OKRCard({ okr, teamId, isAdmin = false }: OKRCardProps) {
</Card>
);
}

View File

@@ -1,12 +1,13 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { OKRCard } from './OKRCard';
import { Card, ToggleGroup, type ToggleOption } from '@/components/ui';
import { Card, ToggleGroup } from '@/components/ui';
import { getGravatarUrl } from '@/lib/gravatar';
import type { OKR } from '@/lib/types';
type ViewMode = 'grid' | 'grouped';
type CardViewMode = 'detailed' | 'compact';
interface OKRsListProps {
okrsData: Array<{
@@ -21,8 +22,23 @@ interface OKRsListProps {
isAdmin?: boolean;
}
const CARD_VIEW_STORAGE_KEY = 'okr-card-view-mode';
export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
const [viewMode, setViewMode] = useState<ViewMode>('grouped');
const [cardViewMode, setCardViewMode] = useState<CardViewMode>(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(CARD_VIEW_STORAGE_KEY);
return (stored as CardViewMode) || 'detailed';
}
return 'detailed';
});
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem(CARD_VIEW_STORAGE_KEY, cardViewMode);
}
}, [cardViewMode]);
// Flatten OKRs for grid view
const allOKRs = okrsData.flatMap((tm) =>
@@ -39,9 +55,7 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
<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>
<p className="text-muted">Aucun OKR n&apos;a encore é défini pour cette équipe</p>
</Card>
);
}
@@ -49,8 +63,43 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
return (
<div>
{/* View Toggle */}
<div className="mb-6 flex items-center justify-between">
<div className="mb-6 flex items-center justify-between gap-4">
<h2 className="text-2xl font-bold text-foreground">OKRs</h2>
<div className="flex items-center gap-3">
<ToggleGroup
value={cardViewMode}
onChange={setCardViewMode}
options={[
{
value: 'detailed',
label: 'Détaillée',
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: 'compact',
label: 'Mini',
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 10h16M4 14h16M4 18h16"
/>
</svg>
),
},
]}
/>
<ToggleGroup
value={viewMode}
onChange={setViewMode}
@@ -60,7 +109,12 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
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" />
<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 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
),
},
@@ -81,6 +135,7 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
]}
/>
</div>
</div>
{/* Grouped View */}
{viewMode === 'grouped' ? (
@@ -113,7 +168,9 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
</div>
{/* OKRs Grid */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div
className={`grid gap-6 ${cardViewMode === 'compact' ? 'md:grid-cols-2 lg:grid-cols-3' : 'md:grid-cols-2 lg:grid-cols-3'}`}
>
{teamMember.okrs.map((okr) => (
<OKRCard
key={okr.id}
@@ -125,6 +182,7 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
}}
teamId={teamId}
isAdmin={isAdmin}
compact={cardViewMode === 'compact'}
/>
))}
</div>
@@ -133,13 +191,20 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
</div>
) : (
/* Grid View */
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div
className={`grid gap-6 ${cardViewMode === 'compact' ? 'md:grid-cols-2 lg:grid-cols-3' : 'md:grid-cols-2 lg:grid-cols-3'}`}
>
{allOKRs.map((okr) => (
<OKRCard key={okr.id} okr={okr} teamId={teamId} isAdmin={isAdmin} />
<OKRCard
key={okr.id}
okr={okr}
teamId={teamId}
isAdmin={isAdmin}
compact={cardViewMode === 'compact'}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import { useState, useEffect } from 'react';
import { OKRCard } from './OKRCard';
import { ToggleGroup } from '@/components/ui';
import type { OKR } from '@/lib/types';
type CardViewMode = 'detailed' | 'compact';
interface ObjectivesListProps {
okrsByPeriod: Record<
string,
Array<OKR & { progress?: number; team: { id: string; name: string } }>
>;
periods: string[];
}
const CARD_VIEW_STORAGE_KEY = 'okr-card-view-mode';
export function ObjectivesList({ okrsByPeriod, periods }: ObjectivesListProps) {
const [cardViewMode, setCardViewMode] = useState<CardViewMode>(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(CARD_VIEW_STORAGE_KEY);
return (stored as CardViewMode) || 'detailed';
}
return 'detailed';
});
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem(CARD_VIEW_STORAGE_KEY, cardViewMode);
}
}, [cardViewMode]);
return (
<div className="space-y-8">
{/* Global View Toggle */}
<div className="flex items-center justify-end mb-4">
<ToggleGroup
value={cardViewMode}
onChange={setCardViewMode}
options={[
{
value: 'detailed',
label: 'Détaillée',
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: 'compact',
label: 'Mini',
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 10h16M4 14h16M4 18h16"
/>
</svg>
),
},
]}
/>
</div>
{periods.map((period) => {
const periodOKRs = okrsByPeriod[period];
const totalProgress =
periodOKRs.reduce((sum, okr) => sum + (okr.progress || 0), 0) / periodOKRs.length;
return (
<div key={period} className="space-y-4">
{/* Period Header */}
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-3">
<span
className="rounded-lg px-3 py-1 text-sm font-medium"
style={{
backgroundColor: 'color-mix(in srgb, var(--purple) 15%, transparent)',
color: 'var(--purple)',
}}
>
{period}
</span>
<span className="text-sm text-muted">
{periodOKRs.length} OKR{periodOKRs.length !== 1 ? 's' : ''}
</span>
</div>
<div className="text-sm font-medium text-foreground">
Progression moyenne:{' '}
<span style={{ color: 'var(--primary)' }}>{Math.round(totalProgress)}%</span>
</div>
</div>
{/* OKRs Grid */}
<div
className={`grid gap-6 ${cardViewMode === 'compact' ? 'md:grid-cols-2 lg:grid-cols-3' : 'md:grid-cols-2 lg:grid-cols-3'}`}
>
{periodOKRs.map((okr) => (
<OKRCard
key={okr.id}
okr={okr}
teamId={okr.team.id}
compact={cardViewMode === 'compact'}
/>
))}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -2,4 +2,5 @@ export { OKRCard } from './OKRCard';
export { OKRForm } from './OKRForm';
export { KeyResultItem } from './KeyResultItem';
export { OKRsList } from './OKRsList';
export { ObjectivesList } from './ObjectivesList';