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:
@@ -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]
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
|
import { Dashboard } from './dashboard/dashboard';
|
||||||
import { Home } from './home/home';
|
import { Home } from './home/home';
|
||||||
import { IssueDetail } from './issues/issue-detail/issue-detail';
|
import { IssueDetail } from './issues/issue-detail/issue-detail';
|
||||||
import { Issues } from './issues/issues';
|
import { Issues } from './issues/issues';
|
||||||
@@ -10,6 +11,7 @@ import { authGuard } from './auth/auth.guard';
|
|||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', pathMatch: 'full', redirectTo: 'home' },
|
{ path: '', pathMatch: 'full', redirectTo: 'home' },
|
||||||
{ path: 'home', component: Home },
|
{ path: 'home', component: Home },
|
||||||
|
{ path: 'dashboard', component: Dashboard, canActivate: [authGuard] },
|
||||||
{ path: 'project', component: Projects, canActivate: [authGuard] },
|
{ path: 'project', component: Projects, canActivate: [authGuard] },
|
||||||
{ path: 'projects', redirectTo: 'project' },
|
{ path: 'projects', redirectTo: 'project' },
|
||||||
{ path: 'issues/new', component: IssueDetail, canActivate: [authGuard] },
|
{ path: 'issues/new', component: IssueDetail, canActivate: [authGuard] },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,9 +35,9 @@ describe('Menu', () => {
|
|||||||
expect(component).toBeTruthy();
|
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 }[];
|
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', () => {
|
it('should contain Issues link', () => {
|
||||||
@@ -50,6 +50,11 @@ describe('Menu', () => {
|
|||||||
expect(items.some((i) => i.path === '/milestones')).toBe(true);
|
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()', () => {
|
it('logout calls keycloak.logout()', () => {
|
||||||
(component as any).logout();
|
(component as any).logout();
|
||||||
expect(keycloakMock.logout).toHaveBeenCalled();
|
expect(keycloakMock.logout).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export class Menu {
|
|||||||
|
|
||||||
protected readonly menuItems = [
|
protected readonly menuItems = [
|
||||||
{ label: 'Accueil', path: '/home' },
|
{ label: 'Accueil', path: '/home' },
|
||||||
|
{ label: 'Tableau de bord', path: '/dashboard' },
|
||||||
{ label: 'Projet', path: '/project' },
|
{ label: 'Projet', path: '/project' },
|
||||||
{ label: 'Issues', path: '/issues' },
|
{ label: 'Issues', path: '/issues' },
|
||||||
{ label: 'Milestones', path: '/milestones' },
|
{ label: 'Milestones', path: '/milestones' },
|
||||||
|
|||||||
Reference in New Issue
Block a user