diff --git a/.claude/rules/wiki.md b/.claude/rules/wiki.md
new file mode 100644
index 0000000..11eb1ea
--- /dev/null
+++ b/.claude/rules/wiki.md
@@ -0,0 +1,31 @@
+# Règles — Documentation wiki
+
+## Principe
+Toute évolution du projet (nouvelle fonctionnalité, modification d'architecture, nouveau composant, changement de route ou de menu) doit être répercutée dans le wiki situé à `../Bonsai-webapp.wiki/`.
+
+## Quand mettre à jour
+À la fin de chaque session de développement, avant de conclure, vérifier si les fichiers suivants sont impactés et les mettre à jour si nécessaire :
+
+| Fichier wiki | Contenu concerné |
+|---|---|
+| `Home.md` | Navigation générale — ajouter les nouveaux modules |
+| `Architecture.md` | Arborescence `src/`, nouvelles routes, nouveaux composants |
+| `Module-Issues.md` | Toute évolution du module Issues (composants, store, filtres) |
+| `Module-Milestones.md` | Toute évolution du module Milestones |
+| `API-REST.md` | Nouveaux endpoints consommés ou fichiers `api-issues/` créés |
+| `Tests.md` | Nouvelles règles de test, nouveaux patterns, évolution des seuils |
+| `Authentification.md` | Évolution de Keycloak, guards, intercepteurs |
+| `Developpement-local.md` | Nouvelles dépendances, commandes, variables d'environnement |
+
+Si un nouveau module est créé (ex. `dashboard/`), créer le fichier wiki correspondant (ex. `Module-Dashboard.md`) et l'ajouter à la navigation dans `Home.md`.
+
+## Comportement attendu
+- Mettre à jour uniquement les sections réellement impactées par les changements effectués.
+- Ne pas réécrire l'intégralité d'un fichier : préférer des ajouts ou corrections ciblés.
+- Informer clairement que la documentation a été mise à jour et lister les fichiers modifiés.
+- Si la mise à jour n'est pas possible (wiki inaccessible), le signaler explicitement.
+
+## Rappel de fin de session
+Avant de conclure toute réponse qui termine une tâche de développement, ajouter systématiquement :
+
+> **Documentation** : [liste des fichiers wiki mis à jour ou raison pour laquelle aucune mise à jour n'était nécessaire]
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..5795e56
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,11 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(mkdir -p /var/home/Gato/IdeaProjects/Bonsai-webapp/src/app/dashboard)"
+ ],
+ "additionalDirectories": [
+ "/var/home/Gato/IdeaProjects/Bonsai-webapp/src/app",
+ "/var/home/Gato/IdeaProjects/Bonsai-webapp.wiki"
+ ]
+ }
+}
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index f9c1def..a6d8045 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -1,4 +1,5 @@
import { Routes } from '@angular/router';
+import { Dashboard } from './dashboard/dashboard';
import { Home } from './home/home';
import { IssueDetail } from './issues/issue-detail/issue-detail';
import { Issues } from './issues/issues';
@@ -10,6 +11,7 @@ import { authGuard } from './auth/auth.guard';
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'home' },
{ path: 'home', component: Home },
+ { path: 'dashboard', component: Dashboard, canActivate: [authGuard] },
{ path: 'project', component: Projects, canActivate: [authGuard] },
{ path: 'projects', redirectTo: 'project' },
{ path: 'issues/new', component: IssueDetail, canActivate: [authGuard] },
diff --git a/src/app/dashboard/dashboard.css b/src/app/dashboard/dashboard.css
new file mode 100644
index 0000000..8b26b7f
--- /dev/null
+++ b/src/app/dashboard/dashboard.css
@@ -0,0 +1,124 @@
+:host {
+ display: block;
+}
+
+.kpi-card {
+ cursor: default;
+ transition: box-shadow 0.15s;
+}
+
+.kpi-card[role="button"] {
+ cursor: pointer;
+}
+
+.kpi-card[role="button"]:hover {
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important;
+}
+
+.kpi-card--alert {
+ border-left: 3px solid #dc3545;
+}
+
+.kpi-label {
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: #6b7280;
+ margin-bottom: 0.25rem;
+}
+
+.kpi-value {
+ font-size: 2rem;
+ font-weight: 700;
+ line-height: 1;
+ color: #111827;
+ margin-bottom: 0.25rem;
+}
+
+.kpi-sub {
+ font-size: 0.8rem;
+}
+
+.status-badge {
+ display: inline-block;
+ padding: 0.15rem 0.5rem;
+ border-radius: 0.25rem;
+ font-size: 0.7rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+}
+
+.type-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 4px;
+ color: #fff;
+ font-size: 0.65rem;
+ font-weight: 700;
+ flex-shrink: 0;
+}
+
+.issue-row {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.55rem 1rem;
+ border-bottom: 1px solid #f3f4f6;
+ cursor: pointer;
+ transition: background 0.1s;
+}
+
+.issue-row:last-child {
+ border-bottom: none;
+}
+
+.issue-row:hover {
+ background: #f9fafb;
+}
+
+.issue-row:focus-visible {
+ outline: 2px solid #2563eb;
+ outline-offset: -2px;
+}
+
+.priority-symbol {
+ font-weight: 700;
+ font-size: 1rem;
+ letter-spacing: -1px;
+ flex-shrink: 0;
+}
+
+.status-badge-sm {
+ display: inline-block;
+ padding: 0.1rem 0.4rem;
+ border-radius: 0.2rem;
+ font-size: 0.65rem;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.milestone-row {
+ padding: 0.65rem 1rem;
+ border-bottom: 1px solid #f3f4f6;
+ cursor: pointer;
+ transition: background 0.1s;
+}
+
+.milestone-row:last-child {
+ border-bottom: none;
+}
+
+.milestone-row:hover {
+ background: #f9fafb;
+}
+
+.milestone-row:focus-visible {
+ outline: 2px solid #2563eb;
+ outline-offset: -2px;
+}
diff --git a/src/app/dashboard/dashboard.html b/src/app/dashboard/dashboard.html
new file mode 100644
index 0000000..e013bde
--- /dev/null
+++ b/src/app/dashboard/dashboard.html
@@ -0,0 +1,217 @@
+
+
+
+
Tableau de bord
+
Vue d'ensemble du projet.
+
+
+
+
+
+
+
+
+
Issues totales
+
{{ totalIssues() }}
+
{{ completionRate() }}% terminées
+
+
+
+
+
+
+
+
+
En cours
+
{{ statusCounts().inProgress }}
+
{{ statusCounts().todo }} à faire
+
+
+
+
+
+
0">
+
+
En retard
+
0" [class.text-secondary]="overdueCount() === 0">
+ {{ overdueCount() }}
+
+
issues dépassées
+
+
+
+
+
+
+
+
Milestones
+
{{ totalMilestones() }}
+
{{ activeMilestones().length }} en cours
+
+
+
+
+
+
+
+
+
+
+
+ @for (item of statusItems(); track item.status) {
+
+
+ {{ statusBadge(item.status).label }}
+ {{ item.count }}
+
+
+
0 ? (item.count / totalIssues() * 100) : 0"
+ [style.background]="statusBadge(item.status).color"
+ >
+
+
+ }
+ @if (totalIssues() === 0) {
+
Aucune issue.
+ }
+
+
+
+
+
+
+
+
+ @for (item of issuesByType(); track item.type) {
+
+ {{ item.icon.letter }}
+ {{ item.type }}
+ {{ item.count }}
+
+ }
+ @if (issuesByType().length === 0) {
+
Aucune issue.
+ }
+
+
+
+
+
+
+
+
+
+
+
+ @for (issue of highPriorityIssues(); track issue.id) {
+
+ {{ typeIcon(issue.type).letter }}
+ {{ issue.name }}
+ {{ priorityDisplay(issue.priority).symbol }}
+ {{ statusBadge(issue.status).label }}
+
+ }
+ @if (highPriorityIssues().length === 0) {
+
Aucune issue haute priorité ouverte.
+ }
+
+
+
+
+
+
+
+
+ @for (m of activeMilestones(); track m.id) {
+
+
+ {{ m.name }}
+ {{ m.progress }}%
+
+
+
+
{{ formatDate(m.dueDate) }}
+
+
+ }
+ @if (activeMilestones().length === 0) {
+
Tous les milestones sont terminés.
+ }
+
+
+
+
+
+
+@if (upcomingIssues().length > 0) {
+
+
+
+
+
+ @for (issue of upcomingIssues(); track issue.id) {
+
+ {{ typeIcon(issue.type).letter }}
+ {{ issue.name }}
+ {{ formatDate(issue.dueDate) }}
+ {{ statusBadge(issue.status).label }}
+
+ }
+
+
+
+
+}
diff --git a/src/app/dashboard/dashboard.spec.ts b/src/app/dashboard/dashboard.spec.ts
new file mode 100644
index 0000000..437f3a5
--- /dev/null
+++ b/src/app/dashboard/dashboard.spec.ts
@@ -0,0 +1,289 @@
+import { signal } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { provideRouter, Router } from '@angular/router';
+import { vi } from 'vitest';
+import { Dashboard } from './dashboard';
+import { IssueEntity, IssuesStore } from '../issues/issues.store';
+import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
+
+const makeIssue = (overrides: Partial = {}): IssueEntity => ({
+ id: 1,
+ type: 'Story',
+ assignee: '',
+ epic: '',
+ name: 'Test Issue',
+ dueDate: '',
+ description: '',
+ estimatedTime: null,
+ dependsOnIds: [],
+ comments: [],
+ priority: 'MOYENNE',
+ status: 'todo',
+ progress: 0,
+ ...overrides,
+});
+
+class FakeIssuesStore {
+ private _data = signal([]);
+ readonly issues = this._data.asReadonly();
+ readonly loading = signal(false);
+ readonly loaded = signal(true);
+
+ seed(issues: IssueEntity[]): void {
+ this._data.set(issues);
+ }
+
+ load = vi.fn().mockResolvedValue(undefined);
+ getById = vi.fn();
+ getNextId = vi.fn().mockReturnValue(1);
+ upsert = vi.fn();
+ deleteById = vi.fn();
+}
+
+class FakeMilestonesStore {
+ private _data = signal([]);
+ readonly milestones = this._data.asReadonly();
+ readonly loading = signal(false);
+ readonly loaded = signal(true);
+
+ seed(milestones: MilestoneEntity[]): void {
+ this._data.set(milestones);
+ }
+
+ load = vi.fn().mockResolvedValue(undefined);
+ upsert = vi.fn();
+ deleteById = vi.fn();
+}
+
+describe('Dashboard', () => {
+ let component: Dashboard;
+ let fixture: ComponentFixture;
+ let issuesStore: FakeIssuesStore;
+ let milestonesStore: FakeMilestonesStore;
+ let router: Router;
+
+ beforeEach(async () => {
+ issuesStore = new FakeIssuesStore();
+ milestonesStore = new FakeMilestonesStore();
+
+ await TestBed.configureTestingModule({
+ imports: [Dashboard],
+ providers: [
+ provideRouter([]),
+ { provide: IssuesStore, useValue: issuesStore },
+ { provide: MilestonesStore, useValue: milestonesStore },
+ ],
+ }).compileComponents();
+
+ router = TestBed.inject(Router);
+ fixture = TestBed.createComponent(Dashboard);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('totalIssues', () => {
+ it('retourne 0 quand il n\'y a pas d\'issues', () => {
+ expect((component as any).totalIssues()).toBe(0);
+ });
+
+ it('retourne le nombre total d\'issues', () => {
+ issuesStore.seed([makeIssue({ id: 1 }), makeIssue({ id: 2 }), makeIssue({ id: 3 })]);
+ expect((component as any).totalIssues()).toBe(3);
+ });
+ });
+
+ describe('completionRate', () => {
+ it('retourne 0 quand il n\'y a pas d\'issues', () => {
+ expect((component as any).completionRate()).toBe(0);
+ });
+
+ it('retourne 0 quand aucune issue n\'est terminée', () => {
+ issuesStore.seed([makeIssue({ status: 'todo' }), makeIssue({ status: 'in-progress' })]);
+ expect((component as any).completionRate()).toBe(0);
+ });
+
+ it('retourne 100 quand toutes les issues sont terminées', () => {
+ issuesStore.seed([makeIssue({ status: 'done' }), makeIssue({ status: 'done' })]);
+ expect((component as any).completionRate()).toBe(100);
+ });
+
+ it('calcule le pourcentage correct', () => {
+ issuesStore.seed([
+ makeIssue({ id: 1, status: 'done' }),
+ makeIssue({ id: 2, status: 'done' }),
+ makeIssue({ id: 3, status: 'todo' }),
+ makeIssue({ id: 4, status: 'todo' }),
+ ]);
+ expect((component as any).completionRate()).toBe(50);
+ });
+ });
+
+ describe('statusCounts', () => {
+ it('compte correctement chaque statut', () => {
+ issuesStore.seed([
+ makeIssue({ id: 1, status: 'draft' }),
+ makeIssue({ id: 2, status: 'todo' }),
+ makeIssue({ id: 3, status: 'todo' }),
+ makeIssue({ id: 4, status: 'in-progress' }),
+ makeIssue({ id: 5, status: 'done' }),
+ ]);
+ const counts = (component as any).statusCounts();
+ expect(counts.draft).toBe(1);
+ expect(counts.todo).toBe(2);
+ expect(counts.inProgress).toBe(1);
+ expect(counts.done).toBe(1);
+ });
+ });
+
+ describe('overdueCount', () => {
+ it('retourne 0 quand aucune issue n\'est en retard', () => {
+ const future = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString();
+ issuesStore.seed([makeIssue({ dueDate: future, status: 'todo' })]);
+ expect((component as any).overdueCount()).toBe(0);
+ });
+
+ it('ne compte pas les issues terminées même dépassées', () => {
+ const past = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString();
+ issuesStore.seed([makeIssue({ dueDate: past, status: 'done' })]);
+ expect((component as any).overdueCount()).toBe(0);
+ });
+
+ it('compte les issues non terminées avec date passée', () => {
+ const past = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString();
+ issuesStore.seed([
+ makeIssue({ id: 1, dueDate: past, status: 'todo' }),
+ makeIssue({ id: 2, dueDate: past, status: 'in-progress' }),
+ ]);
+ expect((component as any).overdueCount()).toBe(2);
+ });
+ });
+
+ describe('highPriorityIssues', () => {
+ it('retourne les issues HAUTE et TRES_HAUTE non terminées', () => {
+ issuesStore.seed([
+ makeIssue({ id: 1, priority: 'TRES_HAUTE', status: 'todo' }),
+ makeIssue({ id: 2, priority: 'HAUTE', status: 'in-progress' }),
+ makeIssue({ id: 3, priority: 'MOYENNE', status: 'todo' }),
+ ]);
+ expect((component as any).highPriorityIssues().length).toBe(2);
+ });
+
+ it('exclut les issues haute priorité terminées', () => {
+ issuesStore.seed([makeIssue({ priority: 'TRES_HAUTE', status: 'done' })]);
+ expect((component as any).highPriorityIssues().length).toBe(0);
+ });
+
+ it('limite à 6 résultats', () => {
+ issuesStore.seed(
+ Array.from({ length: 10 }, (_, i) =>
+ makeIssue({ id: i + 1, priority: 'HAUTE', status: 'todo' }),
+ ),
+ );
+ expect((component as any).highPriorityIssues().length).toBe(6);
+ });
+ });
+
+ describe('upcomingIssues', () => {
+ it('retourne les issues avec échéance dans les 14 prochains jours', () => {
+ const soon = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
+ issuesStore.seed([makeIssue({ dueDate: soon, status: 'todo' })]);
+ expect((component as any).upcomingIssues().length).toBe(1);
+ });
+
+ it('exclut les issues terminées', () => {
+ const soon = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
+ issuesStore.seed([makeIssue({ dueDate: soon, status: 'done' })]);
+ expect((component as any).upcomingIssues().length).toBe(0);
+ });
+
+ it('exclut les issues sans date d\'échéance', () => {
+ issuesStore.seed([makeIssue({ dueDate: '', status: 'todo' })]);
+ expect((component as any).upcomingIssues().length).toBe(0);
+ });
+
+ it('exclut les issues au-delà de 14 jours', () => {
+ const farFuture = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString();
+ issuesStore.seed([makeIssue({ dueDate: farFuture, status: 'todo' })]);
+ expect((component as any).upcomingIssues().length).toBe(0);
+ });
+ });
+
+ describe('issuesByType', () => {
+ it('regroupe correctement par type', () => {
+ issuesStore.seed([
+ makeIssue({ id: 1, type: 'Bug' }),
+ makeIssue({ id: 2, type: 'Bug' }),
+ makeIssue({ id: 3, type: 'Story' }),
+ ]);
+ const byType = (component as any).issuesByType();
+ const bug = byType.find((t: any) => t.type === 'Bug');
+ const story = byType.find((t: any) => t.type === 'Story');
+ expect(bug?.count).toBe(2);
+ expect(story?.count).toBe(1);
+ });
+
+ it('exclut les types sans issues', () => {
+ issuesStore.seed([makeIssue({ type: 'Bug' })]);
+ const byType = (component as any).issuesByType();
+ expect(byType.every((t: any) => t.count > 0)).toBe(true);
+ });
+ });
+
+ describe('activeMilestones', () => {
+ it('exclut les milestones terminés à 100%', () => {
+ issuesStore.seed([makeIssue({ id: 1, status: 'done' })]);
+ milestonesStore.seed([{ id: 1, name: 'Done Milestone', description: '', dueDate: '', issueIds: [1] }]);
+ expect((component as any).activeMilestones().length).toBe(0);
+ });
+
+ it('inclut les milestones non terminés', () => {
+ issuesStore.seed([
+ makeIssue({ id: 1, status: 'done' }),
+ makeIssue({ id: 2, status: 'todo' }),
+ ]);
+ milestonesStore.seed([{ id: 1, name: 'Active', description: '', dueDate: '', issueIds: [1, 2] }]);
+ expect((component as any).activeMilestones().length).toBe(1);
+ });
+ });
+
+ describe('formatDate', () => {
+ it('retourne "—" pour une chaîne vide', () => {
+ expect((component as any).formatDate('')).toBe('—');
+ });
+
+ it('formate une date ISO en format français', () => {
+ const result = (component as any).formatDate('2025-06-15T00:00:00.000Z');
+ expect(result).toMatch(/15\/06\/2025/);
+ });
+ });
+
+ describe('navigation', () => {
+ it('navigue vers /issues/:id via openIssue', () => {
+ const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
+ (component as any).openIssue(42);
+ expect(spy).toHaveBeenCalledWith(['/issues', 42]);
+ });
+
+ it('navigue vers /milestones/:id via openMilestone', () => {
+ const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
+ (component as any).openMilestone(7);
+ expect(spy).toHaveBeenCalledWith(['/milestones', 7]);
+ });
+
+ it('navigue vers /issues via navigateToIssues', () => {
+ const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
+ (component as any).navigateToIssues();
+ expect(spy).toHaveBeenCalledWith(['/issues']);
+ });
+
+ it('navigue vers /milestones via navigateToMilestones', () => {
+ const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
+ (component as any).navigateToMilestones();
+ expect(spy).toHaveBeenCalledWith(['/milestones']);
+ });
+ });
+});
diff --git a/src/app/dashboard/dashboard.ts b/src/app/dashboard/dashboard.ts
new file mode 100644
index 0000000..56fdc5e
--- /dev/null
+++ b/src/app/dashboard/dashboard.ts
@@ -0,0 +1,166 @@
+import { Component, computed, inject } from '@angular/core';
+import { Router } from '@angular/router';
+import { IssueEntity, IssuesStore } from '../issues/issues.store';
+import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
+
+@Component({
+ selector: 'app-dashboard',
+ imports: [],
+ templateUrl: './dashboard.html',
+ styleUrl: './dashboard.css',
+})
+export class Dashboard {
+ private readonly router = inject(Router);
+ private readonly issuesStore = inject(IssuesStore);
+ private readonly milestonesStore = inject(MilestonesStore);
+
+ constructor() {
+ this.issuesStore.load();
+ this.milestonesStore.load();
+ }
+
+ protected readonly totalIssues = computed(() => this.issuesStore.issues().length);
+
+ protected readonly statusCounts = computed(() => ({
+ draft: this.issuesStore.issues().filter((i) => i.status === 'draft').length,
+ todo: this.issuesStore.issues().filter((i) => i.status === 'todo').length,
+ inProgress: this.issuesStore.issues().filter((i) => i.status === 'in-progress').length,
+ done: this.issuesStore.issues().filter((i) => i.status === 'done').length,
+ }));
+
+ protected readonly completionRate = computed(() => {
+ const total = this.totalIssues();
+ if (total === 0) return 0;
+ return Math.round((this.statusCounts().done / total) * 100);
+ });
+
+ protected readonly totalMilestones = computed(() => this.milestonesStore.milestones().length);
+
+ protected readonly statusItems = computed(() =>
+ (['todo', 'in-progress', 'draft', 'done'] as IssueEntity['status'][]).map((status) => ({
+ status,
+ count: this.issuesStore.issues().filter((i) => i.status === status).length,
+ })),
+ );
+
+ protected readonly issuesByType = computed(() => {
+ const types: IssueEntity['type'][] = ['Epic', 'Bug', 'Study', 'Story', 'Task', 'Technical Story'];
+ return types
+ .map((type) => ({
+ type,
+ count: this.issuesStore.issues().filter((i) => i.type === type).length,
+ icon: this.typeIcon(type),
+ }))
+ .filter((t) => t.count > 0);
+ });
+
+ protected readonly highPriorityIssues = computed(() =>
+ this.issuesStore
+ .issues()
+ .filter((i) => (i.priority === 'HAUTE' || i.priority === 'TRES_HAUTE') && i.status !== 'done')
+ .slice(0, 6),
+ );
+
+ protected readonly activeMilestones = computed(() =>
+ this.milestonesStore
+ .milestones()
+ .map((m) => ({ ...m, progress: this.getMilestoneProgress(m) }))
+ .filter((m) => m.progress < 100)
+ .slice(0, 5),
+ );
+
+ protected readonly upcomingIssues = computed(() => {
+ const now = new Date();
+ const twoWeeks = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
+ return this.issuesStore
+ .issues()
+ .filter((i) => {
+ if (!i.dueDate || i.status === 'done') return false;
+ const due = new Date(i.dueDate);
+ return due >= now && due <= twoWeeks;
+ })
+ .sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime())
+ .slice(0, 6);
+ });
+
+ protected readonly overdueCount = computed(() => {
+ const now = new Date();
+ return this.issuesStore.issues().filter((i) => {
+ if (!i.dueDate || i.status === 'done') return false;
+ return new Date(i.dueDate) < now;
+ }).length;
+ });
+
+ private getMilestoneProgress(milestone: MilestoneEntity): number {
+ if (milestone.issueIds.length === 0) return 0;
+ const linked = this.issuesStore.issues().filter((i) => milestone.issueIds.includes(i.id));
+ if (linked.length === 0) return 0;
+ return Math.round((linked.filter((i) => i.status === 'done').length / linked.length) * 100);
+ }
+
+ protected formatDate(iso: string): string {
+ if (!iso) return '—';
+ return new Date(iso).toLocaleDateString('fr-FR', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ });
+ }
+
+ protected typeIcon(type: IssueEntity['type']): { letter: string; bg: string } {
+ const map: Record = {
+ Epic: { letter: 'E', bg: '#7c3aed' },
+ Bug: { letter: 'B', bg: '#dc2626' },
+ Story: { letter: 'S', bg: '#16a34a' },
+ Task: { letter: 'T', bg: '#2563eb' },
+ Study: { letter: 'St', bg: '#6b7280' },
+ 'Technical Story': { letter: 'TS', bg: '#d97706' },
+ };
+ return map[type] ?? { letter: '?', bg: '#6b7280' };
+ }
+
+ protected priorityDisplay(priority: IssueEntity['priority']): {
+ symbol: string;
+ color: string;
+ label: string;
+ } {
+ const map: Record = {
+ TRES_HAUTE: { symbol: '↑↑', color: '#dc3545', label: 'Très haute' },
+ HAUTE: { symbol: '↑', color: '#fd7e14', label: 'Haute' },
+ MOYENNE: { symbol: '–', color: '#ffc107', label: 'Moyenne' },
+ BASSE: { symbol: '↓', color: '#0d6efd', label: 'Basse' },
+ TRES_FAIBLE: { symbol: '↓↓', color: '#6c757d', label: 'Très faible' },
+ };
+ return map[priority] ?? { symbol: '?', color: '#6c757d', label: priority };
+ }
+
+ protected statusBadge(status: IssueEntity['status']): {
+ label: string;
+ bg: string;
+ color: string;
+ } {
+ const map: Record = {
+ draft: { label: 'BROUILLON', bg: '#e2e8f0', color: '#475569' },
+ todo: { label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8' },
+ 'in-progress': { label: 'EN COURS', bg: '#ffedd5', color: '#9a3412' },
+ done: { label: 'TERMINÉ', bg: '#dcfce7', color: '#166534' },
+ };
+ return map[status] ?? { label: status, bg: '#e2e8f0', color: '#475569' };
+ }
+
+ protected openIssue(id: number): void {
+ this.router.navigate(['/issues', id]);
+ }
+
+ protected openMilestone(id: number): void {
+ this.router.navigate(['/milestones', id]);
+ }
+
+ protected navigateToIssues(): void {
+ this.router.navigate(['/issues']);
+ }
+
+ protected navigateToMilestones(): void {
+ this.router.navigate(['/milestones']);
+ }
+}
diff --git a/src/app/menu/menu.spec.ts b/src/app/menu/menu.spec.ts
index ec177ac..806c605 100644
--- a/src/app/menu/menu.spec.ts
+++ b/src/app/menu/menu.spec.ts
@@ -35,9 +35,9 @@ describe('Menu', () => {
expect(component).toBeTruthy();
});
- it('should have four menu items', () => {
+ it('should have five menu items', () => {
const items = (component as any).menuItems as { label: string; path: string }[];
- expect(items.length).toBe(4);
+ expect(items.length).toBe(5);
});
it('should contain Issues link', () => {
@@ -50,6 +50,11 @@ describe('Menu', () => {
expect(items.some((i) => i.path === '/milestones')).toBe(true);
});
+ it('should contain Dashboard link', () => {
+ const items = (component as any).menuItems as { label: string; path: string }[];
+ expect(items.some((i) => i.path === '/dashboard')).toBe(true);
+ });
+
it('logout calls keycloak.logout()', () => {
(component as any).logout();
expect(keycloakMock.logout).toHaveBeenCalled();
diff --git a/src/app/menu/menu.ts b/src/app/menu/menu.ts
index bf54844..d1593dd 100644
--- a/src/app/menu/menu.ts
+++ b/src/app/menu/menu.ts
@@ -15,6 +15,7 @@ export class Menu {
protected readonly menuItems = [
{ label: 'Accueil', path: '/home' },
+ { label: 'Tableau de bord', path: '/dashboard' },
{ label: 'Projet', path: '/project' },
{ label: 'Issues', path: '/issues' },
{ label: 'Milestones', path: '/milestones' },