Compare commits
9 Commits
backup_bef
...
feat/metri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
339661aa13 | ||
|
|
9d0b6da3a0 | ||
|
|
888e81d15d | ||
|
|
c4d8bacd97 | ||
|
|
d6722e90d1 | ||
|
|
f16ae2e017 | ||
|
|
fded7d0078 | ||
|
|
f9c0035c82 | ||
|
|
dfeac94993 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -41,5 +41,6 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
/prisma/dev.db
|
||||
|
||||
backups/
|
||||
128
TODO.md
128
TODO.md
@@ -305,134 +305,6 @@ Endpoints complexes → API Routes conservées
|
||||
- [x] Vue détaillée par sprint avec drill-down
|
||||
- [x] ~~Intégration avec les daily notes (mentions des blockers)~~ (supprimé)
|
||||
|
||||
## 📊 Phase 5.6: Résumé hebdomadaire pour Individual Review (EN COURS)
|
||||
|
||||
### 5.6.1 Fonctionnalités de base (TERMINÉ)
|
||||
- [x] Vue résumé des 7 derniers jours (daily items + tâches)
|
||||
- [x] Statistiques globales (completion rates, jour le plus productif)
|
||||
- [x] Timeline chronologique des activités
|
||||
- [x] Filtrage par jour de la semaine
|
||||
- [x] Architecture SSR pour performance optimale
|
||||
|
||||
### 5.6.2 Améliorations pour l'Individual Review Manager 🎯
|
||||
- [ ] **Métriques de performance personnelles**
|
||||
- [ ] Vélocité hebdomadaire (tasks completed/week)
|
||||
- [ ] Temps moyen de completion des tâches
|
||||
- [ ] Répartition par priorité (high/medium/low tasks)
|
||||
- [ ] Taux de respect des deadlines
|
||||
- [ ] Evolution des performances sur 4 semaines (tendance)
|
||||
|
||||
- [ ] **Catégorisation des activités professionnelles**
|
||||
- [ ] Auto-tagging par type : "Development", "Meetings", "Documentation", "Code Review"
|
||||
- [ ] Répartition temps par catégorie (% dev vs meetings vs admin)
|
||||
- [ ] Identification des "deep work" sessions vs interruptions
|
||||
- [ ] Tracking des objectifs OKRs/KPIs assignés
|
||||
|
||||
- [ ] **Visualisations pour manager**
|
||||
- [ ] Graphique en aires : progression hebdomadaire
|
||||
- [ ] Heatmap de productivité : heures/jours les plus productifs
|
||||
- [ ] Radar chart : compétences/domaines travaillés
|
||||
- [ ] Burndown chart personnel : objectifs vs réalisé
|
||||
|
||||
- [ ] **Rapport automatique formaté**
|
||||
- [ ] Export PDF professionnel avec métriques
|
||||
- [ ] Template "Weekly Accomplishments" pré-rempli
|
||||
- [ ] Bullet points des principales réalisations
|
||||
- [ ] Section "Challenges & Blockers" automatique
|
||||
- [ ] Recommandations d'amélioration basées sur les patterns
|
||||
|
||||
- [ ] **Contexte business et impact**
|
||||
- [ ] Liaison tâches → tickets Jira → business value
|
||||
- [ ] Calcul d'impact estimé (story points, business priority)
|
||||
- [ ] Suivi des initiatives stratégiques
|
||||
- [ ] Corrélation avec les métriques d'équipe
|
||||
|
||||
- [ ] **Intelligence et insights**
|
||||
- [ ] Détection patterns de productivité personnels
|
||||
- [ ] Suggestions d'optimisation du planning
|
||||
- [ ] Alertes sur la charge de travail excessive
|
||||
- [ ] Comparaison avec moyennes d'équipe (anonyme)
|
||||
- [ ] Prédiction de capacity pour la semaine suivante
|
||||
|
||||
- [ ] **Fonctionnalités avancées pour 1-on-1**
|
||||
- [ ] Mode "Manager View" : vue consolidée pour discussions
|
||||
- [ ] Annotations et notes privées sur les réalisations
|
||||
- [ ] Objectifs SMART tracking avec progress bars
|
||||
- [ ] Archivage des reviews précédentes pour suivi long terme
|
||||
- [ ] Templates de questions pour auto-reflection
|
||||
|
||||
### 5.6.3 Intégrations externes pour contexte pro
|
||||
- [ ] **Import calendrier** : Meetings duration & frequency
|
||||
- [ ] **GitHub/GitLab integration** : Commits, PRs, code reviews
|
||||
- [ ] **Slack integration** : Messages envoyés, réactions, temps de réponse
|
||||
- [ ] **Confluence/Notion** : Documents créés/édités
|
||||
- [ ] **Time tracking tools** : Import depuis Toggl, Clockify, etc.
|
||||
|
||||
### 5.6.4 Machine Learning & Predictions
|
||||
- [ ] **Modèle de productivité personnelle**
|
||||
- [ ] Prédiction des jours de forte/faible productivité
|
||||
- [ ] Recommandations de planning optimal
|
||||
- [ ] Détection automatique de burnout patterns
|
||||
- [ ] Suggestions de breaks et équilibre work-life
|
||||
|
||||
- [ ] **Insights business automatiques**
|
||||
- [ ] "Cette semaine, tu as contribué à 3 initiatives stratégiques"
|
||||
- [ ] "Ton focus sur la qualité (code reviews) est 20% au-dessus de la moyenne"
|
||||
- [ ] "Suggestion: bloquer 2h demain pour deep work sur Project X"
|
||||
|
||||
### 🚀 Quick Wins pour démarrer (Priorité 1)
|
||||
- [ ] **Métriques de vélocité personnelle** (1-2h)
|
||||
- [ ] Calcul tâches complétées par jour/semaine
|
||||
- [ ] Graphique simple ligne de tendance sur 4 semaines
|
||||
- [ ] Comparaison semaine actuelle vs semaine précédente
|
||||
|
||||
- [ ] **Export PDF basique** (2-3h)
|
||||
- [ ] Génération PDF simple avec statistiques actuelles
|
||||
- [ ] Template "Weekly Summary" avec logo/header pro
|
||||
- [ ] Liste des principales réalisations de la semaine
|
||||
|
||||
- [ ] **Catégorisation simple par tags** (1h)
|
||||
- [ ] Tags prédéfinis : "Dev", "Meeting", "Admin", "Learning"
|
||||
- [ ] Auto-suggestion basée sur mots-clés dans les titres
|
||||
- [ ] Répartition en camembert par catégorie
|
||||
|
||||
- [ ] **Connexion Jira pour contexte business** (3-4h)
|
||||
- [ ] Affichage des story points complétés
|
||||
- [ ] Lien vers les tickets Jira depuis les tâches
|
||||
- [ ] Récap des sprints/epics contributés
|
||||
|
||||
- [ ] **Période flexible** (1h)
|
||||
- [ ] Sélecteur de période : dernière semaine, 2 semaines, mois
|
||||
- [ ] Comparaison période courante vs période précédente
|
||||
- [ ] Sauvegarde de la période préférée
|
||||
|
||||
### 💡 Idées spécifiques pour Individual Review
|
||||
|
||||
#### **Sections du rapport idéal :**
|
||||
1. **Executive Summary** (3-4 bullet points impact business)
|
||||
2. **Quantified Achievements** (metrics, numbers, scope)
|
||||
3. **Technical Contributions** (code, architecture, tools)
|
||||
4. **Collaboration Impact** (reviews, mentoring, knowledge sharing)
|
||||
5. **Process Improvements** (efficiency gains, automation)
|
||||
6. **Learning & Growth** (new skills, certifications, initiatives)
|
||||
7. **Challenges & Solutions** (blockers overcome, lessons learned)
|
||||
8. **Next Period Goals** (SMART objectives, capacity planning)
|
||||
|
||||
#### **Métriques qui impressionnent un manager :**
|
||||
- **Velocity & Consistency** : "Completed 23 tasks with 94% on-time delivery"
|
||||
- **Quality Focus** : "15 code reviews provided, 0 production bugs"
|
||||
- **Initiative** : "Automated deployment reducing release time by 30%"
|
||||
- **Business Impact** : "Features delivered serve 10K+ users daily"
|
||||
- **Collaboration** : "Mentored 2 junior devs, led 3 technical sessions"
|
||||
- **Efficiency** : "Process optimization saved team 5h/week"
|
||||
|
||||
#### **Questions auto-reflection intégrées :**
|
||||
- "What was your biggest technical achievement this week?"
|
||||
- "Which tasks had the highest business impact?"
|
||||
- "What blockers did you encounter and how did you solve them?"
|
||||
- "What did you learn that you can share with the team?"
|
||||
- "What would you do differently next week?"
|
||||
|
||||
## Autre Todos #2
|
||||
- [ ] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
|
||||
- [ ] refacto des allpreferences : ca devrait eter un contexte dans le layout qui balance serverside dans le hook
|
||||
|
||||
146
components/dashboard/CategoryBreakdown.tsx
Normal file
146
components/dashboard/CategoryBreakdown.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
interface CategoryData {
|
||||
count: number;
|
||||
percentage: number;
|
||||
color: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface CategoryBreakdownProps {
|
||||
categoryData: { [categoryName: string]: CategoryData };
|
||||
totalActivities: number;
|
||||
}
|
||||
|
||||
export function CategoryBreakdown({ categoryData, totalActivities }: CategoryBreakdownProps) {
|
||||
const categories = Object.entries(categoryData)
|
||||
.filter(([, data]) => data.count > 0)
|
||||
.sort((a, b) => b[1].count - a[1].count);
|
||||
|
||||
if (categories.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📊 Répartition par catégorie</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-[var(--muted-foreground)]">
|
||||
Aucune activité à catégoriser
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📊 Répartition par catégorie</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Analyse automatique de vos {totalActivities} activités
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Légende des catégories */}
|
||||
<div className="flex flex-wrap gap-3 justify-center">
|
||||
{categories.map(([categoryName, data]) => (
|
||||
<div
|
||||
key={categoryName}
|
||||
className="flex items-center gap-2 bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 hover:border-[var(--primary)]/50 transition-colors"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: data.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--foreground)]">
|
||||
{data.icon} {categoryName}
|
||||
</span>
|
||||
<Badge className="bg-[var(--primary)]/10 text-[var(--primary)] text-xs">
|
||||
{data.count}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Barres de progression */}
|
||||
<div className="space-y-3">
|
||||
{categories.map(([categoryName, data]) => (
|
||||
<div key={categoryName} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{data.icon}</span>
|
||||
<span className="font-medium">{categoryName}</span>
|
||||
</span>
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{data.count} ({data.percentage.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-[var(--border)] rounded-full h-2">
|
||||
<div
|
||||
className="h-2 rounded-full transition-all duration-500"
|
||||
style={{
|
||||
backgroundColor: data.color,
|
||||
width: `${data.percentage}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Insights */}
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)]">
|
||||
<h4 className="font-medium mb-2">💡 Insights</h4>
|
||||
<div className="text-sm text-[var(--muted-foreground)] space-y-1">
|
||||
{categories.length > 0 && (
|
||||
<>
|
||||
<p>
|
||||
🏆 <strong>{categories[0][0]}</strong> est votre activité principale
|
||||
({categories[0][1].percentage.toFixed(1)}% de votre temps).
|
||||
</p>
|
||||
|
||||
{categories.length > 1 && (
|
||||
<p>
|
||||
📈 Vous avez une bonne diversité avec {categories.length} catégories d'activités.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Suggestions basées sur la répartition */}
|
||||
{categories.some(([, data]) => data.percentage > 70) && (
|
||||
<p>
|
||||
⚠️ Forte concentration sur une seule catégorie.
|
||||
Pensez à diversifier vos activités pour un meilleur équilibre.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const learningCategory = categories.find(([name]) => name === 'Learning');
|
||||
return learningCategory && learningCategory[1].percentage > 0 && (
|
||||
<p>
|
||||
🎓 Excellent ! Vous consacrez du temps à l'apprentissage
|
||||
({learningCategory[1].percentage.toFixed(1)}%).
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
const devCategory = categories.find(([name]) => name === 'Dev');
|
||||
return devCategory && devCategory[1].percentage > 50 && (
|
||||
<p>
|
||||
💻 Focus développement intense. N'oubliez pas les pauses et la collaboration !
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
193
components/dashboard/JiraWeeklyMetrics.tsx
Normal file
193
components/dashboard/JiraWeeklyMetrics.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import type { JiraWeeklyMetrics } from '@/services/jira-summary';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { JiraSummaryService } from '@/services/jira-summary';
|
||||
|
||||
interface JiraWeeklyMetricsProps {
|
||||
jiraMetrics: JiraWeeklyMetrics | null;
|
||||
}
|
||||
|
||||
export function JiraWeeklyMetrics({ jiraMetrics }: JiraWeeklyMetricsProps) {
|
||||
if (!jiraMetrics) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-[var(--muted-foreground)]">
|
||||
Configuration Jira non disponible
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (jiraMetrics.totalJiraTasks === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-[var(--muted-foreground)]">
|
||||
Aucune tâche Jira cette semaine
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const completionRate = (jiraMetrics.completedJiraTasks / jiraMetrics.totalJiraTasks) * 100;
|
||||
const insights = JiraSummaryService.generateBusinessInsights(jiraMetrics);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Impact business et métriques projet
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Métriques principales */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--primary)]/50 transition-colors text-center">
|
||||
<div className="text-2xl font-bold text-[var(--primary)]">
|
||||
{jiraMetrics.totalJiraTasks}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Tickets Jira</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--success)]/50 transition-colors text-center">
|
||||
<div className="text-2xl font-bold text-[var(--success)]">
|
||||
{completionRate.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Taux completion</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--accent)]/50 transition-colors text-center">
|
||||
<div className="text-2xl font-bold text-[var(--accent)]">
|
||||
{jiraMetrics.totalStoryPoints}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Story Points*</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--warning)]/50 transition-colors text-center">
|
||||
<div className="text-2xl font-bold text-[var(--warning)]">
|
||||
{jiraMetrics.projectsContributed.length}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Projet(s)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projets contributés */}
|
||||
{jiraMetrics.projectsContributed.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">📂 Projets contributés</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{jiraMetrics.projectsContributed.map(project => (
|
||||
<Badge key={project} className="bg-[var(--primary)]/10 text-[var(--primary)]">
|
||||
{project}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Types de tickets */}
|
||||
<div>
|
||||
<h4 className="font-medium mb-3">🎯 Types de tickets</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(jiraMetrics.ticketTypes)
|
||||
.sort(([,a], [,b]) => b - a)
|
||||
.map(([type, count]) => {
|
||||
const percentage = (count / jiraMetrics.totalJiraTasks) * 100;
|
||||
return (
|
||||
<div key={type} className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--foreground)]">{type}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 bg-[var(--border)] rounded-full h-2">
|
||||
<div
|
||||
className="h-2 bg-[var(--primary)] rounded-full transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--muted-foreground)] w-8">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liens vers les tickets */}
|
||||
<div>
|
||||
<h4 className="font-medium mb-3">🎫 Tickets traités</h4>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{jiraMetrics.jiraLinks.map((link) => (
|
||||
<div
|
||||
key={link.key}
|
||||
className="flex items-center justify-between p-2 rounded border hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--primary)] hover:underline font-medium text-sm"
|
||||
>
|
||||
{link.key}
|
||||
</a>
|
||||
<Badge
|
||||
className={`text-xs ${
|
||||
link.status === 'done'
|
||||
? 'bg-[var(--success)]/10 text-[var(--success)]'
|
||||
: 'bg-[var(--muted)]/50 text-[var(--muted-foreground)]'
|
||||
}`}
|
||||
>
|
||||
{link.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted-foreground)] truncate">
|
||||
{link.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
|
||||
<span>{link.type}</span>
|
||||
<span>{link.estimatedPoints}pts</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insights business */}
|
||||
{insights.length > 0 && (
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)]">
|
||||
<h4 className="font-medium mb-2">💡 Insights business</h4>
|
||||
<div className="text-sm text-[var(--muted-foreground)] space-y-1">
|
||||
{insights.map((insight, index) => (
|
||||
<p key={index}>{insight}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note sur les story points */}
|
||||
<div className="text-xs text-[var(--muted-foreground)] bg-[var(--card)] border border-[var(--border)] p-2 rounded">
|
||||
<p>
|
||||
* Story Points estimés automatiquement basés sur le type de ticket
|
||||
(Epic: 8pts, Story: 3pts, Task: 2pts, Bug: 1pt)
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
497
components/dashboard/ManagerWeeklySummary.tsx
Normal file
497
components/dashboard/ManagerWeeklySummary.tsx
Normal file
@@ -0,0 +1,497 @@
|
||||
'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 { MetricsTab } from './MetricsTab';
|
||||
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' | 'metrics'>('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>
|
||||
<button
|
||||
onClick={() => setActiveView('metrics')}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeView === 'metrics'
|
||||
? 'border-[var(--primary)] text-[var(--primary)]'
|
||||
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
📊 Métriques
|
||||
</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 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 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 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 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>
|
||||
)}
|
||||
|
||||
{/* Vue Métriques */}
|
||||
{activeView === 'metrics' && (
|
||||
<MetricsTab />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
245
components/dashboard/MetricsTab.tsx
Normal file
245
components/dashboard/MetricsTab.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { DailyStatusChart } from './charts/DailyStatusChart';
|
||||
import { CompletionRateChart } from './charts/CompletionRateChart';
|
||||
import { StatusDistributionChart } from './charts/StatusDistributionChart';
|
||||
import { PriorityBreakdownChart } from './charts/PriorityBreakdownChart';
|
||||
import { VelocityTrendChart } from './charts/VelocityTrendChart';
|
||||
import { WeeklyActivityHeatmap } from './charts/WeeklyActivityHeatmap';
|
||||
import { ProductivityInsights } from './charts/ProductivityInsights';
|
||||
import { format } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
interface MetricsTabProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MetricsTab({ className }: MetricsTabProps) {
|
||||
const [selectedDate] = useState<Date>(new Date());
|
||||
const [weeksBack, setWeeksBack] = useState(4);
|
||||
|
||||
const { metrics, loading: metricsLoading, error: metricsError, refetch: refetchMetrics } = useWeeklyMetrics(selectedDate);
|
||||
const { trends, loading: trendsLoading, error: trendsError, refetch: refetchTrends } = useVelocityTrends(weeksBack);
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchMetrics();
|
||||
refetchTrends();
|
||||
};
|
||||
|
||||
const formatPeriod = () => {
|
||||
if (!metrics) return '';
|
||||
return `Semaine du ${format(metrics.period.start, 'dd MMM', { locale: fr })} au ${format(metrics.period.end, 'dd MMM yyyy', { locale: fr })}`;
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'improving': return '📈';
|
||||
case 'declining': return '📉';
|
||||
case 'stable': return '➡️';
|
||||
default: return '📊';
|
||||
}
|
||||
};
|
||||
|
||||
const getPatternIcon = (pattern: string) => {
|
||||
switch (pattern) {
|
||||
case 'consistent': return '🎯';
|
||||
case 'variable': return '📊';
|
||||
case 'weekend-heavy': return '📅';
|
||||
default: return '📋';
|
||||
}
|
||||
};
|
||||
|
||||
if (metricsError || trendsError) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<p className="text-red-500 mb-4">
|
||||
❌ Erreur lors du chargement des métriques
|
||||
</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-4">
|
||||
{metricsError || trendsError}
|
||||
</p>
|
||||
<Button onClick={handleRefresh} variant="secondary" size="sm">
|
||||
🔄 Réessayer
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Header avec période et contrôles */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[var(--foreground)]">📊 Métriques & Analytics</h2>
|
||||
<p className="text-[var(--muted-foreground)]">{formatPeriod()}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={metricsLoading || trendsLoading}
|
||||
>
|
||||
🔄 Actualiser
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{metricsLoading || trendsLoading ? (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-[var(--border)] rounded w-1/4 mx-auto mb-4"></div>
|
||||
<div className="h-32 bg-[var(--border)] rounded"></div>
|
||||
</div>
|
||||
<p className="text-[var(--muted-foreground)] mt-4">Chargement des métriques...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : metrics ? (
|
||||
<div className="space-y-6">
|
||||
{/* Vue d'ensemble rapide */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🎯 Vue d'ensemble</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{metrics.summary.totalTasksCompleted}
|
||||
</div>
|
||||
<div className="text-sm text-green-600">Terminées</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{metrics.summary.totalTasksCreated}
|
||||
</div>
|
||||
<div className="text-sm text-blue-600">Créées</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-purple-50 dark:bg-purple-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{metrics.summary.averageCompletionRate.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-sm text-purple-600">Taux moyen</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-orange-50 dark:bg-orange-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{getTrendIcon(metrics.summary.trendsAnalysis.completionTrend)}
|
||||
</div>
|
||||
<div className="text-sm text-orange-600 capitalize">
|
||||
{metrics.summary.trendsAnalysis.completionTrend}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-gray-50 dark:bg-gray-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-gray-600">
|
||||
{getPatternIcon(metrics.summary.trendsAnalysis.productivityPattern)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{metrics.summary.trendsAnalysis.productivityPattern === 'consistent' ? 'Régulier' :
|
||||
metrics.summary.trendsAnalysis.productivityPattern === 'variable' ? 'Variable' : 'Weekend+'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Graphiques principaux */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📈 Évolution quotidienne des statuts</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DailyStatusChart data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🎯 Taux de completion quotidien</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CompletionRateChart data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Distribution et priorités */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🍰 Répartition des statuts</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatusDistributionChart data={metrics.statusDistribution} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">⚡ Performance par priorité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PriorityBreakdownChart data={metrics.priorityBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔥 Heatmap d'activité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<WeeklyActivityHeatmap data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tendances de vélocité */}
|
||||
{trends.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">🚀 Tendances de vélocité</h3>
|
||||
<select
|
||||
value={weeksBack}
|
||||
onChange={(e) => setWeeksBack(parseInt(e.target.value))}
|
||||
className="text-sm border border-[var(--border)] rounded px-2 py-1 bg-[var(--background)]"
|
||||
>
|
||||
<option value={4}>4 semaines</option>
|
||||
<option value={8}>8 semaines</option>
|
||||
<option value={12}>12 semaines</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VelocityTrendChart data={trends} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Analyses de productivité */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">💡 Analyses de productivité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProductivityInsights data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { WeeklySummary, WeeklyActivity } from '@/services/weekly-summary';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
interface WeeklySummaryClientProps {
|
||||
initialSummary: WeeklySummary;
|
||||
}
|
||||
|
||||
export default function WeeklySummaryClient({ initialSummary }: WeeklySummaryClientProps) {
|
||||
const [summary] = useState<WeeklySummary>(initialSummary);
|
||||
const [selectedDay, setSelectedDay] = useState<string | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
// Recharger la page pour refaire le fetch côté serveur
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
});
|
||||
};
|
||||
|
||||
const getActivityIcon = (activity: WeeklyActivity) => {
|
||||
if (activity.type === 'checkbox') {
|
||||
return activity.completed ? '✅' : '☐';
|
||||
}
|
||||
return activity.completed ? '🎯' : '📝';
|
||||
};
|
||||
|
||||
const getActivityTypeLabel = (type: 'checkbox' | 'task') => {
|
||||
return type === 'checkbox' ? 'Daily' : 'Tâche';
|
||||
};
|
||||
|
||||
const filteredActivities = selectedDay
|
||||
? summary.activities.filter(a => a.dayName === selectedDay)
|
||||
: summary.activities;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">📅 Résumé de la semaine</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Du {formatDate(summary.period.start)} au {formatDate(summary.period.end)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? '🔄' : '🔄'} {isRefreshing ? 'Actualisation...' : 'Actualiser'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Statistiques globales */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-blue-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{summary.stats.completedCheckboxes}
|
||||
</div>
|
||||
<div className="text-sm text-blue-600">Daily items</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
sur {summary.stats.totalCheckboxes} ({summary.stats.checkboxCompletionRate.toFixed(0)}%)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{summary.stats.completedTasks}
|
||||
</div>
|
||||
<div className="text-sm text-green-600">Tâches</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
sur {summary.stats.totalTasks} ({summary.stats.taskCompletionRate.toFixed(0)}%)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{summary.stats.completedCheckboxes + summary.stats.completedTasks}
|
||||
</div>
|
||||
<div className="text-sm text-purple-600">Total complété</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
sur {summary.stats.totalCheckboxes + summary.stats.totalTasks}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-orange-50 rounded-lg p-4 text-center">
|
||||
<div className="text-lg font-bold text-orange-600">
|
||||
{summary.stats.mostProductiveDay}
|
||||
</div>
|
||||
<div className="text-sm text-orange-600">Jour le plus productif</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdown par jour */}
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">📊 Répartition par jour</h3>
|
||||
<div className="grid grid-cols-7 gap-2 mb-4">
|
||||
{summary.stats.dailyBreakdown.map((day) => (
|
||||
<button
|
||||
key={day.date}
|
||||
onClick={() => setSelectedDay(selectedDay === day.dayName ? null : day.dayName)}
|
||||
className={`p-2 rounded-lg text-center transition-colors ${
|
||||
selectedDay === day.dayName
|
||||
? 'bg-blue-100 border-2 border-blue-300'
|
||||
: 'bg-[var(--muted)] hover:bg-[var(--muted)]/80'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-medium">
|
||||
{day.dayName.slice(0, 3)}
|
||||
</div>
|
||||
<div className="text-sm font-bold">
|
||||
{day.completedCheckboxes + day.completedTasks}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
/{day.checkboxes + day.tasks}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedDay && (
|
||||
<div className="text-sm text-[var(--muted-foreground)] mb-4">
|
||||
📍 Filtré sur: <strong>{selectedDay}</strong>
|
||||
<button
|
||||
onClick={() => setSelectedDay(null)}
|
||||
className="ml-2 text-blue-600 hover:underline"
|
||||
>
|
||||
(voir tout)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline des activités */}
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">
|
||||
🕒 Timeline des activités
|
||||
<span className="text-sm font-normal text-[var(--muted-foreground)]">
|
||||
({filteredActivities.length} items)
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
{filteredActivities.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||
{selectedDay ? 'Aucune activité ce jour-là' : 'Aucune activité cette semaine'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{filteredActivities.map((activity) => (
|
||||
<div
|
||||
key={activity.id}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border transition-colors ${
|
||||
activity.completed
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-[var(--card)] border-[var(--border)]'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg flex-shrink-0">
|
||||
{getActivityIcon(activity)}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-sm ${activity.completed ? 'line-through text-[var(--muted-foreground)]' : ''}`}>
|
||||
{activity.title}
|
||||
</span>
|
||||
<Badge className="text-xs bg-[var(--muted)] text-[var(--muted-foreground)]">
|
||||
{getActivityTypeLabel(activity.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{activity.dayName} • {new Date(activity.createdAt).toLocaleDateString('fr-FR')}
|
||||
{activity.completedAt && (
|
||||
<span> • Complété le {new Date(activity.completedAt).toLocaleDateString('fr-FR')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
97
components/dashboard/charts/CompletionRateChart.tsx
Normal file
97
components/dashboard/charts/CompletionRateChart.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
|
||||
interface CompletionRateChartProps {
|
||||
data: DailyMetrics[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CompletionRateChart({ data, className }: CompletionRateChartProps) {
|
||||
// Transformer les données pour le graphique
|
||||
const chartData = data.map(day => ({
|
||||
day: day.dayName.substring(0, 3), // Lun, Mar, etc.
|
||||
date: new Date(day.date).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }),
|
||||
completionRate: day.completionRate,
|
||||
completed: day.completed,
|
||||
total: day.totalTasks
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium mb-2">{`${label} (${data.date})`}</p>
|
||||
<p className="text-sm text-[var(--foreground)]">
|
||||
Taux de completion: {data.completionRate.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{data.completed} / {data.total} tâches
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Calculer la moyenne pour la ligne de référence
|
||||
const averageRate = data.reduce((sum, day) => sum + day.completionRate, 0) / data.length;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="completionRate"
|
||||
stroke="#10b981"
|
||||
strokeWidth={3}
|
||||
dot={{ fill: "#10b981", strokeWidth: 2, r: 4 }}
|
||||
activeDot={{ r: 6, stroke: "#10b981", strokeWidth: 2 }}
|
||||
/>
|
||||
{/* Ligne de moyenne */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={() => averageRate}
|
||||
stroke="#94a3b8"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Légende */}
|
||||
<div className="flex items-center justify-center gap-4 mt-2 text-xs text-[var(--muted-foreground)]">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-0.5 bg-green-500"></div>
|
||||
<span>Taux quotidien</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-0.5 bg-gray-400 border-dashed"></div>
|
||||
<span>Moyenne ({averageRate.toFixed(1)}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
components/dashboard/charts/DailyStatusChart.tsx
Normal file
68
components/dashboard/charts/DailyStatusChart.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
|
||||
interface DailyStatusChartProps {
|
||||
data: DailyMetrics[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DailyStatusChart({ data, className }: DailyStatusChartProps) {
|
||||
// Transformer les données pour le graphique
|
||||
const chartData = data.map(day => ({
|
||||
day: day.dayName.substring(0, 3), // Lun, Mar, etc.
|
||||
date: new Date(day.date).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }),
|
||||
'Complétées': day.completed,
|
||||
'En cours': day.inProgress,
|
||||
'Bloquées': day.blocked,
|
||||
'En attente': day.pending,
|
||||
'Nouvelles': day.newTasks
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium mb-2">{`${label} (${payload[0]?.payload?.date})`}</p>
|
||||
{payload.map((entry: { dataKey: string; value: number; color: string }, index: number) => (
|
||||
<p key={index} style={{ color: entry.color }} className="text-sm">
|
||||
{`${entry.dataKey}: ${entry.value}`}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Bar dataKey="Complétées" fill="#10b981" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="En cours" fill="#3b82f6" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="Bloquées" fill="#ef4444" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="En attente" fill="#94a3b8" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="Nouvelles" fill="#8b5cf6" radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
components/dashboard/charts/PriorityBreakdownChart.tsx
Normal file
112
components/dashboard/charts/PriorityBreakdownChart.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
|
||||
interface PriorityData {
|
||||
priority: string;
|
||||
completed: number;
|
||||
pending: number;
|
||||
total: number;
|
||||
completionRate: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface PriorityBreakdownChartProps {
|
||||
data: PriorityData[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PriorityBreakdownChart({ data, className }: PriorityBreakdownChartProps) {
|
||||
// Transformer les données pour l'affichage
|
||||
const getPriorityLabel = (priority: string) => {
|
||||
const labels: { [key: string]: string } = {
|
||||
'high': 'Haute',
|
||||
'medium': 'Moyenne',
|
||||
'low': 'Basse'
|
||||
};
|
||||
return labels[priority] || priority;
|
||||
};
|
||||
|
||||
const chartData = data.map(item => ({
|
||||
priority: getPriorityLabel(item.priority),
|
||||
'Terminées': item.completed,
|
||||
'En cours': item.pending,
|
||||
completionRate: item.completionRate,
|
||||
total: item.total
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium mb-2">{`Priorité ${label}`}</p>
|
||||
<p className="text-sm text-green-600">
|
||||
Terminées: {data['Terminées']}
|
||||
</p>
|
||||
<p className="text-sm text-blue-600">
|
||||
En cours: {data['En cours']}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
Taux: {data.completionRate.toFixed(1)}% ({data.total} total)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="priority"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="Terminées"
|
||||
stackId="a"
|
||||
fill="#10b981"
|
||||
radius={[0, 0, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="En cours"
|
||||
stackId="a"
|
||||
fill="#3b82f6"
|
||||
radius={[2, 2, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Affichage des taux de completion */}
|
||||
<div className="mt-4 grid grid-cols-3 gap-4 text-center">
|
||||
{data.map((item, index) => (
|
||||
<div key={index} className="p-2 bg-[var(--card)] rounded border">
|
||||
<div className="text-xs text-[var(--muted-foreground)] mb-1">
|
||||
{getPriorityLabel(item.priority)}
|
||||
</div>
|
||||
<div className="text-lg font-bold" style={{ color: item.color }}>
|
||||
{item.completionRate.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{item.completed}/{item.total}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
components/dashboard/charts/ProductivityInsights.tsx
Normal file
190
components/dashboard/charts/ProductivityInsights.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
|
||||
interface ProductivityInsightsProps {
|
||||
data: DailyMetrics[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProductivityInsights({ data, className }: ProductivityInsightsProps) {
|
||||
// Calculer les insights
|
||||
const totalCompleted = data.reduce((sum, day) => sum + day.completed, 0);
|
||||
const totalCreated = data.reduce((sum, day) => sum + day.newTasks, 0);
|
||||
// const averageCompletion = data.reduce((sum, day) => sum + day.completionRate, 0) / data.length;
|
||||
|
||||
// Trouver le jour le plus productif
|
||||
const mostProductiveDay = data.reduce((best, day) =>
|
||||
day.completed > best.completed ? day : best
|
||||
);
|
||||
|
||||
// Trouver le jour avec le plus de nouvelles tâches
|
||||
const mostCreativeDay = data.reduce((best, day) =>
|
||||
day.newTasks > best.newTasks ? day : best
|
||||
);
|
||||
|
||||
// Analyser la tendance
|
||||
const firstHalf = data.slice(0, Math.ceil(data.length / 2));
|
||||
const secondHalf = data.slice(Math.ceil(data.length / 2));
|
||||
|
||||
const firstHalfAvg = firstHalf.reduce((sum, day) => sum + day.completed, 0) / firstHalf.length;
|
||||
const secondHalfAvg = secondHalf.reduce((sum, day) => sum + day.completed, 0) / secondHalf.length;
|
||||
|
||||
const trend = secondHalfAvg > firstHalfAvg ? 'up' : secondHalfAvg < firstHalfAvg ? 'down' : 'stable';
|
||||
|
||||
// Calculer la consistance (écart-type faible = plus consistant)
|
||||
const avgCompleted = totalCompleted / data.length;
|
||||
const variance = data.reduce((sum, day) => {
|
||||
const diff = day.completed - avgCompleted;
|
||||
return sum + diff * diff;
|
||||
}, 0) / data.length;
|
||||
const standardDeviation = Math.sqrt(variance);
|
||||
const consistencyScore = Math.max(0, 100 - (standardDeviation * 10)); // Score sur 100
|
||||
|
||||
// Ratio création/completion
|
||||
const creationRatio = totalCreated > 0 ? (totalCompleted / totalCreated) * 100 : 0;
|
||||
|
||||
const getTrendIcon = () => {
|
||||
switch (trend) {
|
||||
case 'up': return { icon: '📈', color: 'text-green-600', label: 'En amélioration' };
|
||||
case 'down': return { icon: '📉', color: 'text-red-600', label: 'En baisse' };
|
||||
default: return { icon: '➡️', color: 'text-blue-600', label: 'Stable' };
|
||||
}
|
||||
};
|
||||
|
||||
const getConsistencyLevel = () => {
|
||||
if (consistencyScore >= 80) return { label: 'Très régulier', color: 'text-green-600', icon: '🎯' };
|
||||
if (consistencyScore >= 60) return { label: 'Assez régulier', color: 'text-blue-600', icon: '📊' };
|
||||
if (consistencyScore >= 40) return { label: 'Variable', color: 'text-yellow-600', icon: '📊' };
|
||||
return { label: 'Très variable', color: 'text-red-600', icon: '📊' };
|
||||
};
|
||||
|
||||
const getRatioStatus = () => {
|
||||
if (creationRatio >= 100) return { label: 'Équilibré+', color: 'text-green-600', icon: '⚖️' };
|
||||
if (creationRatio >= 80) return { label: 'Bien équilibré', color: 'text-blue-600', icon: '⚖️' };
|
||||
if (creationRatio >= 60) return { label: 'Légèrement en retard', color: 'text-yellow-600', icon: '⚖️' };
|
||||
return { label: 'Accumulation', color: 'text-red-600', icon: '⚖️' };
|
||||
};
|
||||
|
||||
const trendInfo = getTrendIcon();
|
||||
const consistencyInfo = getConsistencyLevel();
|
||||
const ratioInfo = getRatioStatus();
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-4">
|
||||
{/* Insights principaux */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Jour le plus productif */}
|
||||
<div className="p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-green-900 dark:text-green-100">
|
||||
🏆 Jour champion
|
||||
</h4>
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
{mostProductiveDay.completed}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
{mostProductiveDay.dayName} - {mostProductiveDay.completed} tâches terminées
|
||||
</p>
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
Taux: {mostProductiveDay.completionRate.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Jour le plus créatif */}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100">
|
||||
💡 Jour créatif
|
||||
</h4>
|
||||
<span className="text-2xl font-bold text-blue-600">
|
||||
{mostCreativeDay.newTasks}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{mostCreativeDay.dayName} - {mostCreativeDay.newTasks} nouvelles tâches
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
{mostCreativeDay.dayName === mostProductiveDay.dayName ?
|
||||
'Également jour le plus productif!' :
|
||||
'Journée de planification'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analyses comportementales */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Tendance */}
|
||||
<div className="p-4 bg-[var(--card)] border border-[var(--border)] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">{trendInfo.icon}</span>
|
||||
<h4 className="font-medium text-[var(--foreground)]">Tendance</h4>
|
||||
</div>
|
||||
<p className={`text-sm font-medium ${trendInfo.color}`}>
|
||||
{trendInfo.label}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
{secondHalfAvg > firstHalfAvg ?
|
||||
`+${(((secondHalfAvg - firstHalfAvg) / firstHalfAvg) * 100).toFixed(1)}%` :
|
||||
`${(((secondHalfAvg - firstHalfAvg) / firstHalfAvg) * 100).toFixed(1)}%`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Consistance */}
|
||||
<div className="p-4 bg-[var(--card)] border border-[var(--border)] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">{consistencyInfo.icon}</span>
|
||||
<h4 className="font-medium text-[var(--foreground)]">Régularité</h4>
|
||||
</div>
|
||||
<p className={`text-sm font-medium ${consistencyInfo.color}`}>
|
||||
{consistencyInfo.label}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Score: {consistencyScore.toFixed(0)}/100
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Ratio Création/Completion */}
|
||||
<div className="p-4 bg-[var(--card)] border border-[var(--border)] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">{ratioInfo.icon}</span>
|
||||
<h4 className="font-medium text-[var(--foreground)]">Équilibre</h4>
|
||||
</div>
|
||||
<p className={`text-sm font-medium ${ratioInfo.color}`}>
|
||||
{ratioInfo.label}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
{creationRatio.toFixed(0)}% de completion
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommandations */}
|
||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-950/20 rounded-lg">
|
||||
<h4 className="font-medium text-yellow-900 dark:text-yellow-100 mb-2 flex items-center gap-2">
|
||||
💡 Recommandations
|
||||
</h4>
|
||||
<div className="space-y-1 text-sm text-yellow-800 dark:text-yellow-200">
|
||||
{trend === 'down' && (
|
||||
<p>• Essayez de retrouver votre rythme du début de semaine</p>
|
||||
)}
|
||||
{consistencyScore < 60 && (
|
||||
<p>• Essayez de maintenir un rythme plus régulier</p>
|
||||
)}
|
||||
{creationRatio < 80 && (
|
||||
<p>• Concentrez-vous plus sur terminer les tâches existantes</p>
|
||||
)}
|
||||
{creationRatio > 120 && (
|
||||
<p>• Excellent rythme! Peut-être ralentir la création de nouvelles tâches</p>
|
||||
)}
|
||||
{mostProductiveDay.dayName === mostCreativeDay.dayName && (
|
||||
<p>• Excellente synergie création/exécution le {mostProductiveDay.dayName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
components/dashboard/charts/StatusDistributionChart.tsx
Normal file
109
components/dashboard/charts/StatusDistributionChart.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
|
||||
|
||||
interface StatusDistributionData {
|
||||
status: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface StatusDistributionChartProps {
|
||||
data: StatusDistributionData[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatusDistributionChart({ data, className }: StatusDistributionChartProps) {
|
||||
// Transformer les statuts pour l'affichage
|
||||
const getStatusLabel = (status: string) => {
|
||||
const labels: { [key: string]: string } = {
|
||||
'pending': 'En attente',
|
||||
'in_progress': 'En cours',
|
||||
'blocked': 'Bloquées',
|
||||
'done': 'Terminées',
|
||||
'archived': 'Archivées'
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
|
||||
const chartData = data.map(item => ({
|
||||
...item,
|
||||
name: getStatusLabel(item.status),
|
||||
value: item.count
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: any[] }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium mb-1">{data.name}</p>
|
||||
<p className="text-sm text-[var(--foreground)]">
|
||||
{data.count} tâches ({data.percentage}%)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomLabel = (props: any) => {
|
||||
const { cx, cy, midAngle, innerRadius, outerRadius, percent } = props;
|
||||
if (percent < 0.05) return null; // Ne pas afficher les labels pour les petites sections
|
||||
|
||||
const RADIAN = Math.PI / 180;
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="white"
|
||||
textAnchor={x > cx ? 'start' : 'end'}
|
||||
dominantBaseline="central"
|
||||
fontSize={12}
|
||||
fontWeight="medium"
|
||||
>
|
||||
{`${(percent * 100).toFixed(0)}%`}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={CustomLabel}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={36}
|
||||
formatter={(value, entry: { color?: string }) => (
|
||||
<span style={{ color: entry.color, fontSize: '12px' }}>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
components/dashboard/charts/VelocityTrendChart.tsx
Normal file
95
components/dashboard/charts/VelocityTrendChart.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { VelocityTrend } from '@/services/metrics';
|
||||
|
||||
interface VelocityTrendChartProps {
|
||||
data: VelocityTrend[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function VelocityTrendChart({ data, className }: VelocityTrendChartProps) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium mb-2">{`Semaine du ${label}`}</p>
|
||||
<p className="text-sm text-green-600">
|
||||
Terminées: {data.completed}
|
||||
</p>
|
||||
<p className="text-sm text-blue-600">
|
||||
Créées: {data.created}
|
||||
</p>
|
||||
<p className="text-sm text-purple-600">
|
||||
Vélocité: {data.velocity.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="count"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
orientation="left"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="velocity"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Line
|
||||
yAxisId="count"
|
||||
type="monotone"
|
||||
dataKey="completed"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "#10b981", strokeWidth: 2, r: 4 }}
|
||||
name="Terminées"
|
||||
/>
|
||||
<Line
|
||||
yAxisId="count"
|
||||
type="monotone"
|
||||
dataKey="created"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "#3b82f6", strokeWidth: 2, r: 4 }}
|
||||
name="Créées"
|
||||
/>
|
||||
<Line
|
||||
yAxisId="velocity"
|
||||
type="monotone"
|
||||
dataKey="velocity"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={3}
|
||||
dot={{ fill: "#8b5cf6", strokeWidth: 2, r: 5 }}
|
||||
name="Vélocité (%)"
|
||||
strokeDasharray="5 5"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
components/dashboard/charts/WeeklyActivityHeatmap.tsx
Normal file
123
components/dashboard/charts/WeeklyActivityHeatmap.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
|
||||
interface WeeklyActivityHeatmapProps {
|
||||
data: DailyMetrics[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WeeklyActivityHeatmap({ data, className }: WeeklyActivityHeatmapProps) {
|
||||
// Calculer l'intensité max pour la normalisation
|
||||
const maxActivity = Math.max(...data.map(day => day.completed + day.newTasks));
|
||||
|
||||
// Obtenir l'intensité relative (0-1)
|
||||
const getIntensity = (day: DailyMetrics) => {
|
||||
const activity = day.completed + day.newTasks;
|
||||
return maxActivity > 0 ? activity / maxActivity : 0;
|
||||
};
|
||||
|
||||
// Obtenir la couleur basée sur l'intensité
|
||||
const getColorClass = (intensity: number) => {
|
||||
if (intensity === 0) return 'bg-gray-100 dark:bg-gray-800';
|
||||
if (intensity < 0.2) return 'bg-green-100 dark:bg-green-900/30';
|
||||
if (intensity < 0.4) return 'bg-green-200 dark:bg-green-800/50';
|
||||
if (intensity < 0.6) return 'bg-green-300 dark:bg-green-700/70';
|
||||
if (intensity < 0.8) return 'bg-green-400 dark:bg-green-600/80';
|
||||
return 'bg-green-500 dark:bg-green-500';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-4">
|
||||
{/* Titre */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-sm font-medium text-[var(--foreground)] mb-2">
|
||||
Heatmap d'activité hebdomadaire
|
||||
</h4>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Intensité basée sur les tâches complétées + nouvelles tâches
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Heatmap */}
|
||||
<div className="flex justify-center">
|
||||
<div className="flex gap-1">
|
||||
{data.map((day, index) => {
|
||||
const intensity = getIntensity(day);
|
||||
const colorClass = getColorClass(intensity);
|
||||
const totalActivity = day.completed + day.newTasks;
|
||||
|
||||
return (
|
||||
<div key={index} className="text-center">
|
||||
{/* Carré de couleur */}
|
||||
<div
|
||||
className={`w-8 h-8 rounded ${colorClass} border border-[var(--border)] flex items-center justify-center transition-all hover:scale-110 cursor-help group relative`}
|
||||
title={`${day.dayName}: ${totalActivity} activités (${day.completed} complétées, ${day.newTasks} créées)`}
|
||||
>
|
||||
{/* Tooltip au hover */}
|
||||
<div className="opacity-0 group-hover:opacity-100 absolute bottom-10 left-1/2 transform -translate-x-1/2 bg-[var(--card)] border border-[var(--border)] rounded p-2 text-xs whitespace-nowrap z-10 shadow-lg transition-opacity">
|
||||
<div className="font-medium">{day.dayName}</div>
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
{day.completed} terminées, {day.newTasks} créées
|
||||
</div>
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Taux: {day.completionRate.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicator si jour actuel */}
|
||||
{new Date(day.date).toDateString() === new Date().toDateString() && (
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label du jour */}
|
||||
<div className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
{day.dayName.substring(0, 3)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Légende */}
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-[var(--muted-foreground)]">
|
||||
<span>Moins</span>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-3 h-3 bg-gray-100 dark:bg-gray-800 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-100 dark:bg-green-900/30 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-200 dark:bg-green-800/50 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-300 dark:bg-green-700/70 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-400 dark:bg-green-600/80 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-500 dark:bg-green-500 border border-[var(--border)] rounded"></div>
|
||||
</div>
|
||||
<span>Plus</span>
|
||||
</div>
|
||||
|
||||
{/* Stats rapides */}
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div className="p-2 bg-[var(--card)] rounded border">
|
||||
<div className="font-medium text-green-600">
|
||||
{data.reduce((sum, day) => sum + day.completed, 0)}
|
||||
</div>
|
||||
<div className="text-[var(--muted-foreground)]">Terminées</div>
|
||||
</div>
|
||||
<div className="p-2 bg-[var(--card)] rounded border">
|
||||
<div className="font-medium text-blue-600">
|
||||
{data.reduce((sum, day) => sum + day.newTasks, 0)}
|
||||
</div>
|
||||
<div className="text-[var(--muted-foreground)]">Créées</div>
|
||||
</div>
|
||||
<div className="p-2 bg-[var(--card)] rounded border">
|
||||
<div className="font-medium text-purple-600">
|
||||
{(data.reduce((sum, day) => sum + day.completionRate, 0) / data.length).toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-[var(--muted-foreground)]">Taux moyen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { UserPreferences } from '@/lib/types';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { UserPreferences, Tag } from '@/lib/types';
|
||||
import { useTags } from '@/hooks/useTags';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { TagForm } from '@/components/forms/TagForm';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface GeneralSettingsPageClientProps {
|
||||
initialPreferences: UserPreferences;
|
||||
initialTags: Tag[];
|
||||
}
|
||||
|
||||
export function GeneralSettingsPageClient({ initialPreferences }: GeneralSettingsPageClientProps) {
|
||||
export function GeneralSettingsPageClient({ initialPreferences, initialTags }: GeneralSettingsPageClientProps) {
|
||||
const {
|
||||
tags,
|
||||
refreshTags,
|
||||
deleteTag
|
||||
} = useTags(initialTags as (Tag & { usage: number })[]);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showOnlyUnused, setShowOnlyUnused] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
|
||||
|
||||
// Filtrer et trier les tags
|
||||
const filteredTags = useMemo(() => {
|
||||
let filtered = tags;
|
||||
|
||||
// Filtrer par recherche
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(tag =>
|
||||
tag.name.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Filtrer pour afficher seulement les non utilisés
|
||||
if (showOnlyUnused) {
|
||||
filtered = filtered.filter(tag => {
|
||||
const usage = (tag as Tag & { usage?: number }).usage || 0;
|
||||
return usage === 0;
|
||||
});
|
||||
}
|
||||
|
||||
const sorted = filtered.sort((a, b) => {
|
||||
const usageA = (a as Tag & { usage?: number }).usage || 0;
|
||||
const usageB = (b as Tag & { usage?: number }).usage || 0;
|
||||
if (usageB !== usageA) return usageB - usageA;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Limiter à 12 tags si pas de recherche ni filtre, sinon afficher tous les résultats
|
||||
const hasFilters = searchQuery.trim() || showOnlyUnused;
|
||||
return hasFilters ? sorted : sorted.slice(0, 12);
|
||||
}, [tags, searchQuery, showOnlyUnused]);
|
||||
|
||||
const handleEditTag = (tag: Tag) => {
|
||||
setEditingTag(tag);
|
||||
};
|
||||
|
||||
const handleDeleteTag = async (tag: Tag) => {
|
||||
if (!confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingTagId(tag.id);
|
||||
try {
|
||||
await deleteTag(tag.id);
|
||||
await refreshTags();
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression:', error);
|
||||
} finally {
|
||||
setDeletingTagId(null);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
@@ -41,6 +110,210 @@ export function GeneralSettingsPageClient({ initialPreferences }: GeneralSetting
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Gestion des tags */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
🏷️ Gestion des tags
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
Créer et organiser les étiquettes pour vos tâches
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Nouveau tag
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Stats des tags */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div className="text-center p-3 bg-[var(--muted)]/20 rounded">
|
||||
<div className="text-xl font-bold text-[var(--foreground)]">{tags.length}</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Tags créés</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-[var(--primary)]/10 rounded">
|
||||
<div className="text-xl font-bold text-[var(--primary)]">
|
||||
{tags.reduce((sum, tag) => sum + ((tag as Tag & { usage?: number }).usage || 0), 0)}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Utilisations</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-[var(--success)]/10 rounded">
|
||||
<div className="text-xl font-bold text-[var(--success)]">
|
||||
{tags.filter(tag => (tag as Tag & { usage?: number }).usage && (tag as Tag & { usage?: number }).usage! > 0).length}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Actifs</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recherche et filtres */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<Input
|
||||
placeholder="Rechercher un tag..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{/* Filtres rapides */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant={showOnlyUnused ? "primary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setShowOnlyUnused(!showOnlyUnused)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span className="text-xs">⚠️</span>
|
||||
Tags non utilisés ({tags.filter(tag => ((tag as Tag & { usage?: number }).usage || 0) === 0).length})
|
||||
</Button>
|
||||
|
||||
{(searchQuery || showOnlyUnused) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setShowOnlyUnused(false);
|
||||
}}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
Réinitialiser
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liste des tags en grid */}
|
||||
{filteredTags.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||
{searchQuery && showOnlyUnused ? 'Aucun tag non utilisé trouvé avec cette recherche' :
|
||||
searchQuery ? 'Aucun tag trouvé pour cette recherche' :
|
||||
showOnlyUnused ? '🎉 Aucun tag non utilisé ! Tous vos tags sont actifs.' :
|
||||
'Aucun tag créé'}
|
||||
{!searchQuery && !showOnlyUnused && (
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
>
|
||||
Créer votre premier tag
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Grid des tags */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{filteredTags.map((tag) => {
|
||||
const usage = (tag as Tag & { usage?: number }).usage || 0;
|
||||
const isUnused = usage === 0;
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`p-3 rounded-lg border transition-all hover:shadow-sm ${
|
||||
isUnused
|
||||
? 'border-[var(--destructive)]/30 bg-[var(--destructive)]/5 hover:border-[var(--destructive)]/50'
|
||||
: 'border-[var(--border)] hover:border-[var(--primary)]/50'
|
||||
}`}
|
||||
>
|
||||
{/* Header du tag */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className="font-medium text-sm truncate">{tag.name}</span>
|
||||
{tag.isPinned && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-[var(--primary)]/20 text-[var(--primary)] rounded flex-shrink-0">
|
||||
📌
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditTag(tag)}
|
||||
className="h-7 w-7 p-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteTag(tag)}
|
||||
disabled={deletingTagId === tag.id}
|
||||
className={`h-7 w-7 p-0 ${
|
||||
isUnused
|
||||
? 'text-[var(--destructive)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/20'
|
||||
: 'text-[var(--muted-foreground)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/10'
|
||||
}`}
|
||||
>
|
||||
{deletingTagId === tag.id ? (
|
||||
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats et warning */}
|
||||
<div className="space-y-1">
|
||||
<div className={`text-xs flex items-center justify-between ${
|
||||
isUnused ? 'text-[var(--destructive)]' : 'text-[var(--muted-foreground)]'
|
||||
}`}>
|
||||
<span>{usage} utilisation{usage !== 1 ? 's' : ''}</span>
|
||||
{isUnused && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-[var(--destructive)]/20 text-[var(--destructive)] rounded">
|
||||
⚠️ Non utilisé
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{('createdAt' in tag && (tag as Tag & { createdAt: Date }).createdAt) && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Créé le {new Date((tag as Tag & { createdAt: Date }).createdAt).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Message si plus de tags */}
|
||||
{tags.length > 12 && !searchQuery && !showOnlyUnused && (
|
||||
<div className="text-center pt-2 text-sm text-[var(--muted-foreground)]">
|
||||
Et {tags.length - 12} autres tags... (utilisez la recherche ou les filtres pour les voir)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Note développement futur */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
@@ -49,7 +322,7 @@ export function GeneralSettingsPageClient({ initialPreferences }: GeneralSetting
|
||||
🚧 Interface de configuration en développement
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Les contrôles interactifs pour modifier ces préférences seront disponibles dans une prochaine version.
|
||||
Les contrôles interactifs pour modifier les autres préférences seront disponibles dans une prochaine version.
|
||||
Pour l'instant, les préférences sont modifiables via les boutons de l'interface principale.
|
||||
</p>
|
||||
</div>
|
||||
@@ -59,6 +332,30 @@ export function GeneralSettingsPageClient({ initialPreferences }: GeneralSetting
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals pour les tags */}
|
||||
{isCreateModalOpen && (
|
||||
<TagForm
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSuccess={async () => {
|
||||
setIsCreateModalOpen(false);
|
||||
await refreshTags();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingTag && (
|
||||
<TagForm
|
||||
isOpen={!!editingTag}
|
||||
tag={editingTag}
|
||||
onClose={() => setEditingTag(null)}
|
||||
onSuccess={async () => {
|
||||
setEditingTag(null);
|
||||
await refreshTags();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,8 +53,7 @@ export function Header({ title = "TowerControl", subtitle = "Task Management", s
|
||||
{ href: '/', label: 'Dashboard' },
|
||||
{ href: '/kanban', label: 'Kanban' },
|
||||
{ href: '/daily', label: 'Daily' },
|
||||
{ href: '/weekly-summary', label: 'Résumé' },
|
||||
{ href: '/tags', label: 'Tags' },
|
||||
{ href: '/weekly-manager', label: 'Manager' },
|
||||
...(isJiraConfigured ? [{ href: '/jira-dashboard', label: `Jira${jiraConfig?.projectKey ? ` (${jiraConfig.projectKey})` : ''}` }] : []),
|
||||
{ href: '/settings', label: 'Settings' }
|
||||
];
|
||||
|
||||
@@ -16,6 +16,7 @@ services:
|
||||
- sqlite_data:/app/data
|
||||
# Monter ta DB locale (décommente pour utiliser tes données locales)
|
||||
- ./prisma/dev.db:/app/data/prod.db
|
||||
- ./backups:/app/backups
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health || exit 1"]
|
||||
|
||||
63
hooks/use-metrics.ts
Normal file
63
hooks/use-metrics.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useState, useEffect, useTransition, useCallback } from 'react';
|
||||
import { getWeeklyMetrics, getVelocityTrends } from '@/actions/metrics';
|
||||
import { WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
|
||||
|
||||
export function useWeeklyMetrics(date?: Date) {
|
||||
const [metrics, setMetrics] = useState<WeeklyMetricsOverview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const fetchMetrics = useCallback(() => {
|
||||
startTransition(async () => {
|
||||
setError(null);
|
||||
const result = await getWeeklyMetrics(date);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setMetrics(result.data);
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch metrics');
|
||||
}
|
||||
});
|
||||
}, [date, startTransition]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetrics();
|
||||
}, [date, fetchMetrics]);
|
||||
|
||||
return {
|
||||
metrics,
|
||||
loading: isPending,
|
||||
error,
|
||||
refetch: fetchMetrics
|
||||
};
|
||||
}
|
||||
|
||||
export function useVelocityTrends(weeksBack: number = 4) {
|
||||
const [trends, setTrends] = useState<VelocityTrend[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const fetchTrends = useCallback(() => {
|
||||
startTransition(async () => {
|
||||
setError(null);
|
||||
const result = await getVelocityTrends(weeksBack);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setTrends(result.data);
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch velocity trends');
|
||||
}
|
||||
});
|
||||
}, [weeksBack, startTransition]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTrends();
|
||||
}, [weeksBack, fetchTrends]);
|
||||
|
||||
return {
|
||||
trends,
|
||||
loading: isPending,
|
||||
error,
|
||||
refetch: fetchTrends
|
||||
};
|
||||
}
|
||||
233
package-lock.json
generated
233
package-lock.json
generated
@@ -12,8 +12,10 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@prisma/client": "^6.16.1",
|
||||
"@types/jspdf": "^1.3.3",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"next": "15.5.3",
|
||||
"prisma": "^6.16.1",
|
||||
"react": "19.1.0",
|
||||
@@ -51,6 +53,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
@@ -2035,6 +2046,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jspdf": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/jspdf/-/jspdf-1.3.3.tgz",
|
||||
"integrity": "sha512-DqwyAKpVuv+7DniCp2Deq1xGvfdnKSNgl9Agun2w6dFvR5UKamiv4VfYUgcypd8S9ojUyARFIlZqBrYrBMQlew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.14.tgz",
|
||||
@@ -2045,6 +2062,19 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz",
|
||||
@@ -2065,6 +2095,13 @@
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
@@ -3000,6 +3037,16 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -3254,6 +3301,26 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvg": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/raf": "^3.4.0",
|
||||
"core-js": "^3.8.3",
|
||||
"raf": "^3.4.1",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rgbcolor": "^1.0.1",
|
||||
"stackblur-canvas": "^2.0.0",
|
||||
"svg-pathdata": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -3413,6 +3480,18 @@
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.45.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.1.tgz",
|
||||
"integrity": "sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3428,6 +3507,16 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
@@ -3768,6 +3857,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
@@ -4687,6 +4786,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-png": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
||||
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/pako": "^2.0.3",
|
||||
"iobuffer": "^5.3.2",
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
@@ -4697,6 +4807,12 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@@ -5171,6 +5287,20 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
@@ -5362,6 +5492,12 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
@@ -5892,6 +6028,23 @@
|
||||
"json5": "lib/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.3.tgz",
|
||||
"integrity": "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.9",
|
||||
"fast-png": "^6.2.0",
|
||||
"fflate": "^0.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvg": "^3.0.11",
|
||||
"core-js": "^3.6.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jsx-ast-utils": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||
@@ -6954,6 +7107,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -7016,6 +7175,13 @@
|
||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -7265,6 +7431,16 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
@@ -7441,6 +7617,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
@@ -7530,6 +7713,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.15"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
@@ -8051,6 +8244,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
@@ -8294,6 +8497,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-pathdata": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/synckit": {
|
||||
"version": "0.11.11",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
|
||||
@@ -8452,6 +8665,16 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
@@ -8800,6 +9023,16 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@prisma/client": "^6.16.1",
|
||||
"@types/jspdf": "^1.3.3",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"next": "15.5.3",
|
||||
"prisma": "^6.16.1",
|
||||
"react": "19.1.0",
|
||||
|
||||
@@ -10,28 +10,44 @@ async function seedTestData() {
|
||||
|
||||
const testTasks = [
|
||||
{
|
||||
title: '🎨 Redesign du dashboard',
|
||||
description: 'Créer une interface moderne et intuitive pour le tableau de bord principal',
|
||||
title: '🎨 Design System Implementation',
|
||||
description: 'Create and implement a comprehensive design system with reusable components',
|
||||
status: 'in_progress' as TaskStatus,
|
||||
priority: 'high' as TaskPriority,
|
||||
tags: ['design', 'ui', 'frontend'],
|
||||
dueDate: new Date('2025-01-20')
|
||||
dueDate: new Date('2025-12-31')
|
||||
},
|
||||
{
|
||||
title: '🔧 Optimiser les performances API',
|
||||
description: 'Améliorer les temps de réponse des endpoints et ajouter la pagination',
|
||||
title: '🔧 API Performance Optimization',
|
||||
description: 'Optimize API endpoints response time and implement pagination',
|
||||
status: 'todo' as TaskStatus,
|
||||
priority: 'medium' as TaskPriority,
|
||||
tags: ['backend', 'performance', 'api'],
|
||||
dueDate: new Date('2025-01-25')
|
||||
dueDate: new Date('2025-12-15')
|
||||
},
|
||||
{
|
||||
title: '✅ Tests unitaires composants',
|
||||
description: 'Ajouter des tests Jest/RTL pour les composants principaux',
|
||||
status: 'done' as TaskStatus,
|
||||
title: '✅ Test Coverage Improvement',
|
||||
description: 'Increase test coverage for core components and services',
|
||||
status: 'todo' as TaskStatus,
|
||||
priority: 'medium' as TaskPriority,
|
||||
tags: ['testing', 'jest', 'quality'],
|
||||
dueDate: new Date('2025-01-10')
|
||||
tags: ['testing', 'quality'],
|
||||
dueDate: new Date('2025-12-20')
|
||||
},
|
||||
{
|
||||
title: '📱 Mobile Responsive Design',
|
||||
description: 'Ensure all pages are fully responsive on mobile devices',
|
||||
status: 'todo' as TaskStatus,
|
||||
priority: 'high' as TaskPriority,
|
||||
tags: ['frontend', 'mobile', 'ui'],
|
||||
dueDate: new Date('2025-12-10')
|
||||
},
|
||||
{
|
||||
title: '🔒 Security Audit',
|
||||
description: 'Conduct a comprehensive security audit of the application',
|
||||
status: 'backlog' as TaskStatus,
|
||||
priority: 'urgent' as TaskPriority,
|
||||
tags: ['security', 'audit'],
|
||||
dueDate: new Date('2026-01-15')
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -4,14 +4,17 @@ async function seedTags() {
|
||||
console.log('🏷️ Création des tags de test...');
|
||||
|
||||
const testTags = [
|
||||
{ name: 'Frontend', color: '#3B82F6' },
|
||||
{ name: 'Backend', color: '#EF4444' },
|
||||
{ name: 'Bug', color: '#F59E0B' },
|
||||
{ name: 'Feature', color: '#10B981' },
|
||||
{ name: 'Urgent', color: '#EC4899' },
|
||||
{ name: 'Design', color: '#8B5CF6' },
|
||||
{ name: 'API', color: '#06B6D4' },
|
||||
{ name: 'Database', color: '#84CC16' },
|
||||
{ name: 'frontend', color: '#3B82F6' },
|
||||
{ name: 'backend', color: '#EF4444' },
|
||||
{ name: 'ui', color: '#8B5CF6' },
|
||||
{ name: 'design', color: '#EC4899' },
|
||||
{ name: 'mobile', color: '#F59E0B' },
|
||||
{ name: 'performance', color: '#10B981' },
|
||||
{ name: 'api', color: '#06B6D4' },
|
||||
{ name: 'testing', color: '#84CC16' },
|
||||
{ name: 'quality', color: '#9333EA' },
|
||||
{ name: 'security', color: '#DC2626' },
|
||||
{ name: 'audit', color: '#2563EB' },
|
||||
];
|
||||
|
||||
for (const tagData of testTags) {
|
||||
|
||||
180
services/jira-summary.ts
Normal file
180
services/jira-summary.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import type { JiraConfig } from './jira';
|
||||
import { Task } from '@/lib/types';
|
||||
|
||||
export interface JiraWeeklyMetrics {
|
||||
totalJiraTasks: number;
|
||||
completedJiraTasks: number;
|
||||
totalStoryPoints: number; // Estimation basée sur le type de ticket
|
||||
projectsContributed: string[];
|
||||
ticketTypes: { [type: string]: number };
|
||||
jiraLinks: Array<{
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
type: string;
|
||||
url: string;
|
||||
estimatedPoints: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class JiraSummaryService {
|
||||
/**
|
||||
* Enrichit les tâches hebdomadaires avec des métriques Jira
|
||||
*/
|
||||
static async getJiraWeeklyMetrics(
|
||||
weeklyTasks: Task[],
|
||||
jiraConfig?: JiraConfig
|
||||
): Promise<JiraWeeklyMetrics | null> {
|
||||
|
||||
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const jiraTasks = weeklyTasks.filter(task =>
|
||||
task.source === 'jira' && task.jiraKey && task.jiraProject
|
||||
);
|
||||
|
||||
if (jiraTasks.length === 0) {
|
||||
return {
|
||||
totalJiraTasks: 0,
|
||||
completedJiraTasks: 0,
|
||||
totalStoryPoints: 0,
|
||||
projectsContributed: [],
|
||||
ticketTypes: {},
|
||||
jiraLinks: []
|
||||
};
|
||||
}
|
||||
|
||||
// Calculer les métriques basiques
|
||||
const completedJiraTasks = jiraTasks.filter(task => task.status === 'done');
|
||||
const projectsContributed = [...new Set(jiraTasks.map(task => task.jiraProject).filter((project): project is string => Boolean(project)))];
|
||||
|
||||
// Analyser les types de tickets
|
||||
const ticketTypes: { [type: string]: number } = {};
|
||||
jiraTasks.forEach(task => {
|
||||
const type = task.jiraType || 'Unknown';
|
||||
ticketTypes[type] = (ticketTypes[type] || 0) + 1;
|
||||
});
|
||||
|
||||
// Estimer les story points basés sur le type de ticket
|
||||
const estimateStoryPoints = (type: string): number => {
|
||||
const typeMapping: { [key: string]: number } = {
|
||||
'Story': 3,
|
||||
'Task': 2,
|
||||
'Bug': 1,
|
||||
'Epic': 8,
|
||||
'Sub-task': 1,
|
||||
'Improvement': 2,
|
||||
'New Feature': 5,
|
||||
'défaut': 1, // French
|
||||
'amélioration': 2, // French
|
||||
'nouvelle fonctionnalité': 5, // French
|
||||
};
|
||||
|
||||
return typeMapping[type] || typeMapping[type?.toLowerCase()] || 2; // Défaut: 2 points
|
||||
};
|
||||
|
||||
const totalStoryPoints = jiraTasks.reduce((sum, task) => {
|
||||
return sum + estimateStoryPoints(task.jiraType || '');
|
||||
}, 0);
|
||||
|
||||
// Créer les liens Jira
|
||||
const jiraLinks = jiraTasks.map(task => ({
|
||||
key: task.jiraKey || '',
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
type: task.jiraType || 'Unknown',
|
||||
url: `${jiraConfig.baseUrl.replace('/rest/api/3', '')}/browse/${task.jiraKey}`,
|
||||
estimatedPoints: estimateStoryPoints(task.jiraType || '')
|
||||
}));
|
||||
|
||||
return {
|
||||
totalJiraTasks: jiraTasks.length,
|
||||
completedJiraTasks: completedJiraTasks.length,
|
||||
totalStoryPoints,
|
||||
projectsContributed,
|
||||
ticketTypes,
|
||||
jiraLinks
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la configuration Jira depuis les préférences utilisateur
|
||||
*/
|
||||
static async getJiraConfig(): Promise<JiraConfig | null> {
|
||||
try {
|
||||
// Import dynamique pour éviter les cycles de dépendance
|
||||
const { userPreferencesService } = await import('./user-preferences');
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
|
||||
if (!preferences.jiraConfig?.baseUrl ||
|
||||
!preferences.jiraConfig?.email ||
|
||||
!preferences.jiraConfig?.apiToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: preferences.jiraConfig.baseUrl,
|
||||
email: preferences.jiraConfig.email,
|
||||
apiToken: preferences.jiraConfig.apiToken,
|
||||
projectKey: preferences.jiraConfig.projectKey,
|
||||
ignoredProjects: preferences.jiraConfig.ignoredProjects
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération de la config Jira:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère des insights business basés sur les métriques Jira
|
||||
*/
|
||||
static generateBusinessInsights(jiraMetrics: JiraWeeklyMetrics): string[] {
|
||||
const insights: string[] = [];
|
||||
|
||||
if (jiraMetrics.totalJiraTasks === 0) {
|
||||
insights.push("Aucune tâche Jira cette semaine. Concentré sur des tâches internes ?");
|
||||
return insights;
|
||||
}
|
||||
|
||||
// Insights sur la completion
|
||||
const completionRate = (jiraMetrics.completedJiraTasks / jiraMetrics.totalJiraTasks) * 100;
|
||||
if (completionRate >= 80) {
|
||||
insights.push(`🎯 Excellent taux de completion Jira: ${completionRate.toFixed(0)}%`);
|
||||
} else if (completionRate < 50) {
|
||||
insights.push(`⚠️ Taux de completion Jira faible: ${completionRate.toFixed(0)}%. Revoir les estimations ?`);
|
||||
}
|
||||
|
||||
// Insights sur les story points
|
||||
if (jiraMetrics.totalStoryPoints > 0) {
|
||||
insights.push(`📊 Estimation: ${jiraMetrics.totalStoryPoints} story points traités cette semaine`);
|
||||
|
||||
const avgPointsPerTask = jiraMetrics.totalStoryPoints / jiraMetrics.totalJiraTasks;
|
||||
if (avgPointsPerTask > 4) {
|
||||
insights.push(`🏋️ Travail sur des tâches complexes (${avgPointsPerTask.toFixed(1)} pts/tâche en moyenne)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Insights sur les projets
|
||||
if (jiraMetrics.projectsContributed.length > 1) {
|
||||
insights.push(`🤝 Contribution multi-projets: ${jiraMetrics.projectsContributed.join(', ')}`);
|
||||
} else if (jiraMetrics.projectsContributed.length === 1) {
|
||||
insights.push(`🎯 Focus sur le projet ${jiraMetrics.projectsContributed[0]}`);
|
||||
}
|
||||
|
||||
// Insights sur les types de tickets
|
||||
const bugCount = jiraMetrics.ticketTypes['Bug'] || jiraMetrics.ticketTypes['défaut'] || 0;
|
||||
const totalTickets = Object.values(jiraMetrics.ticketTypes).reduce((sum, count) => sum + count, 0);
|
||||
|
||||
if (bugCount > 0) {
|
||||
const bugRatio = (bugCount / totalTickets) * 100;
|
||||
if (bugRatio > 50) {
|
||||
insights.push(`🐛 Semaine focalisée sur la correction de bugs (${bugRatio.toFixed(0)}%)`);
|
||||
} else if (bugRatio < 20) {
|
||||
insights.push(`✨ Semaine productive avec peu de bugs (${bugRatio.toFixed(0)}%)`);
|
||||
}
|
||||
}
|
||||
|
||||
return insights;
|
||||
}
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
362
services/metrics.ts
Normal file
362
services/metrics.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { prisma } from './database';
|
||||
import { startOfWeek, endOfWeek, eachDayOfInterval, format, startOfDay, endOfDay } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
export interface DailyMetrics {
|
||||
date: string; // Format ISO
|
||||
dayName: string; // Lundi, Mardi, etc.
|
||||
completed: number;
|
||||
inProgress: number;
|
||||
blocked: number;
|
||||
pending: number;
|
||||
newTasks: number;
|
||||
totalTasks: number;
|
||||
completionRate: number;
|
||||
}
|
||||
|
||||
export interface VelocityTrend {
|
||||
date: string;
|
||||
completed: number;
|
||||
created: number;
|
||||
velocity: number;
|
||||
}
|
||||
|
||||
export interface WeeklyMetricsOverview {
|
||||
period: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
dailyBreakdown: DailyMetrics[];
|
||||
summary: {
|
||||
totalTasksCompleted: number;
|
||||
totalTasksCreated: number;
|
||||
averageCompletionRate: number;
|
||||
peakProductivityDay: string;
|
||||
lowProductivityDay: string;
|
||||
trendsAnalysis: {
|
||||
completionTrend: 'improving' | 'declining' | 'stable';
|
||||
productivityPattern: 'consistent' | 'variable' | 'weekend-heavy';
|
||||
};
|
||||
};
|
||||
statusDistribution: {
|
||||
status: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
color: string;
|
||||
}[];
|
||||
priorityBreakdown: {
|
||||
priority: string;
|
||||
completed: number;
|
||||
pending: number;
|
||||
total: number;
|
||||
completionRate: number;
|
||||
color: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export class MetricsService {
|
||||
/**
|
||||
* Récupère les métriques journalières de la semaine
|
||||
*/
|
||||
static async getWeeklyMetrics(date: Date = new Date()): Promise<WeeklyMetricsOverview> {
|
||||
const weekStart = startOfWeek(date, { weekStartsOn: 1 }); // Lundi
|
||||
const weekEnd = endOfWeek(date, { weekStartsOn: 1 }); // Dimanche
|
||||
|
||||
// Générer tous les jours de la semaine
|
||||
const daysOfWeek = eachDayOfInterval({ start: weekStart, end: weekEnd });
|
||||
|
||||
// Récupérer les données pour chaque jour
|
||||
const dailyBreakdown = await Promise.all(
|
||||
daysOfWeek.map(day => this.getDailyMetrics(day))
|
||||
);
|
||||
|
||||
// Calculer les métriques de résumé
|
||||
const summary = this.calculateWeeklySummary(dailyBreakdown);
|
||||
|
||||
// Récupérer la distribution des statuts pour la semaine
|
||||
const statusDistribution = await this.getStatusDistribution(weekStart, weekEnd);
|
||||
|
||||
// Récupérer la répartition par priorité
|
||||
const priorityBreakdown = await this.getPriorityBreakdown(weekStart, weekEnd);
|
||||
|
||||
return {
|
||||
period: { start: weekStart, end: weekEnd },
|
||||
dailyBreakdown,
|
||||
summary,
|
||||
statusDistribution,
|
||||
priorityBreakdown
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les métriques pour un jour donné
|
||||
*/
|
||||
private static async getDailyMetrics(date: Date): Promise<DailyMetrics> {
|
||||
const dayStart = startOfDay(date);
|
||||
const dayEnd = endOfDay(date);
|
||||
|
||||
// Compter les tâches par statut à la fin de la journée
|
||||
const [completed, inProgress, blocked, pending, newTasks, totalTasks] = await Promise.all([
|
||||
// Tâches complétées ce jour
|
||||
prisma.task.count({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
completedAt: {
|
||||
gte: dayStart,
|
||||
lte: dayEnd
|
||||
}
|
||||
},
|
||||
{
|
||||
status: 'done',
|
||||
updatedAt: {
|
||||
gte: dayStart,
|
||||
lte: dayEnd
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
|
||||
// Tâches en cours (status = in_progress à ce moment)
|
||||
prisma.task.count({
|
||||
where: {
|
||||
status: 'in_progress',
|
||||
createdAt: { lte: dayEnd }
|
||||
}
|
||||
}),
|
||||
|
||||
// Tâches bloquées
|
||||
prisma.task.count({
|
||||
where: {
|
||||
status: 'blocked',
|
||||
createdAt: { lte: dayEnd }
|
||||
}
|
||||
}),
|
||||
|
||||
// Tâches en attente
|
||||
prisma.task.count({
|
||||
where: {
|
||||
status: 'pending',
|
||||
createdAt: { lte: dayEnd }
|
||||
}
|
||||
}),
|
||||
|
||||
// Nouvelles tâches créées ce jour
|
||||
prisma.task.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: dayStart,
|
||||
lte: dayEnd
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Total des tâches existantes ce jour
|
||||
prisma.task.count({
|
||||
where: {
|
||||
createdAt: { lte: dayEnd }
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const completionRate = totalTasks > 0 ? (completed / totalTasks) * 100 : 0;
|
||||
|
||||
return {
|
||||
date: date.toISOString(),
|
||||
dayName: format(date, 'EEEE', { locale: fr }),
|
||||
completed,
|
||||
inProgress,
|
||||
blocked,
|
||||
pending,
|
||||
newTasks,
|
||||
totalTasks,
|
||||
completionRate: Math.round(completionRate * 100) / 100
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le résumé hebdomadaire
|
||||
*/
|
||||
private static calculateWeeklySummary(dailyBreakdown: DailyMetrics[]) {
|
||||
const totalTasksCompleted = dailyBreakdown.reduce((sum, day) => sum + day.completed, 0);
|
||||
const totalTasksCreated = dailyBreakdown.reduce((sum, day) => sum + day.newTasks, 0);
|
||||
const averageCompletionRate = dailyBreakdown.reduce((sum, day) => sum + day.completionRate, 0) / dailyBreakdown.length;
|
||||
|
||||
// Identifier les jours de pic et de creux
|
||||
const peakDay = dailyBreakdown.reduce((peak, day) =>
|
||||
day.completed > peak.completed ? day : peak
|
||||
);
|
||||
const lowDay = dailyBreakdown.reduce((low, day) =>
|
||||
day.completed < low.completed ? day : low
|
||||
);
|
||||
|
||||
// Analyser les tendances
|
||||
const firstHalf = dailyBreakdown.slice(0, 3);
|
||||
const secondHalf = dailyBreakdown.slice(4);
|
||||
const firstHalfAvg = firstHalf.reduce((sum, day) => sum + day.completed, 0) / firstHalf.length;
|
||||
const secondHalfAvg = secondHalf.reduce((sum, day) => sum + day.completed, 0) / secondHalf.length;
|
||||
|
||||
let completionTrend: 'improving' | 'declining' | 'stable';
|
||||
if (secondHalfAvg > firstHalfAvg * 1.1) {
|
||||
completionTrend = 'improving';
|
||||
} else if (secondHalfAvg < firstHalfAvg * 0.9) {
|
||||
completionTrend = 'declining';
|
||||
} else {
|
||||
completionTrend = 'stable';
|
||||
}
|
||||
|
||||
// Analyser le pattern de productivité
|
||||
const weekendDays = dailyBreakdown.slice(5); // Samedi et dimanche
|
||||
const weekdayDays = dailyBreakdown.slice(0, 5);
|
||||
const weekendAvg = weekendDays.reduce((sum, day) => sum + day.completed, 0) / weekendDays.length;
|
||||
const weekdayAvg = weekdayDays.reduce((sum, day) => sum + day.completed, 0) / weekdayDays.length;
|
||||
|
||||
let productivityPattern: 'consistent' | 'variable' | 'weekend-heavy';
|
||||
if (weekendAvg > weekdayAvg * 1.2) {
|
||||
productivityPattern = 'weekend-heavy';
|
||||
} else {
|
||||
const variance = dailyBreakdown.reduce((sum, day) => {
|
||||
const diff = day.completed - (totalTasksCompleted / dailyBreakdown.length);
|
||||
return sum + diff * diff;
|
||||
}, 0) / dailyBreakdown.length;
|
||||
productivityPattern = variance > 4 ? 'variable' : 'consistent';
|
||||
}
|
||||
|
||||
return {
|
||||
totalTasksCompleted,
|
||||
totalTasksCreated,
|
||||
averageCompletionRate: Math.round(averageCompletionRate * 100) / 100,
|
||||
peakProductivityDay: peakDay.dayName,
|
||||
lowProductivityDay: lowDay.dayName,
|
||||
trendsAnalysis: {
|
||||
completionTrend,
|
||||
productivityPattern
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la distribution des statuts pour la période
|
||||
*/
|
||||
private static async getStatusDistribution(start: Date, end: Date) {
|
||||
const statusCounts = await prisma.task.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
status: true
|
||||
},
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: start,
|
||||
lte: end
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const total = statusCounts.reduce((sum, item) => sum + item._count.status, 0);
|
||||
|
||||
const statusColors: { [key: string]: string } = {
|
||||
pending: '#94a3b8', // gray
|
||||
in_progress: '#3b82f6', // blue
|
||||
blocked: '#ef4444', // red
|
||||
done: '#10b981', // green
|
||||
archived: '#6b7280' // gray-500
|
||||
};
|
||||
|
||||
return statusCounts.map(item => ({
|
||||
status: item.status,
|
||||
count: item._count.status,
|
||||
percentage: Math.round((item._count.status / total) * 100 * 100) / 100,
|
||||
color: statusColors[item.status] || '#6b7280'
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la répartition par priorité avec taux de completion
|
||||
*/
|
||||
private static async getPriorityBreakdown(start: Date, end: Date) {
|
||||
const priorities = ['high', 'medium', 'low'];
|
||||
|
||||
const priorityData = await Promise.all(
|
||||
priorities.map(async (priority) => {
|
||||
const [completed, total] = await Promise.all([
|
||||
prisma.task.count({
|
||||
where: {
|
||||
priority,
|
||||
completedAt: {
|
||||
gte: start,
|
||||
lte: end
|
||||
}
|
||||
}
|
||||
}),
|
||||
prisma.task.count({
|
||||
where: {
|
||||
priority,
|
||||
createdAt: {
|
||||
gte: start,
|
||||
lte: end
|
||||
}
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const pending = total - completed;
|
||||
const completionRate = total > 0 ? (completed / total) * 100 : 0;
|
||||
|
||||
return {
|
||||
priority,
|
||||
completed,
|
||||
pending,
|
||||
total,
|
||||
completionRate: Math.round(completionRate * 100) / 100,
|
||||
color: priority === 'high' ? '#ef4444' :
|
||||
priority === 'medium' ? '#f59e0b' : '#10b981'
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return priorityData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les métriques de vélocité d'équipe (pour graphiques de tendance)
|
||||
*/
|
||||
static async getVelocityTrends(weeksBack: number = 4): Promise<VelocityTrend[]> {
|
||||
const trends = [];
|
||||
|
||||
for (let i = weeksBack - 1; i >= 0; i--) {
|
||||
const weekStart = startOfWeek(new Date(Date.now() - i * 7 * 24 * 60 * 60 * 1000), { weekStartsOn: 1 });
|
||||
const weekEnd = endOfWeek(weekStart, { weekStartsOn: 1 });
|
||||
|
||||
const [completed, created] = await Promise.all([
|
||||
prisma.task.count({
|
||||
where: {
|
||||
completedAt: {
|
||||
gte: weekStart,
|
||||
lte: weekEnd
|
||||
}
|
||||
}
|
||||
}),
|
||||
prisma.task.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: weekStart,
|
||||
lte: weekEnd
|
||||
}
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const velocity = created > 0 ? (completed / created) * 100 : 0;
|
||||
|
||||
trends.push({
|
||||
date: format(weekStart, 'dd/MM', { locale: fr }),
|
||||
completed,
|
||||
created,
|
||||
velocity: Math.round(velocity * 100) / 100
|
||||
});
|
||||
}
|
||||
|
||||
return trends;
|
||||
}
|
||||
}
|
||||
185
services/task-categorization.ts
Normal file
185
services/task-categorization.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
export interface PredefinedCategory {
|
||||
name: string;
|
||||
color: string;
|
||||
keywords: string[];
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const PREDEFINED_CATEGORIES: PredefinedCategory[] = [
|
||||
{
|
||||
name: 'Dev',
|
||||
color: '#3b82f6', // Blue
|
||||
icon: '💻',
|
||||
keywords: [
|
||||
'code', 'coding', 'development', 'develop', 'dev', 'programming', 'program',
|
||||
'bug', 'fix', 'debug', 'feature', 'implement', 'refactor', 'review',
|
||||
'api', 'database', 'db', 'frontend', 'backend', 'ui', 'ux',
|
||||
'component', 'service', 'function', 'method', 'class',
|
||||
'git', 'commit', 'merge', 'pull request', 'pr', 'deploy', 'deployment',
|
||||
'test', 'testing', 'unit test', 'integration'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Meeting',
|
||||
color: '#8b5cf6', // Purple
|
||||
icon: '🤝',
|
||||
keywords: [
|
||||
'meeting', 'réunion', 'call', 'standup', 'daily', 'retrospective', 'retro',
|
||||
'planning', 'demo', 'presentation', 'sync', 'catch up', 'catchup',
|
||||
'interview', 'discussion', 'brainstorm', 'workshop', 'session',
|
||||
'one on one', '1on1', 'review meeting', 'sprint planning'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Admin',
|
||||
color: '#6b7280', // Gray
|
||||
icon: '📋',
|
||||
keywords: [
|
||||
'admin', 'administration', 'paperwork', 'documentation', 'doc', 'docs',
|
||||
'report', 'reporting', 'timesheet', 'expense', 'invoice',
|
||||
'email', 'mail', 'communication', 'update', 'status',
|
||||
'config', 'configuration', 'setup', 'installation', 'maintenance',
|
||||
'backup', 'security', 'permission', 'user management'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Learning',
|
||||
color: '#10b981', // Green
|
||||
icon: '📚',
|
||||
keywords: [
|
||||
'learning', 'learn', 'study', 'training', 'course', 'tutorial',
|
||||
'research', 'reading', 'documentation', 'knowledge', 'skill',
|
||||
'certification', 'workshop', 'seminar', 'conference',
|
||||
'practice', 'exercise', 'experiment', 'exploration', 'investigate'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export class TaskCategorizationService {
|
||||
/**
|
||||
* Suggère une catégorie basée sur le titre et la description d'une tâche
|
||||
*/
|
||||
static suggestCategory(title: string, description?: string): PredefinedCategory | null {
|
||||
const text = `${title} ${description || ''}`.toLowerCase();
|
||||
|
||||
// Compte les matches pour chaque catégorie
|
||||
const categoryScores = PREDEFINED_CATEGORIES.map(category => {
|
||||
const matches = category.keywords.filter(keyword =>
|
||||
text.includes(keyword.toLowerCase())
|
||||
).length;
|
||||
|
||||
return {
|
||||
category,
|
||||
score: matches
|
||||
};
|
||||
});
|
||||
|
||||
// Trouve la meilleure catégorie
|
||||
const bestMatch = categoryScores.reduce((best, current) =>
|
||||
current.score > best.score ? current : best
|
||||
);
|
||||
|
||||
// Retourne la catégorie seulement s'il y a au moins un match
|
||||
return bestMatch.score > 0 ? bestMatch.category : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggère plusieurs catégories avec leur score de confiance
|
||||
*/
|
||||
static suggestCategoriesWithScore(title: string, description?: string): Array<{
|
||||
category: PredefinedCategory;
|
||||
score: number;
|
||||
confidence: number;
|
||||
}> {
|
||||
const text = `${title} ${description || ''}`.toLowerCase();
|
||||
|
||||
const categoryScores = PREDEFINED_CATEGORIES.map(category => {
|
||||
const matches = category.keywords.filter(keyword =>
|
||||
text.includes(keyword.toLowerCase())
|
||||
);
|
||||
|
||||
const score = matches.length;
|
||||
const confidence = Math.min((score / 3) * 100, 100); // Max 100% de confiance avec 3+ mots-clés
|
||||
|
||||
return {
|
||||
category,
|
||||
score,
|
||||
confidence
|
||||
};
|
||||
});
|
||||
|
||||
return categoryScores
|
||||
.filter(item => item.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse les activités et retourne la répartition par catégorie
|
||||
*/
|
||||
static analyzeActivitiesByCategory(activities: Array<{ title: string; description?: string }>): {
|
||||
[categoryName: string]: {
|
||||
count: number;
|
||||
percentage: number;
|
||||
color: string;
|
||||
icon: string;
|
||||
}
|
||||
} {
|
||||
const categoryCounts: { [key: string]: number } = {};
|
||||
const uncategorized = { count: 0 };
|
||||
|
||||
// Initialiser les compteurs
|
||||
PREDEFINED_CATEGORIES.forEach(cat => {
|
||||
categoryCounts[cat.name] = 0;
|
||||
});
|
||||
|
||||
// Analyser chaque activité
|
||||
activities.forEach(activity => {
|
||||
const suggestedCategory = this.suggestCategory(activity.title, activity.description);
|
||||
|
||||
if (suggestedCategory) {
|
||||
categoryCounts[suggestedCategory.name]++;
|
||||
} else {
|
||||
uncategorized.count++;
|
||||
}
|
||||
});
|
||||
|
||||
const total = activities.length;
|
||||
const result: { [categoryName: string]: { count: number; percentage: number; color: string; icon: string } } = {};
|
||||
|
||||
// Ajouter les catégories prédéfinies
|
||||
PREDEFINED_CATEGORIES.forEach(category => {
|
||||
const count = categoryCounts[category.name];
|
||||
result[category.name] = {
|
||||
count,
|
||||
percentage: total > 0 ? (count / total) * 100 : 0,
|
||||
color: category.color,
|
||||
icon: category.icon
|
||||
};
|
||||
});
|
||||
|
||||
// Ajouter "Autre" si nécessaire
|
||||
if (uncategorized.count > 0) {
|
||||
result['Autre'] = {
|
||||
count: uncategorized.count,
|
||||
percentage: total > 0 ? (uncategorized.count / total) * 100 : 0,
|
||||
color: '#d1d5db',
|
||||
icon: '❓'
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les tags suggérés pour une tâche
|
||||
*/
|
||||
static getSuggestedTags(title: string, description?: string): string[] {
|
||||
const suggestions = this.suggestCategoriesWithScore(title, description);
|
||||
|
||||
return suggestions
|
||||
.filter(s => s.confidence >= 30) // Seulement les suggestions avec 30%+ de confiance
|
||||
.slice(0, 2) // Maximum 2 suggestions
|
||||
.map(s => s.category.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
import { prisma } from './database';
|
||||
import { Task, TaskStatus, TaskPriority, TaskSource } from '@/lib/types';
|
||||
|
||||
export interface DailyItem {
|
||||
id: string;
|
||||
text: string;
|
||||
isChecked: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
export interface WeeklyStats {
|
||||
totalCheckboxes: number;
|
||||
completedCheckboxes: number;
|
||||
totalTasks: number;
|
||||
completedTasks: number;
|
||||
checkboxCompletionRate: number;
|
||||
taskCompletionRate: number;
|
||||
mostProductiveDay: string;
|
||||
dailyBreakdown: Array<{
|
||||
date: string;
|
||||
dayName: string;
|
||||
checkboxes: number;
|
||||
completedCheckboxes: number;
|
||||
tasks: number;
|
||||
completedTasks: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface WeeklyActivity {
|
||||
id: string;
|
||||
type: 'checkbox' | 'task';
|
||||
title: string;
|
||||
completed: boolean;
|
||||
completedAt?: Date;
|
||||
createdAt: Date;
|
||||
date: string;
|
||||
dayName: string;
|
||||
}
|
||||
|
||||
export interface WeeklySummary {
|
||||
stats: WeeklyStats;
|
||||
activities: WeeklyActivity[];
|
||||
period: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
}
|
||||
|
||||
export class WeeklySummaryService {
|
||||
/**
|
||||
* Récupère le résumé complet de la semaine écoulée
|
||||
*/
|
||||
static async getWeeklySummary(): Promise<WeeklySummary> {
|
||||
const now = new Date();
|
||||
const startOfWeek = new Date(now);
|
||||
startOfWeek.setDate(now.getDate() - 7);
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfWeek = new Date(now);
|
||||
endOfWeek.setHours(23, 59, 59, 999);
|
||||
|
||||
console.log(`📊 Génération du résumé hebdomadaire du ${startOfWeek.toLocaleDateString()} au ${endOfWeek.toLocaleDateString()}`);
|
||||
|
||||
const [checkboxes, tasks] = await Promise.all([
|
||||
this.getWeeklyCheckboxes(startOfWeek, endOfWeek),
|
||||
this.getWeeklyTasks(startOfWeek, endOfWeek)
|
||||
]);
|
||||
|
||||
const stats = this.calculateStats(checkboxes, tasks, startOfWeek, endOfWeek);
|
||||
const activities = this.mergeActivities(checkboxes, tasks);
|
||||
|
||||
return {
|
||||
stats,
|
||||
activities,
|
||||
period: {
|
||||
start: startOfWeek,
|
||||
end: endOfWeek
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les checkboxes des 7 derniers jours
|
||||
*/
|
||||
private static async getWeeklyCheckboxes(startDate: Date, endDate: Date): Promise<DailyItem[]> {
|
||||
const items = await prisma.dailyCheckbox.findMany({
|
||||
where: {
|
||||
date: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
},
|
||||
orderBy: [
|
||||
{ date: 'desc' },
|
||||
{ createdAt: 'desc' }
|
||||
]
|
||||
});
|
||||
|
||||
return items.map(item => ({
|
||||
id: item.id,
|
||||
text: item.text,
|
||||
isChecked: item.isChecked,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
date: item.date
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les tâches des 7 derniers jours (créées ou modifiées)
|
||||
*/
|
||||
private static async getWeeklyTasks(startDate: Date, endDate: Date): Promise<Task[]> {
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
createdAt: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
},
|
||||
{
|
||||
updatedAt: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
return tasks.map(task => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
status: task.status as TaskStatus,
|
||||
priority: task.priority as TaskPriority,
|
||||
source: task.source as TaskSource,
|
||||
sourceId: task.sourceId || undefined,
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
dueDate: task.dueDate || undefined,
|
||||
completedAt: task.completedAt || undefined,
|
||||
jiraProject: task.jiraProject || undefined,
|
||||
jiraKey: task.jiraKey || undefined,
|
||||
jiraType: task.jiraType || undefined,
|
||||
assignee: task.assignee || undefined,
|
||||
tags: [] // Les tags sont dans une relation séparée, on les laisse vides pour l'instant
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les statistiques de la semaine
|
||||
*/
|
||||
private static calculateStats(
|
||||
checkboxes: DailyItem[],
|
||||
tasks: Task[],
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): WeeklyStats {
|
||||
const completedCheckboxes = checkboxes.filter(c => c.isChecked);
|
||||
const completedTasks = tasks.filter(t => t.status === 'done');
|
||||
|
||||
// Créer un breakdown par jour
|
||||
const dailyBreakdown = [];
|
||||
const current = new Date(startDate);
|
||||
|
||||
while (current <= endDate) {
|
||||
const dayCheckboxes = checkboxes.filter(c =>
|
||||
c.date.toISOString().split('T')[0] === current.toISOString().split('T')[0]
|
||||
);
|
||||
const dayCompletedCheckboxes = dayCheckboxes.filter(c => c.isChecked);
|
||||
|
||||
// Pour les tâches, on compte celles modifiées ce jour-là
|
||||
const dayTasks = tasks.filter(t =>
|
||||
t.updatedAt.toISOString().split('T')[0] === current.toISOString().split('T')[0] ||
|
||||
t.createdAt.toISOString().split('T')[0] === current.toISOString().split('T')[0]
|
||||
);
|
||||
const dayCompletedTasks = dayTasks.filter(t => t.status === 'done');
|
||||
|
||||
dailyBreakdown.push({
|
||||
date: current.toISOString().split('T')[0],
|
||||
dayName: current.toLocaleDateString('fr-FR', { weekday: 'long' }),
|
||||
checkboxes: dayCheckboxes.length,
|
||||
completedCheckboxes: dayCompletedCheckboxes.length,
|
||||
tasks: dayTasks.length,
|
||||
completedTasks: dayCompletedTasks.length
|
||||
});
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
// Trouver le jour le plus productif
|
||||
const mostProductiveDay = dailyBreakdown.reduce((max, day) => {
|
||||
const dayScore = day.completedCheckboxes + day.completedTasks;
|
||||
const maxScore = max.completedCheckboxes + max.completedTasks;
|
||||
return dayScore > maxScore ? day : max;
|
||||
}, dailyBreakdown[0]);
|
||||
|
||||
return {
|
||||
totalCheckboxes: checkboxes.length,
|
||||
completedCheckboxes: completedCheckboxes.length,
|
||||
totalTasks: tasks.length,
|
||||
completedTasks: completedTasks.length,
|
||||
checkboxCompletionRate: checkboxes.length > 0 ? (completedCheckboxes.length / checkboxes.length) * 100 : 0,
|
||||
taskCompletionRate: tasks.length > 0 ? (completedTasks.length / tasks.length) * 100 : 0,
|
||||
mostProductiveDay: mostProductiveDay.dayName,
|
||||
dailyBreakdown
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fusionne les activités (checkboxes + tâches) en une timeline
|
||||
*/
|
||||
private static mergeActivities(checkboxes: DailyItem[], tasks: Task[]): WeeklyActivity[] {
|
||||
const activities: WeeklyActivity[] = [];
|
||||
|
||||
// Ajouter les checkboxes
|
||||
checkboxes.forEach(checkbox => {
|
||||
activities.push({
|
||||
id: `checkbox-${checkbox.id}`,
|
||||
type: 'checkbox',
|
||||
title: checkbox.text,
|
||||
completed: checkbox.isChecked,
|
||||
completedAt: checkbox.isChecked ? checkbox.updatedAt : undefined,
|
||||
createdAt: checkbox.createdAt,
|
||||
date: checkbox.date.toISOString().split('T')[0],
|
||||
dayName: checkbox.date.toLocaleDateString('fr-FR', { weekday: 'long' })
|
||||
});
|
||||
});
|
||||
|
||||
// Ajouter les tâches
|
||||
tasks.forEach(task => {
|
||||
const date = task.updatedAt.toISOString().split('T')[0];
|
||||
const dateObj = new Date(date + 'T00:00:00');
|
||||
activities.push({
|
||||
id: `task-${task.id}`,
|
||||
type: 'task',
|
||||
title: task.title,
|
||||
completed: task.status === 'done',
|
||||
completedAt: task.status === 'done' ? task.updatedAt : undefined,
|
||||
createdAt: task.createdAt,
|
||||
date: date,
|
||||
dayName: dateObj.toLocaleDateString('fr-FR', { weekday: 'long' })
|
||||
});
|
||||
});
|
||||
|
||||
// Trier par date (plus récent en premier)
|
||||
return activities.sort((a, b) => {
|
||||
const dateA = a.completedAt || a.createdAt;
|
||||
const dateB = b.completedAt || b.createdAt;
|
||||
return dateB.getTime() - dateA.getTime();
|
||||
});
|
||||
}
|
||||
}
|
||||
78
src/actions/metrics.ts
Normal file
78
src/actions/metrics.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
'use server';
|
||||
|
||||
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
/**
|
||||
* Récupère les métriques hebdomadaires pour une date donnée
|
||||
*/
|
||||
export async function getWeeklyMetrics(date?: Date): Promise<{
|
||||
success: boolean;
|
||||
data?: WeeklyMetricsOverview;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const targetDate = date || new Date();
|
||||
const metrics = await MetricsService.getWeeklyMetrics(targetDate);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: metrics
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching weekly metrics:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch weekly metrics'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les tendances de vélocité sur plusieurs semaines
|
||||
*/
|
||||
export async function getVelocityTrends(weeksBack: number = 4): Promise<{
|
||||
success: boolean;
|
||||
data?: VelocityTrend[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
if (weeksBack < 1 || weeksBack > 12) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid weeksBack parameter (must be 1-12)'
|
||||
};
|
||||
}
|
||||
|
||||
const trends = await MetricsService.getVelocityTrends(weeksBack);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: trends
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching velocity trends:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch velocity trends'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rafraîchir les données de métriques (invalide le cache)
|
||||
*/
|
||||
export async function refreshMetrics(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
revalidatePath('/manager');
|
||||
return { success: true };
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to refresh metrics'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient';
|
||||
|
||||
// Force dynamic rendering for real-time data
|
||||
@@ -6,7 +7,10 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function GeneralSettingsPage() {
|
||||
// Fetch data server-side
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
const [preferences, tags] = await Promise.all([
|
||||
userPreferencesService.getAllPreferences(),
|
||||
tagsService.getTags()
|
||||
]);
|
||||
|
||||
return <GeneralSettingsPageClient initialPreferences={preferences} />;
|
||||
return <GeneralSettingsPageClient initialPreferences={preferences} initialTags={tags} />;
|
||||
}
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { Tag } from '@/lib/types';
|
||||
import { useTags } from '@/hooks/useTags';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { TagForm } from '@/components/forms/TagForm';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
|
||||
interface TagsPageClientProps {
|
||||
initialTags: Tag[];
|
||||
}
|
||||
|
||||
export function TagsPageClient({ initialTags }: TagsPageClientProps) {
|
||||
const {
|
||||
tags,
|
||||
loading,
|
||||
error,
|
||||
refreshTags,
|
||||
deleteTag
|
||||
} = useTags(initialTags as (Tag & { usage: number })[]);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
|
||||
|
||||
// Filtrer et trier les tags
|
||||
const filteredAndSortedTags = useMemo(() => {
|
||||
let filtered = tags;
|
||||
|
||||
// Filtrer par recherche
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = tags.filter(tag =>
|
||||
tag.name.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Trier par usage puis par nom
|
||||
return filtered.sort((a, b) => {
|
||||
const usageA = (a as Tag & { usage?: number }).usage || 0;
|
||||
const usageB = (b as Tag & { usage?: number }).usage || 0;
|
||||
if (usageB !== usageA) return usageB - usageA;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [tags, searchQuery]);
|
||||
|
||||
const handleEditTag = (tag: Tag) => {
|
||||
setEditingTag(tag);
|
||||
};
|
||||
|
||||
const handleDeleteTag = async (tag: Tag) => {
|
||||
if (!confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingTagId(tag.id);
|
||||
try {
|
||||
// Utiliser la server action directement depuis useTags
|
||||
await deleteTag(tag.id);
|
||||
// Refresh la liste des tags
|
||||
await refreshTags();
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression:', error);
|
||||
} finally {
|
||||
setDeletingTagId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
{/* Header uniforme */}
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Tags - Gestion des étiquettes"
|
||||
syncing={loading}
|
||||
/>
|
||||
|
||||
{/* Header spécifique aux tags */}
|
||||
<div className="bg-[var(--card)]/50 border-b border-[var(--border)]/30">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-lg font-mono font-bold text-[var(--foreground)] tracking-wider">
|
||||
Tags ({filteredAndSortedTags.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Nouveau tag
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<div className="container mx-auto px-6 py-6">
|
||||
{/* Barre de recherche */}
|
||||
<div className="max-w-md mx-auto mb-6">
|
||||
<Input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Rechercher un tag..."
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Messages d'état */}
|
||||
{error && (
|
||||
<div className="max-w-md mx-auto mb-6 bg-[var(--destructive)]/20 border border-[var(--destructive)]/30 rounded-lg p-3 text-center">
|
||||
<div className="text-[var(--destructive)] text-sm">Erreur : {error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-[var(--muted-foreground)]">Chargement...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags en grille compacte */}
|
||||
{!loading && (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{filteredAndSortedTags.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--muted-foreground)]">
|
||||
<div className="text-6xl mb-4">🏷️</div>
|
||||
<p className="text-lg mb-2">
|
||||
{searchQuery ? 'Aucun tag trouvé' : 'Aucun tag'}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{searchQuery ? 'Essayez un autre terme' : 'Créez votre premier tag'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{filteredAndSortedTags.map((tag) => {
|
||||
const isDeleting = deletingTagId === tag.id;
|
||||
const usage = (tag as Tag & { usage?: number }).usage || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`relative bg-[var(--card)] rounded-lg border border-[var(--border)] hover:border-[var(--border)] transition-all duration-200 p-4 group ${
|
||||
isDeleting ? 'opacity-50 pointer-events-none' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Actions en overlay */}
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<button
|
||||
onClick={() => handleEditTag(tag)}
|
||||
className="p-1.5 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors rounded-lg hover:bg-[var(--card-hover)]"
|
||||
title="Modifier"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTag(tag)}
|
||||
className="p-1.5 text-[var(--muted-foreground)] hover:text-[var(--destructive)] transition-colors rounded-lg hover:bg-[var(--destructive)]/20"
|
||||
title="Supprimer"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
|
||||
</div>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<div className="flex items-start gap-3 pr-12">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full flex-shrink-0 mt-0.5"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-[var(--foreground)] font-medium truncate text-sm mb-1">
|
||||
{tag.name}
|
||||
</h3>
|
||||
<p className="text-[var(--muted-foreground)] text-xs mb-2">
|
||||
{usage} tâche{usage > 1 ? 's' : ''}
|
||||
</p>
|
||||
{tag.isPinned && (
|
||||
<span className="inline-flex items-center text-[var(--accent)] text-xs bg-[var(--accent)]/10 px-2 py-1 rounded-full">
|
||||
🎯 Objectif
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<TagForm
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSuccess={refreshTags}
|
||||
/>
|
||||
|
||||
<TagForm
|
||||
isOpen={!!editingTag}
|
||||
onClose={() => setEditingTag(null)}
|
||||
onSuccess={refreshTags}
|
||||
tag={editingTag}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { TagsPageClient } from './TagsPageClient';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function TagsPage() {
|
||||
// SSR - Récupération des tags côté serveur
|
||||
const initialTags = await tagsService.getTags();
|
||||
|
||||
return (
|
||||
<TagsPageClient initialTags={initialTags} />
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import WeeklySummaryClient from '@/components/dashboard/WeeklySummaryClient';
|
||||
import { WeeklySummaryService } from '@/services/weekly-summary';
|
||||
|
||||
export default async function WeeklySummaryPage() {
|
||||
// Récupération côté serveur
|
||||
const summary = await WeeklySummaryService.getWeeklySummary();
|
||||
|
||||
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">
|
||||
<WeeklySummaryClient initialSummary={summary} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user