diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 72e20f5..e619268 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -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, + ), + }, ]; diff --git a/apps/frontend/src/app/core/services/sites.service.spec.ts b/apps/frontend/src/app/core/services/sites.service.spec.ts new file mode 100644 index 0000000..45aee2c --- /dev/null +++ b/apps/frontend/src/app/core/services/sites.service.spec.ts @@ -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'); + }); +}); diff --git a/apps/frontend/src/app/core/services/sites.service.ts b/apps/frontend/src/app/core/services/sites.service.ts new file mode 100644 index 0000000..85754cc --- /dev/null +++ b/apps/frontend/src/app/core/services/sites.service.ts @@ -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(`${environment.apiUrl}/sites`); + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 6519fc9..08b015e 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -1,15 +1,24 @@
-
- Déconnexion +
+ Voir les sites + Déconnexion +
@if (error(); as message) { diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 715ce8c..06adf01 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -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; diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 4254945..1f285b3 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -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([]), ], }); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index b2137a1..e9f9792 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -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 = { @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', }) diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.html b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.html new file mode 100644 index 0000000..9533177 --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.html @@ -0,0 +1,19 @@ +
+ + +
+ + +

Site {{ siteId() }}

+
+ + +

Le détail de ce site est à venir (voir issue #51).

+ Retour aux sites +
+
diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.scss b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.scss new file mode 100644 index 0000000..ca04d4a --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.scss @@ -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; +} diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.spec.ts b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.spec.ts new file mode 100644 index 0000000..f229e36 --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.spec.ts @@ -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'); + }); +}); diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.ts b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.ts new file mode 100644 index 0000000..39e53dc --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.ts @@ -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')))); +} diff --git a/apps/frontend/src/app/features/sites/site-list/site-list.html b/apps/frontend/src/app/features/sites/site-list/site-list.html new file mode 100644 index 0000000..9998066 --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-list/site-list.html @@ -0,0 +1,46 @@ +
+ + +
+ + +
+

Sites

+

Vue d'ensemble du parc suivi

+
+
+ + @if (error(); as message) { + + } + + + + + + + + + + + + + + + @for (site of sites(); track site.site_id) { + + + + + + + + + } + +
NomTypeLocalisationCapacité (kW)Statut
{{ site.site_name }}{{ site.site_type }}{{ site.location || '-' }}{{ site.capacity_kw ?? '-' }}{{ site.status ?? '-' }}Détail
+
+
diff --git a/apps/frontend/src/app/features/sites/site-list/site-list.scss b/apps/frontend/src/app/features/sites/site-list/site-list.scss new file mode 100644 index 0000000..9fa25a2 --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-list/site-list.scss @@ -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; + } +} diff --git a/apps/frontend/src/app/features/sites/site-list/site-list.spec.ts b/apps/frontend/src/app/features/sites/site-list/site-list.spec.ts new file mode 100644 index 0000000..56d8d6c --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-list/site-list.spec.ts @@ -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('-'); + }); +}); diff --git a/apps/frontend/src/app/features/sites/site-list/site-list.ts b/apps/frontend/src/app/features/sites/site-list/site-list.ts new file mode 100644 index 0000000..30e9e4e --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-list/site-list.ts @@ -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 = { + 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([]); + error = signal(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 { + this.error.set(UNAVAILABLE_MESSAGE); + return EMPTY; + } +} diff --git a/apps/frontend/src/app/shared/models/site.model.ts b/apps/frontend/src/app/shared/models/site.model.ts new file mode 100644 index 0000000..fcf18f0 --- /dev/null +++ b/apps/frontend/src/app/shared/models/site.model.ts @@ -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; +} diff --git a/apps/frontend/src/styles.scss b/apps/frontend/src/styles.scss index 3e162eb..5599780 100644 --- a/apps/frontend/src/styles.scss +++ b/apps/frontend/src/styles.scss @@ -1,6 +1,7 @@ @use 'styles/tokens'; @use 'styles/forms'; @use 'styles/auth-page'; +@use 'styles/links'; body { margin: 0; diff --git a/apps/frontend/src/styles/_links.scss b/apps/frontend/src/styles/_links.scss new file mode 100644 index 0000000..7569ff0 --- /dev/null +++ b/apps/frontend/src/styles/_links.scss @@ -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; +} diff --git a/docs/architecture/32-design-systeme-frontend.md b/docs/architecture/32-design-systeme-frontend.md index 3d1c442..a7894d8 100644 --- a/docs/architecture/32-design-systeme-frontend.md +++ b/docs/architecture/32-design-systeme-frontend.md @@ -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 (``) est enveloppé dans +`` pour ramener au tableau de bord en un clic, +et un `