feat: add 'Manager' link to Header component
- Introduced a new navigation link for the 'Manager' page in the Header component, improving user access to management features.
This commit is contained in:
481
components/dashboard/ManagerWeeklySummary.tsx
Normal file
481
components/dashboard/ManagerWeeklySummary.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ManagerSummary } from '@/services/manager-summary';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||
import { getPriorityConfig } from '@/lib/status-config';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { format } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
interface ManagerWeeklySummaryProps {
|
||||
initialSummary: ManagerSummary;
|
||||
}
|
||||
|
||||
export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySummaryProps) {
|
||||
const [summary] = useState<ManagerSummary>(initialSummary);
|
||||
const [activeView, setActiveView] = useState<'narrative' | 'accomplishments' | 'challenges'>('narrative');
|
||||
const { tags: availableTags } = useTasksContext();
|
||||
|
||||
const handleRefresh = () => {
|
||||
// SSR - refresh via page reload
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
|
||||
const formatPeriod = () => {
|
||||
return `Semaine du ${format(summary.period.start, 'dd MMM', { locale: fr })} au ${format(summary.period.end, 'dd MMM yyyy', { locale: fr })}`;
|
||||
};
|
||||
|
||||
const getPriorityBadgeStyle = (priority: 'low' | 'medium' | 'high') => {
|
||||
const config = getPriorityConfig(priority);
|
||||
const baseClasses = 'text-xs px-2 py-0.5 rounded font-medium';
|
||||
|
||||
switch (config.color) {
|
||||
case 'blue':
|
||||
return `${baseClasses} bg-blue-100 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400`;
|
||||
case 'yellow':
|
||||
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400`;
|
||||
case 'purple':
|
||||
return `${baseClasses} bg-purple-100 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400`;
|
||||
case 'red':
|
||||
return `${baseClasses} bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400`;
|
||||
default:
|
||||
return `${baseClasses} bg-gray-100 dark:bg-gray-900/20 text-gray-600 dark:text-gray-400`;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header avec navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--foreground)]">👔 Résumé Manager</h1>
|
||||
<p className="text-[var(--muted-foreground)]">{formatPeriod()}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
🔄 Actualiser
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Navigation des vues */}
|
||||
<div className="border-b border-[var(--border)]">
|
||||
<nav className="flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveView('narrative')}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeView === 'narrative'
|
||||
? 'border-[var(--primary)] text-[var(--primary)]'
|
||||
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
📝 Vue Executive
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('accomplishments')}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeView === 'accomplishments'
|
||||
? 'border-[var(--primary)] text-[var(--primary)]'
|
||||
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
✅ Accomplissements ({summary.keyAccomplishments.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('challenges')}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeView === 'challenges'
|
||||
? 'border-[var(--primary)] text-[var(--primary)]'
|
||||
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
🎯 Enjeux à venir ({summary.upcomingChallenges.length})
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Vue Executive / Narrative */}
|
||||
{activeView === 'narrative' && (
|
||||
<div className="space-y-6">
|
||||
{/* Résumé narratif */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
📊 Résumé de la semaine
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg border-l-4 border-blue-400">
|
||||
<h3 className="font-medium text-blue-900 mb-2">🎯 Points clés accomplis</h3>
|
||||
<p className="text-blue-800">{summary.narrative.weekHighlight}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 p-4 rounded-lg border-l-4 border-yellow-400">
|
||||
<h3 className="font-medium text-yellow-900 mb-2">⚡ Défis traités</h3>
|
||||
<p className="text-yellow-800">{summary.narrative.mainChallenges}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 p-4 rounded-lg border-l-4 border-green-400">
|
||||
<h3 className="font-medium text-green-900 mb-2">🔮 Focus semaine prochaine</h3>
|
||||
<p className="text-green-800">{summary.narrative.nextWeekFocus}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Métriques rapides */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">📈 Métriques en bref</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{summary.metrics.totalTasksCompleted}
|
||||
</div>
|
||||
<div className="text-sm text-blue-600">Tâches complétées</div>
|
||||
<div className="text-xs text-blue-500">
|
||||
dont {summary.metrics.highPriorityTasksCompleted} priorité haute
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-green-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{summary.metrics.totalCheckboxesCompleted}
|
||||
</div>
|
||||
<div className="text-sm text-green-600">Todos complétés</div>
|
||||
<div className="text-xs text-green-500">
|
||||
dont {summary.metrics.meetingCheckboxesCompleted} meetings
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-purple-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{summary.keyAccomplishments.filter(a => a.impact === 'high').length}
|
||||
</div>
|
||||
<div className="text-sm text-purple-600">Items à fort impact</div>
|
||||
<div className="text-xs text-purple-500">
|
||||
/ {summary.keyAccomplishments.length} accomplissements
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-orange-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{summary.upcomingChallenges.filter(c => c.priority === 'high').length}
|
||||
</div>
|
||||
<div className="text-sm text-orange-600">Priorités critiques</div>
|
||||
<div className="text-xs text-orange-500">
|
||||
/ {summary.upcomingChallenges.length} enjeux
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top accomplissements */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">🏆 Top accomplissements</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{summary.keyAccomplishments.length === 0 ? (
|
||||
<div className="col-span-3 text-center py-8 text-[var(--muted-foreground)]">
|
||||
<p>Aucun accomplissement significatif trouvé cette semaine.</p>
|
||||
<p className="text-sm mt-2">Ajoutez des tâches avec priorité haute/medium ou des meetings.</p>
|
||||
</div>
|
||||
) : (
|
||||
summary.keyAccomplishments.slice(0, 6).map((accomplishment, index) => (
|
||||
<div
|
||||
key={accomplishment.id}
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 hover:bg-green-50/50 dark:hover:bg-green-950/20 transition-all duration-200 group"
|
||||
>
|
||||
{/* Barre colorée gauche */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-green-500 rounded-l-lg"></div>
|
||||
|
||||
{/* Header compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-xs font-bold flex items-center justify-center">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className={getPriorityBadgeStyle(accomplishment.impact)}>
|
||||
{getPriorityConfig(accomplishment.impact).label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{format(accomplishment.completedAt, 'dd/MM', { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
|
||||
{accomplishment.title}
|
||||
</h4>
|
||||
|
||||
{/* Tags */}
|
||||
{accomplishment.tags && accomplishment.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<TagDisplay
|
||||
tags={accomplishment.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
maxTags={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description si disponible */}
|
||||
{accomplishment.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
|
||||
{accomplishment.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Count de todos */}
|
||||
{accomplishment.todosCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
|
||||
<span>📋</span>
|
||||
<span>{accomplishment.todosCount} todo{accomplishment.todosCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top challenges */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">🎯 Top enjeux à venir</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{summary.upcomingChallenges.length === 0 ? (
|
||||
<div className="col-span-3 text-center py-8 text-[var(--muted-foreground)]">
|
||||
<p>Aucun enjeu prioritaire trouvé.</p>
|
||||
<p className="text-sm mt-2">Ajoutez des tâches non complétées avec priorité haute/medium.</p>
|
||||
</div>
|
||||
) : (
|
||||
summary.upcomingChallenges.slice(0, 6).map((challenge, index) => (
|
||||
<div
|
||||
key={challenge.id}
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 hover:bg-orange-50/50 dark:hover:bg-orange-950/20 transition-all duration-200 group"
|
||||
>
|
||||
{/* Barre colorée gauche */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-orange-500 rounded-l-lg"></div>
|
||||
|
||||
{/* Header compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-full text-xs font-bold flex items-center justify-center">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className={getPriorityBadgeStyle(challenge.priority)}>
|
||||
{getPriorityConfig(challenge.priority).label}
|
||||
</span>
|
||||
</div>
|
||||
{challenge.deadline && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{format(challenge.deadline, 'dd/MM', { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
|
||||
{challenge.title}
|
||||
</h4>
|
||||
|
||||
{/* Tags */}
|
||||
{challenge.tags && challenge.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<TagDisplay
|
||||
tags={challenge.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
maxTags={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description si disponible */}
|
||||
{challenge.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
|
||||
{challenge.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Count de todos */}
|
||||
{challenge.todosCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
|
||||
<span>📋</span>
|
||||
<span>{challenge.todosCount} todo{challenge.todosCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vue détaillée des accomplissements */}
|
||||
{activeView === 'accomplishments' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">✅ Accomplissements de la semaine</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{summary.keyAccomplishments.length} accomplissements significatifs • {summary.metrics.totalTasksCompleted} tâches • {summary.metrics.totalCheckboxesCompleted} todos complétés
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{summary.keyAccomplishments.map((accomplishment, index) => (
|
||||
<div
|
||||
key={accomplishment.id}
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 hover:bg-green-50/50 dark:hover:bg-green-950/20 transition-all duration-200 group"
|
||||
>
|
||||
{/* Barre colorée gauche */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-green-500 rounded-l-lg"></div>
|
||||
|
||||
{/* Header compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-xs font-bold flex items-center justify-center">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className={getPriorityBadgeStyle(accomplishment.impact)}>
|
||||
{getPriorityConfig(accomplishment.impact).label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{format(accomplishment.completedAt, 'dd/MM', { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
|
||||
{accomplishment.title}
|
||||
</h4>
|
||||
|
||||
{/* Tags */}
|
||||
{accomplishment.tags && accomplishment.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<TagDisplay
|
||||
tags={accomplishment.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
maxTags={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description si disponible */}
|
||||
{accomplishment.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] line-clamp-3 leading-relaxed mb-2">
|
||||
{accomplishment.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Count de todos */}
|
||||
{accomplishment.todosCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
|
||||
<span>📋</span>
|
||||
<span>{accomplishment.todosCount} todo{accomplishment.todosCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Vue détaillée des challenges */}
|
||||
{activeView === 'challenges' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">🎯 Enjeux et défis à venir</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{summary.upcomingChallenges.length} défis identifiés • {summary.upcomingChallenges.filter(c => c.priority === 'high').length} priorité haute • {summary.upcomingChallenges.filter(c => c.blockers.length > 0).length} avec blockers
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{summary.upcomingChallenges.map((challenge, index) => (
|
||||
<div
|
||||
key={challenge.id}
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 hover:bg-orange-50/50 dark:hover:bg-orange-950/20 transition-all duration-200 group"
|
||||
>
|
||||
{/* Barre colorée gauche */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-orange-500 rounded-l-lg"></div>
|
||||
|
||||
{/* Header compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-full text-xs font-bold flex items-center justify-center">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className={getPriorityBadgeStyle(challenge.priority)}>
|
||||
{getPriorityConfig(challenge.priority).label}
|
||||
</span>
|
||||
</div>
|
||||
{challenge.deadline && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{format(challenge.deadline, 'dd/MM', { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
|
||||
{challenge.title}
|
||||
</h4>
|
||||
|
||||
{/* Tags */}
|
||||
{challenge.tags && challenge.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<TagDisplay
|
||||
tags={challenge.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
maxTags={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description si disponible */}
|
||||
{challenge.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] line-clamp-3 leading-relaxed mb-2">
|
||||
{challenge.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Count de todos */}
|
||||
{challenge.todosCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
|
||||
<span>📋</span>
|
||||
<span>{challenge.todosCount} todo{challenge.todosCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export function Header({ title = "TowerControl", subtitle = "Task Management", s
|
||||
{ href: '/kanban', label: 'Kanban' },
|
||||
{ href: '/daily', label: 'Daily' },
|
||||
{ href: '/weekly-summary', label: 'Hebdo' },
|
||||
{ href: '/weekly-manager', label: 'Manager' },
|
||||
...(isJiraConfigured ? [{ href: '/jira-dashboard', label: `Jira${jiraConfig?.projectKey ? ` (${jiraConfig.projectKey})` : ''}` }] : []),
|
||||
{ href: '/settings', label: 'Settings' }
|
||||
];
|
||||
|
||||
563
services/manager-summary.ts
Normal file
563
services/manager-summary.ts
Normal file
@@ -0,0 +1,563 @@
|
||||
import { prisma } from './database';
|
||||
import { startOfWeek, endOfWeek } from 'date-fns';
|
||||
|
||||
type TaskType = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
priority: string; // high, medium, low
|
||||
completedAt?: Date | null;
|
||||
createdAt: Date;
|
||||
taskTags?: {
|
||||
tag: {
|
||||
name: string;
|
||||
}
|
||||
}[];
|
||||
};
|
||||
|
||||
type CheckboxType = {
|
||||
id: string;
|
||||
text: string;
|
||||
isChecked: boolean;
|
||||
type: string; // task, meeting
|
||||
date: Date;
|
||||
createdAt: Date;
|
||||
task?: {
|
||||
id: string;
|
||||
title: string;
|
||||
priority: string;
|
||||
taskTags?: {
|
||||
tag: {
|
||||
name: string;
|
||||
}
|
||||
}[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
export interface KeyAccomplishment {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
impact: 'high' | 'medium' | 'low';
|
||||
completedAt: Date;
|
||||
relatedItems: string[]; // IDs des tâches/checkboxes liées
|
||||
todosCount: number; // Nombre de todos associés
|
||||
}
|
||||
|
||||
export interface UpcomingChallenge {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
estimatedEffort: 'days' | 'weeks' | 'hours';
|
||||
blockers: string[];
|
||||
deadline?: Date;
|
||||
relatedItems: string[]; // IDs des tâches/checkboxes liées
|
||||
todosCount: number; // Nombre de todos associés
|
||||
}
|
||||
|
||||
export interface ManagerSummary {
|
||||
period: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
keyAccomplishments: KeyAccomplishment[];
|
||||
upcomingChallenges: UpcomingChallenge[];
|
||||
metrics: {
|
||||
totalTasksCompleted: number;
|
||||
totalCheckboxesCompleted: number;
|
||||
highPriorityTasksCompleted: number;
|
||||
meetingCheckboxesCompleted: number;
|
||||
completionRate: number;
|
||||
focusAreas: { [category: string]: number };
|
||||
};
|
||||
narrative: {
|
||||
weekHighlight: string;
|
||||
mainChallenges: string;
|
||||
nextWeekFocus: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class ManagerSummaryService {
|
||||
/**
|
||||
* Génère un résumé orienté manager pour la semaine
|
||||
*/
|
||||
static async getManagerSummary(date: Date = new Date()): Promise<ManagerSummary> {
|
||||
const weekStart = startOfWeek(date, { weekStartsOn: 1 }); // Lundi
|
||||
const weekEnd = endOfWeek(date, { weekStartsOn: 1 }); // Dimanche
|
||||
|
||||
// Récupérer les données de base
|
||||
const [tasks, checkboxes] = await Promise.all([
|
||||
this.getCompletedTasks(weekStart, weekEnd),
|
||||
this.getCompletedCheckboxes(weekStart, weekEnd)
|
||||
]);
|
||||
|
||||
// Analyser et extraire les accomplissements clés
|
||||
const keyAccomplishments = this.extractKeyAccomplishments(tasks, checkboxes);
|
||||
|
||||
// Identifier les défis à venir
|
||||
const upcomingChallenges = await this.identifyUpcomingChallenges();
|
||||
|
||||
// Calculer les métriques
|
||||
const metrics = this.calculateMetrics(tasks, checkboxes);
|
||||
|
||||
// Générer le narratif
|
||||
const narrative = this.generateNarrative(keyAccomplishments, upcomingChallenges);
|
||||
|
||||
return {
|
||||
period: { start: weekStart, end: weekEnd },
|
||||
keyAccomplishments,
|
||||
upcomingChallenges,
|
||||
metrics,
|
||||
narrative
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les tâches complétées de la semaine
|
||||
*/
|
||||
private static async getCompletedTasks(startDate: Date, endDate: Date) {
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
// Tâches avec completedAt dans la période (priorité)
|
||||
{
|
||||
completedAt: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
},
|
||||
// Tâches avec status 'done' et updatedAt dans la période
|
||||
{
|
||||
status: 'done',
|
||||
updatedAt: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
},
|
||||
// Tâches avec status 'archived' récemment (aussi des accomplissements)
|
||||
{
|
||||
status: 'archived',
|
||||
updatedAt: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
orderBy: {
|
||||
completedAt: 'desc'
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
priority: true,
|
||||
completedAt: true,
|
||||
createdAt: true,
|
||||
taskTags: {
|
||||
select: {
|
||||
tag: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les checkboxes complétées de la semaine
|
||||
*/
|
||||
private static async getCompletedCheckboxes(startDate: Date, endDate: Date) {
|
||||
const checkboxes = await prisma.dailyCheckbox.findMany({
|
||||
where: {
|
||||
isChecked: true,
|
||||
date: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
text: true,
|
||||
isChecked: true,
|
||||
type: true,
|
||||
date: true,
|
||||
createdAt: true,
|
||||
task: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
priority: true,
|
||||
taskTags: {
|
||||
select: {
|
||||
tag: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
return checkboxes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait les accomplissements clés basés sur la priorité
|
||||
*/
|
||||
private static extractKeyAccomplishments(tasks: TaskType[], checkboxes: CheckboxType[]): KeyAccomplishment[] {
|
||||
const accomplishments: KeyAccomplishment[] = [];
|
||||
|
||||
// Tâches: prendre toutes les high/medium priority, et quelques low si significatives
|
||||
tasks.forEach(task => {
|
||||
const priority = task.priority.toLowerCase();
|
||||
|
||||
// Convertir priorité task en impact accomplissement
|
||||
let impact: 'high' | 'medium' | 'low';
|
||||
if (priority === 'high') {
|
||||
impact = 'high';
|
||||
} else if (priority === 'medium') {
|
||||
impact = 'medium';
|
||||
} else {
|
||||
// Pour les low priority, ne garder que si c'est vraiment significatif
|
||||
if (!this.isSignificantTask(task.title)) {
|
||||
return;
|
||||
}
|
||||
impact = 'low';
|
||||
}
|
||||
|
||||
// Compter les todos (checkboxes) associés à cette tâche
|
||||
const relatedTodos = checkboxes.filter(cb => cb.task?.id === task.id);
|
||||
|
||||
accomplishments.push({
|
||||
id: `task-${task.id}`,
|
||||
title: task.title,
|
||||
description: task.description || undefined,
|
||||
tags: task.taskTags?.map(tt => tt.tag.name) || [],
|
||||
impact,
|
||||
completedAt: task.completedAt || new Date(),
|
||||
relatedItems: [task.id, ...relatedTodos.map(t => t.id)],
|
||||
todosCount: relatedTodos.length // Nombre réel de todos associés
|
||||
});
|
||||
});
|
||||
|
||||
// AJOUTER SEULEMENT les meetings importants standalone (non liés à une tâche)
|
||||
const standaloneMeetings = checkboxes.filter(checkbox =>
|
||||
checkbox.type === 'meeting' && !checkbox.task // Meetings non liés à une tâche
|
||||
);
|
||||
|
||||
standaloneMeetings.forEach(meeting => {
|
||||
accomplishments.push({
|
||||
id: `meeting-${meeting.id}`,
|
||||
title: `📅 ${meeting.text}`,
|
||||
tags: [], // Meetings n'ont pas de tags par défaut
|
||||
impact: 'medium', // Meetings sont importants
|
||||
completedAt: meeting.date,
|
||||
relatedItems: [meeting.id],
|
||||
todosCount: 1 // Un meeting = 1 todo
|
||||
});
|
||||
});
|
||||
|
||||
// Trier par impact puis par date
|
||||
return accomplishments
|
||||
.sort((a, b) => {
|
||||
const impactOrder = { high: 3, medium: 2, low: 1 };
|
||||
if (impactOrder[a.impact] !== impactOrder[b.impact]) {
|
||||
return impactOrder[b.impact] - impactOrder[a.impact];
|
||||
}
|
||||
return b.completedAt.getTime() - a.completedAt.getTime();
|
||||
})
|
||||
.slice(0, 12); // Plus d'items maintenant qu'on filtre mieux
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifie les défis et enjeux à venir
|
||||
*/
|
||||
private static async identifyUpcomingChallenges(): Promise<UpcomingChallenge[]> {
|
||||
|
||||
// Récupérer les tâches à venir (priorité high/medium en premier)
|
||||
const upcomingTasks = await prisma.task.findMany({
|
||||
where: {
|
||||
completedAt: null
|
||||
},
|
||||
orderBy: [
|
||||
{ priority: 'asc' }, // high < medium < low
|
||||
{ createdAt: 'desc' }
|
||||
],
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
priority: true,
|
||||
createdAt: true,
|
||||
taskTags: {
|
||||
select: {
|
||||
tag: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
take: 30
|
||||
});
|
||||
|
||||
// Récupérer les checkboxes récurrentes non complétées (meetings + tâches prioritaires)
|
||||
const upcomingCheckboxes = await prisma.dailyCheckbox.findMany({
|
||||
where: {
|
||||
isChecked: false,
|
||||
date: {
|
||||
gte: new Date()
|
||||
},
|
||||
OR: [
|
||||
{ type: 'meeting' },
|
||||
{
|
||||
task: {
|
||||
priority: {
|
||||
in: ['high', 'medium']
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
text: true,
|
||||
isChecked: true,
|
||||
type: true,
|
||||
date: true,
|
||||
createdAt: true,
|
||||
task: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
priority: true,
|
||||
taskTags: {
|
||||
select: {
|
||||
tag: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: [
|
||||
{ date: 'asc' },
|
||||
{ createdAt: 'asc' }
|
||||
],
|
||||
take: 20
|
||||
});
|
||||
|
||||
const challenges: UpcomingChallenge[] = [];
|
||||
|
||||
// Analyser les tâches - se baser sur la priorité réelle
|
||||
upcomingTasks.forEach((task) => {
|
||||
const taskPriority = task.priority.toLowerCase();
|
||||
|
||||
// Convertir priorité task en priorité challenge
|
||||
let priority: 'high' | 'medium' | 'low';
|
||||
if (taskPriority === 'high') {
|
||||
priority = 'high';
|
||||
} else if (taskPriority === 'medium') {
|
||||
priority = 'medium';
|
||||
} else {
|
||||
// Pour les low priority, ne garder que si c'est vraiment challengeant
|
||||
if (!this.isChallengingTask(task.title)) {
|
||||
return;
|
||||
}
|
||||
priority = 'low';
|
||||
}
|
||||
|
||||
const estimatedEffort = this.estimateEffort(task.title, task.description || undefined);
|
||||
|
||||
challenges.push({
|
||||
id: `task-${task.id}`,
|
||||
title: task.title,
|
||||
description: task.description || undefined,
|
||||
tags: task.taskTags?.map(tt => tt.tag.name) || [],
|
||||
priority,
|
||||
estimatedEffort,
|
||||
blockers: this.identifyBlockers(task.title, task.description || undefined),
|
||||
relatedItems: [task.id],
|
||||
todosCount: 0 // TODO: compter les todos associés à cette tâche
|
||||
});
|
||||
});
|
||||
|
||||
// Ajouter les meetings importants comme challenges
|
||||
upcomingCheckboxes.forEach(checkbox => {
|
||||
if (checkbox.type === 'meeting') {
|
||||
challenges.push({
|
||||
id: `checkbox-${checkbox.id}`,
|
||||
title: checkbox.text,
|
||||
tags: checkbox.task?.taskTags?.map(tt => tt.tag.name) || [],
|
||||
priority: 'medium', // Meetings sont medium par défaut
|
||||
estimatedEffort: 'hours',
|
||||
blockers: [],
|
||||
relatedItems: [checkbox.id],
|
||||
todosCount: 1 // Une checkbox = 1 todo
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return challenges
|
||||
.sort((a, b) => {
|
||||
const priorityOrder = { high: 3, medium: 2, low: 1 };
|
||||
return priorityOrder[b.priority] - priorityOrder[a.priority];
|
||||
})
|
||||
.slice(0, 10); // Plus d'items maintenant qu'on filtre mieux
|
||||
}
|
||||
|
||||
/**
|
||||
* Estime l'effort requis
|
||||
*/
|
||||
private static estimateEffort(title: string, description?: string): 'days' | 'weeks' | 'hours' {
|
||||
const content = `${title} ${description || ''}`.toLowerCase();
|
||||
|
||||
if (content.includes('architecture') || content.includes('migration') || content.includes('refactor')) {
|
||||
return 'weeks';
|
||||
}
|
||||
if (content.includes('feature') || content.includes('implement') || content.includes('integration')) {
|
||||
return 'days';
|
||||
}
|
||||
return 'hours';
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifie les blockers potentiels
|
||||
*/
|
||||
private static identifyBlockers(title: string, description?: string): string[] {
|
||||
const content = `${title} ${description || ''}`.toLowerCase();
|
||||
const blockers: string[] = [];
|
||||
|
||||
if (content.includes('depends') || content.includes('waiting')) {
|
||||
blockers.push('Dépendances externes');
|
||||
}
|
||||
if (content.includes('approval') || content.includes('review')) {
|
||||
blockers.push('Validation requise');
|
||||
}
|
||||
if (content.includes('design') && !content.includes('implement')) {
|
||||
blockers.push('Spécifications incomplètes');
|
||||
}
|
||||
|
||||
return blockers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine si une tâche est significative
|
||||
*/
|
||||
private static isSignificantTask(title: string): boolean {
|
||||
const significantKeywords = [
|
||||
'release', 'deploy', 'launch', 'milestone',
|
||||
'architecture', 'design', 'strategy',
|
||||
'integration', 'migration', 'optimization'
|
||||
];
|
||||
return significantKeywords.some(keyword => title.toLowerCase().includes(keyword));
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine si une checkbox est significative
|
||||
*/
|
||||
private static isSignificantCheckbox(text: string): boolean {
|
||||
const content = text.toLowerCase();
|
||||
return content.length > 30 || // Checkboxes détaillées
|
||||
content.includes('meeting') ||
|
||||
content.includes('review') ||
|
||||
content.includes('call') ||
|
||||
content.includes('presentation');
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine si une tâche représente un défi
|
||||
*/
|
||||
private static isChallengingTask(title: string): boolean {
|
||||
const challengingKeywords = [
|
||||
'complex', 'difficult', 'challenge',
|
||||
'architecture', 'performance', 'security',
|
||||
'integration', 'migration', 'optimization'
|
||||
];
|
||||
return challengingKeywords.some(keyword => title.toLowerCase().includes(keyword));
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse les patterns dans les checkboxes pour identifier des enjeux
|
||||
*/
|
||||
private static analyzeCheckboxPatterns(): UpcomingChallenge[] {
|
||||
// Pour l'instant, retourner un array vide
|
||||
// À implémenter selon les besoins spécifiques
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les métriques résumées
|
||||
*/
|
||||
private static calculateMetrics(tasks: TaskType[], checkboxes: CheckboxType[]) {
|
||||
const totalTasksCompleted = tasks.length;
|
||||
const totalCheckboxesCompleted = checkboxes.length;
|
||||
|
||||
// Calculer les métriques détaillées
|
||||
const highPriorityTasksCompleted = tasks.filter(t => t.priority.toLowerCase() === 'high').length;
|
||||
const meetingCheckboxesCompleted = checkboxes.filter(c => c.type === 'meeting').length;
|
||||
|
||||
// Analyser la répartition par catégorie
|
||||
const focusAreas: { [category: string]: number } = {};
|
||||
|
||||
return {
|
||||
totalTasksCompleted,
|
||||
totalCheckboxesCompleted,
|
||||
highPriorityTasksCompleted,
|
||||
meetingCheckboxesCompleted,
|
||||
completionRate: 0, // À calculer par rapport aux objectifs
|
||||
focusAreas
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le narratif pour le manager
|
||||
*/
|
||||
private static generateNarrative(
|
||||
accomplishments: KeyAccomplishment[],
|
||||
challenges: UpcomingChallenge[]
|
||||
) {
|
||||
// Points forts de la semaine
|
||||
const topAccomplishments = accomplishments.slice(0, 3);
|
||||
const weekHighlight = topAccomplishments.length > 0
|
||||
? `Cette semaine, j'ai principalement progressé sur ${topAccomplishments.map(a => a.title).join(', ')}.`
|
||||
: 'Semaine focalisée sur l\'exécution des tâches quotidiennes.';
|
||||
|
||||
// Défis rencontrés
|
||||
const highImpactItems = accomplishments.filter(a => a.impact === 'high');
|
||||
const mainChallenges = highImpactItems.length > 0
|
||||
? `Les principaux enjeux traités ont été liés aux ${[...new Set(highImpactItems.flatMap(a => a.tags))].join(', ')}.`
|
||||
: 'Pas de blockers majeurs rencontrés cette semaine.';
|
||||
|
||||
// Focus semaine prochaine
|
||||
const topChallenges = challenges.slice(0, 3);
|
||||
const nextWeekFocus = topChallenges.length > 0
|
||||
? `La semaine prochaine sera concentrée sur ${topChallenges.map(c => c.title).join(', ')}.`
|
||||
: 'Continuation du travail en cours selon les priorités établies.';
|
||||
|
||||
return {
|
||||
weekHighlight,
|
||||
mainChallenges,
|
||||
nextWeekFocus
|
||||
};
|
||||
}
|
||||
}
|
||||
32
src/app/weekly-manager/WeeklyManagerPageClient.tsx
Normal file
32
src/app/weekly-manager/WeeklyManagerPageClient.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { TasksProvider } from '@/contexts/TasksContext';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import ManagerWeeklySummary from '@/components/dashboard/ManagerWeeklySummary';
|
||||
import { ManagerSummary } from '@/services/manager-summary';
|
||||
import { Task, Tag, UserPreferences } from '@/lib/types';
|
||||
|
||||
interface WeeklyManagerPageClientProps {
|
||||
initialSummary: ManagerSummary;
|
||||
initialTasks: Task[];
|
||||
initialTags: (Tag & { usage: number })[];
|
||||
initialPreferences: UserPreferences;
|
||||
}
|
||||
|
||||
export function WeeklyManagerPageClient({
|
||||
initialSummary,
|
||||
initialTasks,
|
||||
initialTags,
|
||||
initialPreferences
|
||||
}: WeeklyManagerPageClientProps) {
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<TasksProvider
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
>
|
||||
<ManagerWeeklySummary initialSummary={initialSummary} />
|
||||
</TasksProvider>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
36
src/app/weekly-manager/page.tsx
Normal file
36
src/app/weekly-manager/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { ManagerSummaryService } from '@/services/manager-summary';
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { WeeklyManagerPageClient } from './WeeklyManagerPageClient';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function WeeklyManagerPage() {
|
||||
// SSR - Récupération des données côté serveur
|
||||
const [summary, initialTasks, initialTags, initialPreferences] = await Promise.all([
|
||||
ManagerSummaryService.getManagerSummary(),
|
||||
tasksService.getTasks(),
|
||||
tagsService.getTags(),
|
||||
userPreferencesService.getAllPreferences()
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header />
|
||||
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<WeeklyManagerPageClient
|
||||
initialSummary={summary}
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
initialPreferences={initialPreferences}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user