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
+
+
+
+ +
+
+
+
En retard
+
+ {{ overdueCount() }} +
+
issues dépassées
+
+
+
+ +
+
+
+
Milestones
+
{{ totalMilestones() }}
+
{{ activeMilestones().length }} en cours
+
+
+
+
+ + +
+
+
+
Répartition par statut
+
+ @for (item of statusItems(); track item.status) { +
+
+ {{ statusBadge(item.status).label }} + {{ item.count }} +
+
+
+
+
+ } + @if (totalIssues() === 0) { +

Aucune issue.

+ } +
+
+
+ +
+
+
Répartition par type
+
+ @for (item of issuesByType(); track item.type) { +
+ {{ item.icon.letter }} + {{ item.type }} + {{ item.count }} +
+ } + @if (issuesByType().length === 0) { +

Aucune issue.

+ } +
+
+
+
+ + +
+
+
+
+ Haute priorité — ouvertes + @if (highPriorityIssues().length > 0) { + {{ highPriorityIssues().length }} + } +
+
+ @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.

+ } +
+
+
+ +
+
+
Milestones en cours
+
+ @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) { +
+
+
+
+ Échéances dans les 14 prochains jours + {{ upcomingIssues().length }} +
+
+ @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' },