Compare commits

..
Author SHA1 Message Date
Johan LEROY 8fb5ab9f65 fix(frontend): traite la revue de phyri0s sur la PR #95
Frontend / build (push) Successful in 9m42s
Frontend / test (push) Failing after 5m23s
Frontend / SonarQube (push) Skipped
- Reutilise .ev-link pour le lien "Detail" de la liste des sites au
  lieu de dupliquer ses regles de style.
- site.location vide est traite comme absent (affiche "-"), pas
  seulement null/undefined.
- siteId de la page detail suit desormais route.paramMap de facon
  reactive plutot qu'une lecture ponctuelle du snapshot, pour rester
  a jour quand Angular reutilise l'instance du composant en changeant
  de site.
- Ajoute provideRouter([]) manquant dans un test dashboard existant,
  necessaire depuis l'ajout du lien "Voir les sites" au rebase sur dev.
2026-09-17 16:01:15 +02:00
Johan LEROY e22feac2c4 fix(frontend): navigation cohérente entre les pages authentifiées
Logo cliquable vers le tableau de bord (ev-brand-link) et fil d'Ariane
(ev-breadcrumb) sur les sous-pages, pour éviter les impasses de
navigation entre dashboard, liste des sites et détail de site.
2026-09-17 15:58:50 +02:00
Johan LEROY 5581cb1ef3 feat(frontend): vue liste des sites
Nouveau SitesService (GET /sites) et page SiteList consommant le design
système (ev-card, ev-badge, ev-alert, ev-brand). Ajoute la route /sites,
un lien depuis le dashboard, et une route détail /sites/:siteId pointant
vers un placeholder minimal en attendant l'issue #51.

Closes #49
2026-09-17 15:58:35 +02:00
Johan LEROYandGitHub 1d8c986386 Merge pull request #93 from ineszang/feat/design-system
feat(frontend): design système - tokens, composants ui et restylage des pages
2026-09-17 15:56:24 +02:00
19 changed files with 508 additions and 14 deletions
+13
View File
@@ -12,4 +12,17 @@ export const routes: Routes = [
canActivate: [authGuard],
loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard),
},
{
path: 'sites',
canActivate: [authGuard],
loadComponent: () => import('./features/sites/site-list/site-list').then(m => m.SiteList),
},
{
path: 'sites/:siteId',
canActivate: [authGuard],
loadComponent: () =>
import('./features/sites/site-detail-placeholder/site-detail-placeholder').then(
(m) => m.SiteDetailPlaceholder,
),
},
];
@@ -0,0 +1,41 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { SitesService } from './sites.service';
import { environment } from '../../../environments/environment';
describe('SitesService', () => {
let service: SitesService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(SitesService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('appelle le bon endpoint et retourne la liste des sites', () => {
let result: unknown;
service.getSites().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/sites`);
expect(req.request.method).toBe('GET');
req.flush([
{
site_id: 'SITE001',
site_name: 'Site 1',
site_type: 'industriel',
location: 'Nantes',
capacity_kw: 500,
status: 'actif',
},
]);
expect((result as { site_id: string }[])[0].site_id).toBe('SITE001');
});
});
@@ -0,0 +1,13 @@
import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { Site } from '../../shared/models/site.model';
@Service()
export class SitesService {
private http = inject(HttpClient);
getSites() {
return this.http.get<Site[]>(`${environment.apiUrl}/sites`);
}
}
@@ -1,15 +1,24 @@
<div class="dashboard">
<header class="dashboard__header">
<div class="dashboard__brand">
<ev-brand class="dashboard__logo" />
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="dashboard__logo" />
</a>
<div>
<h1>Vue d'ensemble</h1>
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
</div>
</div>
<ev-button class="logout-button" variant="secondary" [fullWidth]="false" (click)="onLogout()"
>Déconnexion</ev-button
>
<div class="dashboard__actions">
<a routerLink="/sites" class="ev-link">Voir les sites</a>
<ev-button
class="logout-button"
variant="secondary"
[fullWidth]="false"
(click)="onLogout()"
>Déconnexion</ev-button
>
</div>
</header>
@if (error(); as message) {
@@ -34,6 +34,12 @@
color: var(--color-text-muted);
}
.dashboard__actions {
display: flex;
align-items: center;
gap: 1rem;
}
h2 {
font-size: 1.1rem;
font-weight: 600;
@@ -5,7 +5,7 @@ import { Dashboard } from './dashboard';
import { StatsService } from '../../core/services/stats.service';
import { AlertsService } from '../../core/services/alerts.service';
import {AuthService} from '../../core/services/auth.service';
import {Router} from '@angular/router';
import {Router, provideRouter} from '@angular/router';
vi.mock('chart.js', () => {
class ChartMock {
@@ -29,6 +29,7 @@ describe('Dashboard', () => {
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
provideRouter([]),
],
});
@@ -60,6 +61,7 @@ describe('Dashboard', () => {
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
provideRouter([]),
],
});
@@ -86,6 +88,7 @@ describe('Dashboard', () => {
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
provideRouter([]),
],
});
@@ -99,7 +102,6 @@ describe('Dashboard', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() };
const routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({
imports: [Dashboard],
@@ -107,18 +109,21 @@ describe('Dashboard', () => {
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const button = fixture.nativeElement.querySelector('.logout-button');
button.click();
expect(authMock.logout).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
});
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
@@ -127,26 +132,27 @@ describe('Dashboard', () => {
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
clearSession: vi.fn(),
};
const routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const button = fixture.nativeElement.querySelector('.logout-button');
button.click();
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
});
it('distingue le ton des sévérités high et critical', () => {
@@ -158,6 +164,7 @@ describe('Dashboard', () => {
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
provideRouter([]),
],
});
@@ -2,7 +2,7 @@ import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs';
import { DecimalPipe } from '@angular/common';
import { Router } from '@angular/router';
import { Router, RouterLink } from '@angular/router';
import { StatsService } from '../../core/services/stats.service';
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
@@ -30,7 +30,17 @@ const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart, Card, EvAlert, Badge, Brand, Button],
imports: [
DecimalPipe,
RouterLink,
ConsumptionGauge,
SiteLoadChart,
Card,
EvAlert,
Badge,
Brand,
Button,
],
templateUrl: './dashboard.html',
styleUrl: './dashboard.scss',
})
@@ -0,0 +1,19 @@
<div class="site-detail-placeholder">
<nav class="ev-breadcrumb">
<a routerLink="/dashboard">Tableau de bord</a>
<span>/</span>
<a routerLink="/sites">Sites</a>
</nav>
<header class="site-detail-placeholder__header">
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="site-detail-placeholder__logo" />
</a>
<h1>Site {{ siteId() }}</h1>
</header>
<ev-card>
<p>Le détail de ce site est à venir (voir issue #51).</p>
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
</ev-card>
</div>
@@ -0,0 +1,28 @@
:host {
display: block;
color: var(--color-text);
padding: 2.5rem 2rem;
max-width: 640px;
margin: 0 auto;
}
.site-detail-placeholder__header {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 1.5rem;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
}
.site-detail-placeholder__logo {
font-size: 1.3rem;
}
ev-card p {
margin: 0 0 0.75rem;
}
@@ -0,0 +1,42 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { SiteDetailPlaceholder } from './site-detail-placeholder';
describe('SiteDetailPlaceholder', () => {
it("affiche l'identifiant du site depuis la route", () => {
const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' }));
TestBed.configureTestingModule({
imports: [SiteDetailPlaceholder],
providers: [
provideRouter([]),
{ provide: ActivatedRoute, useValue: { paramMap } },
],
});
const fixture = TestBed.createComponent(SiteDetailPlaceholder);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('SITE001');
});
it('met à jour l\'affichage quand le paramètre change sans recréer le composant', () => {
const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' }));
TestBed.configureTestingModule({
imports: [SiteDetailPlaceholder],
providers: [
provideRouter([]),
{ provide: ActivatedRoute, useValue: { paramMap } },
],
});
const fixture = TestBed.createComponent(SiteDetailPlaceholder);
fixture.detectChanges();
paramMap.next(convertToParamMap({ siteId: 'SITE002' }));
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('SITE002');
expect(fixture.nativeElement.textContent).not.toContain('SITE001');
});
});
@@ -0,0 +1,19 @@
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { map } from 'rxjs';
import { Card } from '../../../shared/components/ui/card/card';
import { Brand } from '../../../shared/components/ui/brand/brand';
@Component({
selector: 'app-site-detail-placeholder',
standalone: true,
imports: [RouterLink, Card, Brand],
templateUrl: './site-detail-placeholder.html',
styleUrl: './site-detail-placeholder.scss',
})
export class SiteDetailPlaceholder {
private route = inject(ActivatedRoute);
siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId'))));
}
@@ -0,0 +1,46 @@
<div class="site-list">
<nav class="ev-breadcrumb">
<a routerLink="/dashboard">Tableau de bord</a>
</nav>
<header class="site-list__header">
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="site-list__logo" />
</a>
<div>
<h1>Sites</h1>
<p class="site-list__subtitle">Vue d'ensemble du parc suivi</p>
</div>
</header>
@if (error(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
<ev-card class="table-card">
<table class="sites-table">
<thead>
<tr>
<th>Nom</th>
<th>Type</th>
<th>Localisation</th>
<th>Capacité (kW)</th>
<th>Statut</th>
<th></th>
</tr>
</thead>
<tbody>
@for (site of sites(); track site.site_id) {
<tr>
<td>{{ site.site_name }}</td>
<td>{{ site.site_type }}</td>
<td>{{ site.location || '-' }}</td>
<td>{{ site.capacity_kw ?? '-' }}</td>
<td><ev-badge [tone]="badgeToneForStatus(site.status)">{{ site.status ?? '-' }}</ev-badge></td>
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
</tr>
}
</tbody>
</table>
</ev-card>
</div>
@@ -0,0 +1,63 @@
:host {
display: block;
color: var(--color-text);
padding: 2.5rem 2rem;
max-width: 1100px;
margin: 0 auto;
}
.site-list__header {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 2rem;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
}
.site-list__logo {
font-size: 1.3rem;
}
.site-list__subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
}
.banner-error {
display: block;
margin: 0 0 1.5rem;
}
.table-card {
padding: 0;
overflow: hidden;
}
.sites-table {
width: 100%;
border-collapse: collapse;
th,
td {
padding: 0.85rem 1.25rem;
text-align: left;
border-bottom: 1px solid var(--color-border-light);
}
th {
font-size: 0.8rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.02em;
font-weight: 600;
}
tr:last-child td {
border-bottom: none;
}
}
@@ -0,0 +1,81 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { vi } from 'vitest';
import { of, throwError } from 'rxjs';
import { SiteList } from './site-list';
import { SitesService } from '../../../core/services/sites.service';
describe('SiteList', () => {
it('charge et affiche les sites au démarrage', () => {
const sitesMock = {
getSites: vi.fn().mockReturnValue(
of([
{
site_id: 'SITE001',
site_name: 'Site 1',
site_type: 'industriel',
location: 'Nantes',
capacity_kw: 500,
status: 'actif',
},
]),
),
};
TestBed.configureTestingModule({
imports: [SiteList],
providers: [{ provide: SitesService, useValue: sitesMock }, provideRouter([])],
});
const fixture = TestBed.createComponent(SiteList);
fixture.detectChanges();
expect(sitesMock.getSites).toHaveBeenCalled();
expect(fixture.componentInstance.sites().length).toBe(1);
expect(fixture.componentInstance.error()).toBeNull();
});
it("signale l'indisponibilité quand le chargement échoue", () => {
const sitesMock = { getSites: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) };
TestBed.configureTestingModule({
imports: [SiteList],
providers: [{ provide: SitesService, useValue: sitesMock }, provideRouter([])],
});
const fixture = TestBed.createComponent(SiteList);
fixture.detectChanges();
expect(fixture.componentInstance.error()).not.toBeNull();
expect(fixture.componentInstance.sites().length).toBe(0);
});
it('affiche un tiret pour les champs nullables', () => {
const sitesMock = {
getSites: vi.fn().mockReturnValue(
of([
{
site_id: 'SITE002',
site_name: 'Site 2',
site_type: 'bureau',
location: null,
capacity_kw: null,
status: null,
},
]),
),
};
TestBed.configureTestingModule({
imports: [SiteList],
providers: [{ provide: SitesService, useValue: sitesMock }, provideRouter([])],
});
const fixture = TestBed.createComponent(SiteList);
fixture.detectChanges();
const cells = fixture.nativeElement.querySelectorAll('td');
expect(cells[2].textContent.trim()).toBe('-');
expect(cells[3].textContent.trim()).toBe('-');
});
});
@@ -0,0 +1,47 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { catchError, EMPTY, Observable } from 'rxjs';
import { SitesService } from '../../../core/services/sites.service';
import { Site } from '../../../shared/models/site.model';
import { Card } from '../../../shared/components/ui/card/card';
import { Alert } from '../../../shared/components/ui/alert/alert';
import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge';
import { Brand } from '../../../shared/components/ui/brand/brand';
const UNAVAILABLE_MESSAGE = 'Liste des sites indisponible, réessayez plus tard.';
const TON_PAR_STATUT: Record<string, BadgeTone> = {
actif: 'success',
maintenance: 'warning',
hors_service: 'danger',
};
@Component({
selector: 'app-site-list',
standalone: true,
imports: [RouterLink, Card, Alert, Badge, Brand],
templateUrl: './site-list.html',
styleUrl: './site-list.scss',
})
export class SiteList implements OnInit {
private sitesService = inject(SitesService);
sites = signal<Site[]>([]);
error = signal<string | null>(null);
ngOnInit(): void {
this.sitesService
.getSites()
.pipe(catchError(() => this.reportUnavailable()))
.subscribe((sites) => this.sites.set(sites));
}
badgeToneForStatus(status: string | null): BadgeTone {
return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral';
}
private reportUnavailable(): Observable<never> {
this.error.set(UNAVAILABLE_MESSAGE);
return EMPTY;
}
}
@@ -0,0 +1,8 @@
export interface Site {
site_id: string;
site_name: string;
site_type: string;
location: string | null;
capacity_kw: number | null;
status: string | null;
}
+1
View File
@@ -1,6 +1,7 @@
@use 'styles/tokens';
@use 'styles/forms';
@use 'styles/auth-page';
@use 'styles/links';
body {
margin: 0;
+34
View File
@@ -0,0 +1,34 @@
.ev-link {
color: var(--color-primary);
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.ev-breadcrumb {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--color-text-muted);
margin-bottom: 1.25rem;
a {
color: var(--color-text-muted);
text-decoration: none;
&:hover {
color: var(--color-primary);
text-decoration: underline;
}
}
}
.ev-brand-link {
display: inline-flex;
color: inherit;
text-decoration: none;
}
@@ -36,6 +36,13 @@ le fond dégradé et le centrage commun aux pages d'authentification (`login`, `
et à terme `forgot-password`/`reset-password`) : elle enveloppe la carte, pas de duplication du
fond par page.
Les classes de navigation partagées (`.ev-link`, `.ev-breadcrumb`, `.ev-brand-link`) sont dans
`apps/frontend/src/styles/_links.scss`, importées globalement. Convention pour toute page de la
zone authentifiée (derrière `authGuard`) : le logo (`<ev-brand>`) est enveloppé dans
`<a routerLink="/dashboard" class="ev-brand-link">` pour ramener au tableau de bord en un clic,
et un `<nav class="ev-breadcrumb">` liste le chemin de retour vers les pages parentes quand la
page n'est pas à la racine (voir `site-list`/`site-detail-placeholder` pour l'exemple).
## Composants partagés
Dans `apps/frontend/src/app/shared/components/ui/`, chacun standalone, à importer directement