Merge pull request 'Ajoute du tableau de bord' (#29) from feat/26-tableau-de-bord into develop

Reviewed-on: Bonsai/Bonsai-webapp#29
This commit is contained in:
2026-05-28 19:07:12 +02:00
9 changed files with 848 additions and 2 deletions
+31
View File
@@ -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]
+11
View File
@@ -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"
]
}
}
+2
View File
@@ -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] },
+124
View File
@@ -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;
}
+217
View File
@@ -0,0 +1,217 @@
<!-- suppress HtmlUnknownAttribute -->
<div class="d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-3 mb-4">
<div>
<h1 class="h2 mb-2">Tableau de bord</h1>
<p class="text-secondary mb-0">Vue d'ensemble du projet.</p>
</div>
</div>
<!-- KPI cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-lg-3">
<div class="card shadow-sm kpi-card" role="button" tabindex="0" (click)="navigateToIssues()" (keydown.enter)="navigateToIssues()">
<div class="card-body">
<div class="kpi-label">Issues totales</div>
<div class="kpi-value">{{ totalIssues() }}</div>
<div class="kpi-sub text-secondary">{{ completionRate() }}% terminées</div>
<div class="progress mt-2" style="height: 4px;">
<div class="progress-bar bg-success" role="progressbar" [style.width.%]="completionRate()"></div>
</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="card shadow-sm kpi-card">
<div class="card-body">
<div class="kpi-label">En cours</div>
<div class="kpi-value" style="color: #9a3412;">{{ statusCounts().inProgress }}</div>
<div class="kpi-sub text-secondary">{{ statusCounts().todo }} à faire</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="card shadow-sm kpi-card" [class.kpi-card--alert]="overdueCount() > 0">
<div class="card-body">
<div class="kpi-label">En retard</div>
<div class="kpi-value" [class.text-danger]="overdueCount() > 0" [class.text-secondary]="overdueCount() === 0">
{{ overdueCount() }}
</div>
<div class="kpi-sub text-secondary">issues dépassées</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="card shadow-sm kpi-card" role="button" tabindex="0" (click)="navigateToMilestones()" (keydown.enter)="navigateToMilestones()">
<div class="card-body">
<div class="kpi-label">Milestones</div>
<div class="kpi-value">{{ totalMilestones() }}</div>
<div class="kpi-sub text-secondary">{{ activeMilestones().length }} en cours</div>
</div>
</div>
</div>
</div>
<!-- Statuts & Types -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-white fw-semibold small">Répartition par statut</div>
<div class="card-body">
@for (item of statusItems(); track item.status) {
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<span
class="status-badge"
[style.background]="statusBadge(item.status).bg"
[style.color]="statusBadge(item.status).color"
>{{ statusBadge(item.status).label }}</span>
<span class="text-secondary small">{{ item.count }}</span>
</div>
<div class="progress" style="height: 5px;">
<div
class="progress-bar"
role="progressbar"
[style.width.%]="totalIssues() > 0 ? (item.count / totalIssues() * 100) : 0"
[style.background]="statusBadge(item.status).color"
></div>
</div>
</div>
}
@if (totalIssues() === 0) {
<p class="text-secondary text-center py-2 mb-0">Aucune issue.</p>
}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-white fw-semibold small">Répartition par type</div>
<div class="card-body">
@for (item of issuesByType(); track item.type) {
<div class="d-flex align-items-center gap-2 mb-2">
<span class="type-icon" [style.background]="item.icon.bg">{{ item.icon.letter }}</span>
<span class="flex-grow-1 small">{{ item.type }}</span>
<span class="badge bg-secondary rounded-pill">{{ item.count }}</span>
</div>
}
@if (issuesByType().length === 0) {
<p class="text-secondary text-center py-2 mb-0">Aucune issue.</p>
}
</div>
</div>
</div>
</div>
<!-- Haute priorité & Milestones en cours -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<span class="fw-semibold small">Haute priorité — ouvertes</span>
@if (highPriorityIssues().length > 0) {
<span class="badge bg-danger rounded-pill">{{ highPriorityIssues().length }}</span>
}
</div>
<div class="card-body p-0">
@for (issue of highPriorityIssues(); track issue.id) {
<div
class="issue-row"
tabindex="0"
(click)="openIssue(issue.id)"
(keydown.enter)="openIssue(issue.id)"
>
<span class="type-icon" [style.background]="typeIcon(issue.type).bg">{{ typeIcon(issue.type).letter }}</span>
<span class="flex-grow-1 small text-truncate">{{ issue.name }}</span>
<span
class="priority-symbol"
[style.color]="priorityDisplay(issue.priority).color"
[title]="priorityDisplay(issue.priority).label"
>{{ priorityDisplay(issue.priority).symbol }}</span>
<span
class="status-badge-sm"
[style.background]="statusBadge(issue.status).bg"
[style.color]="statusBadge(issue.status).color"
>{{ statusBadge(issue.status).label }}</span>
</div>
}
@if (highPriorityIssues().length === 0) {
<p class="text-secondary text-center py-4 mb-0">Aucune issue haute priorité ouverte.</p>
}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-white fw-semibold small">Milestones en cours</div>
<div class="card-body p-0">
@for (m of activeMilestones(); track m.id) {
<div
class="milestone-row"
tabindex="0"
(click)="openMilestone(m.id)"
(keydown.enter)="openMilestone(m.id)"
>
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="small fw-semibold text-truncate flex-grow-1 me-2">{{ m.name }}</span>
<span class="small text-secondary text-nowrap">{{ m.progress }}%</span>
</div>
<div class="d-flex align-items-center gap-2">
<div class="progress flex-grow-1" style="height: 5px;">
<div
class="progress-bar"
role="progressbar"
[style.width.%]="m.progress"
[attr.aria-valuenow]="m.progress"
aria-valuemin="0"
aria-valuemax="100"
></div>
</div>
<span class="small text-secondary text-nowrap">{{ formatDate(m.dueDate) }}</span>
</div>
</div>
}
@if (activeMilestones().length === 0) {
<p class="text-secondary text-center py-4 mb-0">Tous les milestones sont terminés.</p>
}
</div>
</div>
</div>
</div>
<!-- Échéances proches -->
@if (upcomingIssues().length > 0) {
<div class="row g-3">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<span class="fw-semibold small">Échéances dans les 14 prochains jours</span>
<span class="badge bg-warning text-dark rounded-pill">{{ upcomingIssues().length }}</span>
</div>
<div class="card-body p-0">
@for (issue of upcomingIssues(); track issue.id) {
<div
class="issue-row"
tabindex="0"
(click)="openIssue(issue.id)"
(keydown.enter)="openIssue(issue.id)"
>
<span class="type-icon" [style.background]="typeIcon(issue.type).bg">{{ typeIcon(issue.type).letter }}</span>
<span class="flex-grow-1 small text-truncate">{{ issue.name }}</span>
<span class="small text-secondary text-nowrap">{{ formatDate(issue.dueDate) }}</span>
<span
class="status-badge-sm"
[style.background]="statusBadge(issue.status).bg"
[style.color]="statusBadge(issue.status).color"
>{{ statusBadge(issue.status).label }}</span>
</div>
}
</div>
</div>
</div>
</div>
}
+289
View File
@@ -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> = {}): 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<IssueEntity[]>([]);
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<MilestoneEntity[]>([]);
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<Dashboard>;
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']);
});
});
});
+166
View File
@@ -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<IssueEntity['type'], { letter: string; bg: string }> = {
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<IssueEntity['priority'], { symbol: string; color: string; label: string }> = {
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<IssueEntity['status'], { label: string; bg: string; color: string }> = {
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']);
}
}
+7 -2
View File
@@ -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();
+1
View File
@@ -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' },