diff --git a/apps/frontend/TESTING.md b/apps/frontend/TESTING.md index d4e92bf..e87dbaf 100644 --- a/apps/frontend/TESTING.md +++ b/apps/frontend/TESTING.md @@ -1,4 +1,4 @@ -# Conventions de tests unitaires — Frontend +# Conventions de tests unitaires : Frontend ## Outil Vitest (intégré nativement à Angular CLI, pas d'installation à faire). @@ -83,3 +83,6 @@ describe('MonComposant', () => { ## Lancer les tests - Développement (mode watch) : `npm test` - Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html` +- Un fichier ou un dossier seulement : + `npx ng test --watch=false --coverage=false --include=src/app/core/services/alerts.service.spec.ts` + (répéter `--include` pour plusieurs cibles ; un dossier joue tous ses specs) diff --git a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts index 4483313..58eaae6 100644 --- a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts @@ -8,8 +8,10 @@ import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture'; describe('mockApiInterceptor', () => { let http: HttpClient; let httpMock: HttpTestingController; + let useMockFixturesInitial: boolean; beforeEach(() => { + useMockFixturesInitial = environment.useMockFixtures; TestBed.configureTestingModule({ providers: [ provideHttpClient(withInterceptors([mockApiInterceptor])), @@ -21,7 +23,7 @@ describe('mockApiInterceptor', () => { }); afterEach(() => { - environment.useMockFixtures = true; + environment.useMockFixtures = useMockFixturesInitial; httpMock.verify(); }); diff --git a/apps/frontend/src/app/core/mocks/alerts.fixture.ts b/apps/frontend/src/app/core/mocks/alerts.fixture.ts index c1f7a9a..84c8c66 100644 --- a/apps/frontend/src/app/core/mocks/alerts.fixture.ts +++ b/apps/frontend/src/app/core/mocks/alerts.fixture.ts @@ -2,53 +2,63 @@ import { Alert } from '../../shared/models/alert.model'; export const ALERTS_FIXTURE: Alert[] = [ { - alert_id: 'ALR-SITE002-1718458320', - timestamp: '2026-09-15T11:12:00', + alert_id: 5, site_id: 'SITE002', + timestamp: '2026-09-15T11:12:00Z', + type: 'threshold', severity: 'critical', - type: 'outage', - message: 'Risque de surcharge sur Usine Lyon Vénissieux', + message: 'Puissance appelée 812.5 kW au-dessus de la capacité du site (720.0 kW)', value: 812.5, threshold: 720.0, + metric: 'consumption_kw', + prediction_id: null, }, { - alert_id: 'ALR-SITE003-1718458321', - timestamp: '2026-09-15T11:05:00', + alert_id: 4, site_id: 'SITE003', + timestamp: '2026-09-15T11:05:00Z', + type: 'outage', severity: 'critical', - type: 'sensor', - message: 'Perte réseau totale sur Data Center Marseille', - value: 0, - threshold: 0, + message: 'Aucune lecture depuis 5:00:00 (dernière lecture : 2026-09-15T06:05:00+00:00)', + value: null, + threshold: null, + metric: null, + prediction_id: null, }, { - alert_id: 'ALR-SITE005-1718458322', - timestamp: '2026-09-15T10:47:00', + alert_id: 3, site_id: 'SITE005', + timestamp: '2026-09-15T10:47:00Z', + type: 'spike', severity: 'high', - type: 'threshold', - message: 'Usine Toulouse approche de son seuil de capacité', + message: 'Variation brutale entre deux lectures consécutives (260.0 kW -> 410.0 kW)', value: 410.0, - threshold: 480.0, + threshold: 260.0, + metric: 'consumption_kw', + prediction_id: null, }, { - alert_id: 'ALR-SITE006-1718458323', - timestamp: '2026-09-15T10:30:00', + alert_id: 2, site_id: 'SITE006', - severity: 'medium', + timestamp: '2026-09-15T10:30:00Z', type: 'sensor', - message: 'Capteur de température défaillant sur Bureau Lille', - value: 0, - threshold: 0, + severity: 'medium', + message: 'Qualité de mesure degraded (capteur hors ligne, valeur nulle)', + value: null, + threshold: null, + metric: null, + prediction_id: null, }, { - alert_id: 'ALR-SITE004-1718458324', - timestamp: '2026-09-15T09:58:00', + alert_id: 1, site_id: 'SITE004', - severity: 'low', + timestamp: '2026-09-15T09:58:00Z', type: 'anomaly', - message: 'Comportement de consommation inhabituel sur Bureau Bordeaux', + severity: 'low', + message: 'Écart de 13% entre la consommation mesurée (62.0 kWh) et la prévision (55.0 kWh)', value: 62.0, threshold: 55.0, + metric: 'consumption_kwh', + prediction_id: 42, }, ]; diff --git a/apps/frontend/src/app/core/services/alerts.service.spec.ts b/apps/frontend/src/app/core/services/alerts.service.spec.ts index 68b5740..c17bc4b 100644 --- a/apps/frontend/src/app/core/services/alerts.service.spec.ts +++ b/apps/frontend/src/app/core/services/alerts.service.spec.ts @@ -3,6 +3,20 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; import { AlertsService } from './alerts.service'; import { environment } from '../../../environments/environment'; +import { Alert } from '../../shared/models/alert.model'; + +const ALERT_API: Alert = { + alert_id: 1, + site_id: 'site-1', + timestamp: '2026-09-16T00:00:00Z', + type: 'threshold', + severity: 'high', + message: 'Dépassement du seuil configuré', + value: 812.5, + threshold: 720.0, + metric: 'consumption_kw', + prediction_id: null, +}; describe('AlertsService', () => { let service: AlertsService; @@ -18,26 +32,35 @@ describe('AlertsService', () => { afterEach(() => httpMock.verify()); - it("appelle le bon endpoint et retourne un tableau d'alertes", () => { - let result: unknown; + it("appelle le bon endpoint sans paramètre et retourne un tableau d'alertes", () => { + let result: Alert[] = []; service.getAlerts().subscribe((r) => (result = r)); - const req = httpMock.expectOne(`${environment.apiUrl}/alerts`); - expect(req.request.method).toBe('GET'); + const req = httpMock.expectOne( + (r) => r.url === `${environment.apiUrl}/alerts` && r.method === 'GET', + ); + expect(req.request.params.keys()).toEqual([]); + req.flush([ALERT_API]); - req.flush([ - { - alert_id: 'ALR-TEST-1', - timestamp: '2026-09-15T12:00:00', - site_id: 'SITE001', - severity: 'high', - type: 'threshold', - message: 'Test', - value: 100, - threshold: 90, - }, - ]); + expect(result.length).toBe(1); + expect(result[0].alert_id).toBe(1); + expect(result[0].prediction_id).toBeNull(); + }); - expect((result as unknown[]).length).toBe(1); + it('transmet les filtres site_id et severity en paramètres de requête', () => { + service.getAlerts({ site_id: 'SITE001', severity: 'high' }).subscribe(); + + const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`); + expect(req.request.params.get('site_id')).toBe('SITE001'); + expect(req.request.params.get('severity')).toBe('high'); + req.flush([]); + }); + + it('ne pose pas de paramètre pour un filtre omis', () => { + service.getAlerts({ site_id: 'SITE001' }).subscribe(); + + const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`); + expect(req.request.params.has('severity')).toBe(false); + req.flush([]); }); }); diff --git a/apps/frontend/src/app/core/services/alerts.service.ts b/apps/frontend/src/app/core/services/alerts.service.ts index ebd00e2..8d9a8bf 100644 --- a/apps/frontend/src/app/core/services/alerts.service.ts +++ b/apps/frontend/src/app/core/services/alerts.service.ts @@ -1,13 +1,25 @@ import { Service, inject } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { environment } from '../../../environments/environment'; -import { Alert } from '../../shared/models/alert.model'; +import { Alert, AlertSeverity } from '../../shared/models/alert.model'; + +export interface AlertFilters { + site_id?: string; + severity?: AlertSeverity; +} @Service() export class AlertsService { private http = inject(HttpClient); - getAlerts() { - return this.http.get(`${environment.apiUrl}/alerts`); + getAlerts(filters: AlertFilters = {}) { + let params = new HttpParams(); + if (filters.site_id) { + params = params.set('site_id', filters.site_id); + } + if (filters.severity) { + params = params.set('severity', filters.severity); + } + return this.http.get(`${environment.apiUrl}/alerts`, { params }); } } diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index f9a3fb2..78e7fca 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -27,87 +27,120 @@ @if (statsError(); as message) { } - @if (alertsError(); as message) { - - } @if (predictionsError(); as message) { } @if (stats(); as s) { -
- - Consommation vs capacité +

+ + Actualisé à {{ s.timestamp | date: 'HH:mm:ss' }} · {{ s.total_sites }} sites suivis +

+ +
+ + Consommation vs capacité - {{ s.total_consumption_kw | number: '1.0-1' }} / - {{ s.total_capacity_kw | number }} kW + {{ s.total_consumption_kw | number: '1.0-1' }} + / {{ s.total_capacity_kw | number }} kW + + + + + Charge moyenne du parc + {{ s.average_load_percent | number: '1.0-0' }} % - - - - Charge moyenne du parc - {{ s.average_load_percent }} % -
-
+
+
+ {{ loadHint(s.average_load_percent) }} - - Sites suivis - {{ s.total_sites }} + + Sites suivis + {{ s.total_sites }} + Voir la liste des sites
- -
-

Charge et alerte visuelle par site

- -
} - @if (alerts().length > 0) { -
-

Alertes actives

-
    - @for (alert of alerts(); track alert.alert_id) { -
  • - {{ alert.severity }} - {{ alert.message }} -
  • +
    +
    + @if (stats(); as s) { +
    +

    Charge par site

    + + + +
    + } + +
    +

    Prévisions de consommation

    + @if (predictions().length > 0) { + + + + + + + + + + + + @for (site of predictions(); track site.site_id) { + + + @if (site.prediction; as prediction) { + @if (prediction.status === 'available') { + + + } @else { + + + } + } @else { + + + } + + + } + +
    SitePrévisionÉchéance
    {{ site.site_name }} + {{ prediction.predicted_value | number: '1.0-1' }} kWh + {{ prediction.target_at | date: "dd/MM 'à' HH:mm" }} + {{ + prediction.status === 'insufficient_data' + ? 'Historique insuffisant' + : 'Erreur' + }} + -Pas encore de prévision-Détail
    +
    + } @else if (!predictionsError()) { +

    Aucune prévision disponible pour le moment.

    } -
-
- } +
+ - @if (predictions().length > 0) { -
-

Prévisions de consommation

-
    - @for (site of predictions(); track site.site_id) { -
  • - {{ site.site_name }} - @if (site.prediction; as prediction) { - @if (prediction.status === 'available') { - - {{ prediction.predicted_value | number: '1.0-1' }} kWh - {{ prediction.target_at | date: "dd/MM 'à' HH:mm" }} - - } @else { - {{ - prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur' - }} - } - } @else { - Pas encore de prévision - } -
  • - } -
-
- } + + diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 9c89f56..c3f60bb 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -8,9 +8,11 @@ .dashboard__header { display: flex; + flex-wrap: wrap; align-items: flex-start; justify-content: space-between; - margin-bottom: 2rem; + gap: var(--space-3); + margin-bottom: var(--space-3); } .dashboard__brand { @@ -20,8 +22,9 @@ h1 { margin: 0; - font-size: 1.75rem; + font-size: var(--font-size-xl); font-weight: 700; + letter-spacing: -0.01em; } } @@ -31,19 +34,69 @@ .dashboard__subtitle { margin: 0.25rem 0 0; + font-size: var(--font-size-sm); color: var(--color-text-muted); } .dashboard__actions { display: flex; + flex-wrap: wrap; align-items: center; - gap: 1rem; + gap: var(--space-2); + + .ev-link { + padding: 0.45rem 0.9rem; + border-radius: var(--radius-pill); + background: var(--color-primary-light); + color: var(--color-primary-hover); + font-size: var(--font-size-sm); + transition: background 0.15s ease; + + &:hover { + background: var(--color-primary); + color: var(--color-text-inverse); + text-decoration: none; + } + } +} + +.dashboard__status { + display: flex; + align-items: center; + gap: var(--space-2); + margin: 0 0 var(--space-4); + font-size: var(--font-size-sm); + color: var(--color-text-muted); +} + +.dashboard__pulse { + width: 0.6rem; + height: 0.6rem; + border-radius: 50%; + background: var(--color-success); + animation: pulse 2s ease-out infinite; +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.45); + } + + 100% { + box-shadow: 0 0 0 8px rgba(22, 163, 74, 0); + } +} + +@media (prefers-reduced-motion: reduce) { + .dashboard__pulse { + animation: none; + } } h2 { - font-size: 1.1rem; + margin: 0 0 var(--space-3); + font-size: var(--font-size-lg); font-weight: 600; - margin: 0 0 1rem; } .banner-error { @@ -54,116 +107,124 @@ h2 { .overview { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1rem; - margin-bottom: 2.5rem; + gap: var(--space-3); + margin-bottom: var(--space-5); } -.card { - padding: 1.25rem; - gap: 0.35rem; +.kpi { + position: relative; + overflow: hidden; + padding: var(--space-4); + gap: var(--space-1); + transition: + box-shadow 0.15s ease, + transform 0.15s ease; + + &::before { + content: ''; + position: absolute; + inset: 0 0 auto 0; + height: 3px; + background: var(--color-primary); + } + + &:hover { + box-shadow: var(--shadow-card-hover); + transform: translateY(-1px); + } } -.card--gauge { +.kpi--gauge { align-items: center; text-align: center; } -.card--link { - cursor: pointer; - transition: border-color 0.15s ease; +.kpi__label { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} - &:hover { - border-color: var(--color-primary); +.kpi__value { + font-size: var(--font-size-2xl); + font-weight: 700; + line-height: 1.1; + font-variant-numeric: tabular-nums; + + small { + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--color-text-muted); } } -.card__label { - font-size: 0.8rem; +.kpi__hint { + font-size: var(--font-size-xs); color: var(--color-text-muted); - text-transform: uppercase; - letter-spacing: 0.02em; } -.card__value { - font-size: 1.6rem; - font-weight: 700; +.kpi__link { + margin-top: auto; + font-size: var(--font-size-sm); } .progress-bar { - height: 6px; + height: 8px; + margin: var(--space-1) 0; background: var(--color-border-light); border-radius: var(--radius-pill); overflow: hidden; - margin-top: 0.25rem; } .progress-bar__fill { height: 100%; - background: var(--color-primary); border-radius: var(--radius-pill); + background: var(--color-success); transition: width 0.3s ease; } +.progress-bar__fill--warning { + background: var(--color-warning); +} + +.progress-bar__fill--danger { + background: var(--color-danger); +} + +.dashboard__grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(320px, 380px); + gap: var(--space-4); + align-items: start; +} + +.dashboard__side { + position: sticky; + top: var(--space-3); +} + .chart-section { - margin-bottom: 2.5rem; + margin-bottom: var(--space-5); } -.alerts-list { - list-style: none; +.chart-card { + padding: var(--space-3); +} + +.dashboard__empty { margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.alert-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.7rem 1rem; - border-radius: var(--radius-md); - background: var(--color-danger-bg); - border: 1px solid var(--color-danger-border); -} - -.alert-item__message { - font-size: 0.9rem; -} - -.predictions-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.prediction-item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - padding: 0.7rem 1rem; - border-radius: var(--radius-md); - background: var(--color-surface); - border: 1px solid var(--color-border-light); -} - -.prediction-item__site { - font-size: 0.9rem; - font-weight: 600; -} - -.prediction-item__value { - font-size: 0.9rem; - font-weight: 600; -} - -.prediction-item__target { - margin-left: 0.35rem; - font-size: 0.8rem; - font-weight: 400; + font-size: var(--font-size-sm); color: var(--color-text-muted); } + +@media (max-width: 900px) { + .dashboard__grid { + grid-template-columns: 1fr; + } + + .dashboard__side { + position: static; + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 910b0f6..e8a696e 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -1,12 +1,13 @@ import { TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; -import { of, throwError } from 'rxjs'; +import { Observable, of, throwError } from 'rxjs'; +import { Router, provideRouter } from '@angular/router'; import { Dashboard } from './dashboard'; import { StatsService } from '../../core/services/stats.service'; import { AlertsService } from '../../core/services/alerts.service'; +import { SitesService } from '../../core/services/sites.service'; import { PredictionsService } from '../../core/services/predictions.service'; -import {AuthService} from '../../core/services/auth.service'; -import {Router, provideRouter} from '@angular/router'; +import { AuthService } from '../../core/services/auth.service'; vi.mock('chart.js', () => { class ChartMock { @@ -18,66 +19,73 @@ vi.mock('chart.js', () => { return { Chart: ChartMock, registerables: [] }; }); +const STATS = { total_sites: 7, sites: [] }; + function predictionsMock(sites: unknown[] = []) { - return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) }; + return { + getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })), + }; +} + +function setup( + options: { + stats?: Observable; + predictions?: { getPredictions: ReturnType }; + auth?: Record; + } = {}, +) { + const statsMock = { getSummary: vi.fn().mockReturnValue(options.stats ?? of(STATS)) }; + const predictions = options.predictions ?? predictionsMock(); + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: { getAlerts: vi.fn().mockReturnValue(of([])) } }, + { provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of([])) } }, + { provide: PredictionsService, useValue: predictions }, + ...(options.auth ? [{ provide: AuthService, useValue: options.auth }] : []), + provideRouter([]), + ], + }); + return { fixture: TestBed.createComponent(Dashboard), statsMock, predictions }; } describe('Dashboard', () => { afterEach(() => vi.useRealTimers()); - it('charge les stats, les alertes et les prévisions au démarrage', async () => { - const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; - const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) }; - const predictions = predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]); - - TestBed.configureTestingModule({ - imports: [Dashboard], - providers: [ - { provide: StatsService, useValue: statsMock }, - { provide: AlertsService, useValue: alertsMock }, - { provide: PredictionsService, useValue: predictions }, - provideRouter([]), - ], + it('charge les stats et les prévisions au démarrage', async () => { + const { fixture, statsMock, predictions } = setup({ + predictions: predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]), }); - const fixture = TestBed.createComponent(Dashboard); fixture.detectChanges(); - - // laisse le timer(0, ...) se déclencher avant de vérifier await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); expect(statsMock.getSummary).toHaveBeenCalled(); - expect(alertsMock.getAlerts).toHaveBeenCalled(); expect(predictions.getPredictions).toHaveBeenCalled(); - expect(fixture.componentInstance.alerts().length).toBe(1); expect(fixture.componentInstance.predictions().length).toBe(1); expect(fixture.componentInstance.statsError()).toBeNull(); - expect(fixture.componentInstance.alertsError()).toBeNull(); expect(fixture.componentInstance.predictionsError()).toBeNull(); }); + it('délègue les alertes au widget app-alert-feed', () => { + const { fixture } = setup(); + + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('app-alert-feed')).not.toBeNull(); + }); + it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => { vi.useFakeTimers(); - const statsMock = { - getSummary: vi - .fn() - .mockReturnValueOnce(throwError(() => new Error('API injoignable'))) - .mockReturnValue(of({ total_sites: 7, sites: [] })), - }; - const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; - - TestBed.configureTestingModule({ - imports: [Dashboard], - providers: [ - { provide: StatsService, useValue: statsMock }, - { provide: AlertsService, useValue: alertsMock }, - { provide: PredictionsService, useValue: predictionsMock() }, - provideRouter([]), - ], + const { fixture, statsMock } = setup({ + stats: throwError(() => new Error('API injoignable')), }); + statsMock.getSummary + .mockReturnValueOnce(throwError(() => new Error('API injoignable'))) + .mockReturnValue(of(STATS)); - const fixture = TestBed.createComponent(Dashboard); fixture.detectChanges(); vi.advanceTimersByTime(1); @@ -91,45 +99,11 @@ describe('Dashboard', () => { expect(fixture.componentInstance.statsError()).toBeNull(); }); - it("n'interrompt pas la page quand le chargement des alertes échoue", () => { - const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; - const alertsMock = { getAlerts: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }; - - TestBed.configureTestingModule({ - imports: [Dashboard], - providers: [ - { provide: StatsService, useValue: statsMock }, - { provide: AlertsService, useValue: alertsMock }, - { provide: PredictionsService, useValue: predictionsMock() }, - provideRouter([]), - ], - }); - - const fixture = TestBed.createComponent(Dashboard); - fixture.detectChanges(); - - expect(fixture.componentInstance.alerts().length).toBe(0); - expect(fixture.componentInstance.alertsError()).not.toBeNull(); - }); - it("n'interrompt pas la page quand le chargement des prévisions échoue", () => { - const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; - const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; - const predictions = { - getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), - }; - - TestBed.configureTestingModule({ - imports: [Dashboard], - providers: [ - { provide: StatsService, useValue: statsMock }, - { provide: AlertsService, useValue: alertsMock }, - { provide: PredictionsService, useValue: predictions }, - provideRouter([]), - ], + const { fixture } = setup({ + predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }, }); - const fixture = TestBed.createComponent(Dashboard); fixture.detectChanges(); expect(fixture.componentInstance.predictions().length).toBe(0); @@ -138,25 +112,11 @@ describe('Dashboard', () => { it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => { vi.useFakeTimers(); - const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; - const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; - const predictions = { - getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), - }; - - TestBed.configureTestingModule({ - imports: [Dashboard], - providers: [ - { provide: StatsService, useValue: statsMock }, - { provide: AlertsService, useValue: alertsMock }, - { provide: PredictionsService, useValue: predictions }, - provideRouter([]), - ], + const { fixture } = setup({ + predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }, }); - const fixture = TestBed.createComponent(Dashboard); fixture.detectChanges(); - expect(fixture.componentInstance.predictionsError()).not.toBeNull(); // Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit @@ -168,109 +128,106 @@ describe('Dashboard', () => { }); it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => { - 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(), - principal: vi.fn().mockReturnValue({ role: 'admin' }), - }; - TestBed.configureTestingModule({ - imports: [Dashboard], - providers: [ - { provide: StatsService, useValue: statsMock }, - { provide: AlertsService, useValue: alertsMock }, - { provide: PredictionsService, useValue: predictionsMock() }, - { provide: AuthService, useValue: authMock }, - 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(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: [] })) }; - const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; const authMock = { - logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))), - clearSession: vi.fn(), - principal: vi.fn().mockReturnValue({ role: 'admin' }), - }; - TestBed.configureTestingModule({ - imports: [Dashboard], - providers: [ - { provide: StatsService, useValue: statsMock }, - { provide: AlertsService, useValue: alertsMock }, - { provide: PredictionsService, useValue: predictionsMock() }, - { provide: AuthService, useValue: authMock }, - provideRouter([]), - ], + logout: vi.fn().mockReturnValue(of(undefined)), + clearSession: vi.fn(), + principal: vi.fn().mockReturnValue({ role: 'admin' }), + }; + const { fixture } = setup({ auth: authMock }); + fixture.detectChanges(); + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + + fixture.nativeElement.querySelector('.logout-button').click(); + + expect(authMock.logout).toHaveBeenCalled(); + expect(navigateSpy).toHaveBeenCalledWith(['/login']); }); - const fixture = TestBed.createComponent(Dashboard); - fixture.detectChanges(); + it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => { + const authMock = { + logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))), + clearSession: vi.fn(), + principal: vi.fn().mockReturnValue({ role: 'admin' }), + }; + const { fixture } = setup({ auth: authMock }); + fixture.detectChanges(); + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); - const router = TestBed.inject(Router); - const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + fixture.nativeElement.querySelector('.logout-button').click(); - const button = fixture.nativeElement.querySelector('.logout-button'); - button.click(); + expect(authMock.clearSession).toHaveBeenCalled(); + expect(navigateSpy).toHaveBeenCalledWith(['/login']); + }); - expect(authMock.clearSession).toHaveBeenCalled(); - expect(navigateSpy).toHaveBeenCalledWith(['/login']); -}); - - it('distingue le ton des sévérités high et critical', () => { - const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; - const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; - - TestBed.configureTestingModule({ - imports: [Dashboard], - providers: [ - { provide: StatsService, useValue: statsMock }, - { provide: AlertsService, useValue: alertsMock }, - { provide: PredictionsService, useValue: predictionsMock() }, - provideRouter([]), - ], + it('affiche l’heure du dernier relevé et les indicateurs du parc', () => { + vi.useFakeTimers(); + const { fixture } = setup({ + stats: of({ + timestamp: '2026-09-18T09:00:00Z', + total_sites: 7, + total_consumption_kw: 1234.5, + total_capacity_kw: 5000, + average_load_percent: 24.7, + sites: [], + }), }); - const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + vi.advanceTimersByTime(1); + fixture.detectChanges(); + + const texte = fixture.nativeElement.textContent as string; + expect(texte).toContain('Actualisé à'); + expect(texte).toContain('7 sites suivis'); + expect(texte).toContain('Marge confortable'); + expect(fixture.nativeElement.querySelector('.progress-bar__fill--success')).not.toBeNull(); + }); + + it('présente les prévisions en tableau avec un lien vers chaque site', () => { + const { fixture } = setup({ + predictions: predictionsMock([ + { + site_id: 'SITE001', + site_name: 'Usine Nantes', + prediction: { + target_at: '2026-09-18T10:00:00Z', + target_metric: 'consumption_kwh', + period_minutes: 60, + predicted_value: 118.4, + status: 'available', + failure_reason: null, + model_reference: 'lightgbm-v1', + created_at: '2026-09-18T09:00:00Z', + }, + }, + { site_id: 'SITE002', site_name: 'Bureau Lille', prediction: null }, + ]), + }); + + fixture.detectChanges(); + + const table = fixture.nativeElement.querySelector('table.ev-table'); + expect(table).not.toBeNull(); + expect(table.textContent).toContain('118.4 kWh'); + expect(table.textContent).toContain('Pas encore de prévision'); + expect(fixture.nativeElement.querySelector('a[href="/sites/SITE001"]')).not.toBeNull(); + }); + + it('colore la charge moyenne selon les seuils 70 % et 90 %', () => { + const { fixture } = setup(); const dashboard = fixture.componentInstance; - expect(dashboard.badgeToneForSeverity('low')).toBe('success'); - expect(dashboard.badgeToneForSeverity('medium')).toBe('warning'); - expect(dashboard.badgeToneForSeverity('high')).toBe('danger'); - expect(dashboard.badgeToneForSeverity('critical')).toBe('critical'); - expect(dashboard.badgeToneForSeverity('high')).not.toBe( - dashboard.badgeToneForSeverity('critical'), - ); + expect(dashboard.loadTone(69.9)).toBe('success'); + expect(dashboard.loadTone(70)).toBe('warning'); + expect(dashboard.loadTone(89.9)).toBe('warning'); + expect(dashboard.loadTone(90)).toBe('danger'); + expect(dashboard.loadHint(95)).toBe('Proche de la capacité du parc'); }); it('distingue le ton des statuts de prévision', () => { - const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; - const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; - - TestBed.configureTestingModule({ - imports: [Dashboard], - providers: [ - { provide: StatsService, useValue: statsMock }, - { provide: AlertsService, useValue: alertsMock }, - { provide: PredictionsService, useValue: predictionsMock() }, - provideRouter([]), - ], - }); - - const fixture = TestBed.createComponent(Dashboard); + const { fixture } = setup(); const dashboard = fixture.componentInstance; expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success'); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index 2ba20c0..2d9e1d6 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -6,11 +6,10 @@ 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'; -import { AlertsService } from '../../core/services/alerts.service'; +import { AlertFeed } from '../../shared/components/alert-feed/alert-feed'; import { PredictionsService } from '../../core/services/predictions.service'; import { AuthService } from '../../core/services/auth.service'; import { StatsSummary } from '../../shared/models/stats.model'; -import { Alert, AlertSeverity } from '../../shared/models/alert.model'; import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model'; import { Card } from '../../shared/components/ui/card/card'; import { Alert as EvAlert } from '../../shared/components/ui/alert/alert'; @@ -22,22 +21,19 @@ const REFRESH_INTERVAL_MS = 10000; const UNAVAILABLE_MESSAGE = 'Données indisponibles, les valeurs affichées datent du dernier relevé.'; -const TON_PAR_SEVERITE: Record = { - low: 'success', - medium: 'warning', - high: 'danger', - critical: 'critical', -}; - -// `error` n'a pas de précédent dans les fixtures ou l'API à ce jour, mais figure dans le -// domaine du schéma backend (`ck_prediction_status`) : mieux vaut une couleur définie que -// tomber sur `undefined` si ce statut apparaît un jour. +// `error` n'a pas encore de précédent côté API mais figure dans `ck_prediction_status` : +// mieux vaut un ton défini que `undefined` le jour où ce statut apparaît. const TON_PAR_STATUT_PREDICTION: Record = { available: 'success', insufficient_data: 'warning', error: 'danger', }; +const SEUIL_CHARGE_SOUTENUE = 70; +const SEUIL_CHARGE_CRITIQUE = 90; + +export type LoadTone = 'success' | 'warning' | 'danger'; + @Component({ selector: 'app-dashboard', standalone: true, @@ -47,6 +43,7 @@ const TON_PAR_STATUT_PREDICTION: Record = { RouterLink, ConsumptionGauge, SiteLoadChart, + AlertFeed, Card, EvAlert, Badge, @@ -58,32 +55,20 @@ const TON_PAR_STATUT_PREDICTION: Record = { }) export class Dashboard implements OnInit { private statsService = inject(StatsService); - private alertsService = inject(AlertsService); public auth = inject(AuthService); private predictionsService = inject(PredictionsService); private router = inject(Router); private destroyRef = inject(DestroyRef); stats = signal(null); - alerts = signal([]); predictions = signal([]); - // Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats) - // efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus, - // sans retry ni indication pour l'utilisateur que la section correspondante est restée vide. + // Piège : un signal d'erreur par flux, sinon le tick suivant de `timer` (stats) efface en + // silence l'échec des prévisions après 10 s au plus, sans retry ni indication à l'utilisateur. statsError = signal(null); - alertsError = signal(null); predictionsError = signal(null); ngOnInit(): void { - this.alertsService - .getAlerts() - .pipe(catchError(() => this.reportUnavailable(this.alertsError))) - .subscribe((alerts) => { - this.alertsError.set(null); - this.alerts.set(alerts); - }); - // Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul // chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`. this.predictionsService @@ -99,7 +84,9 @@ export class Dashboard implements OnInit { timer(0, REFRESH_INTERVAL_MS) .pipe( switchMap(() => - this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))), + this.statsService + .getSummary() + .pipe(catchError(() => this.reportUnavailable(this.statsError))), ), takeUntilDestroyed(this.destroyRef), ) @@ -109,14 +96,28 @@ export class Dashboard implements OnInit { }); } - badgeToneForSeverity(severity: AlertSeverity): BadgeTone { - return TON_PAR_SEVERITE[severity]; - } - badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone { return TON_PAR_STATUT_PREDICTION[status]; } + loadTone(percent: number): LoadTone { + if (percent >= SEUIL_CHARGE_CRITIQUE) { + return 'danger'; + } + return percent >= SEUIL_CHARGE_SOUTENUE ? 'warning' : 'success'; + } + + loadHint(percent: number): string { + switch (this.loadTone(percent)) { + case 'danger': + return 'Proche de la capacité du parc'; + case 'warning': + return 'Charge soutenue'; + default: + return 'Marge confortable'; + } + } + onLogout(): void { this.auth.logout().subscribe({ next: () => this.router.navigate(['/login']), 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 index 9998066..9e8576c 100644 --- a/apps/frontend/src/app/features/sites/site-list/site-list.html +++ b/apps/frontend/src/app/features/sites/site-list/site-list.html @@ -17,8 +17,8 @@ } - - + +
@@ -36,7 +36,9 @@ - + } 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 index 9fa25a2..77c1923 100644 --- a/apps/frontend/src/app/features/sites/site-list/site-list.scss +++ b/apps/frontend/src/app/features/sites/site-list/site-list.scss @@ -32,32 +32,3 @@ 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/shared/components/alert-feed/alert-feed.html b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.html new file mode 100644 index 0000000..12cb095 --- /dev/null +++ b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.html @@ -0,0 +1,95 @@ +
+
+
+

Alertes actives

+ @if (!loading() || alerts().length > 0) { +

+ {{ alerts().length }} {{ alerts().length > 1 ? 'alertes' : 'alerte' }} +

+ } +
+
+ @if (!siteId()) { + + } + +
+
+ + @if (error(); as message) { + {{ message }} + } + + @if (loading() && alerts().length === 0 && !error()) { +

Chargement des alertes…

+ } @else if (alerts().length === 0 && !error()) { + Aucune alerte pour ces critères. + } + +
    + @for (alert of visibleAlerts(); track alert.alert_id) { +
  • + + + +
    +
    + {{ + severityLabel(alert.severity) + }} + {{ typeLabel(alert.type) }} + {{ siteName(alert.site_id) }} + +
    +

    {{ alert.message }}

    + @if (alert.value !== null) { +

    + {{ alert.value | number: '1.0-1' }} {{ unitFor(alert.metric) }} + @if (alert.threshold !== null) { + seuil {{ alert.threshold | number: '1.0-1' }} {{ unitFor(alert.metric) }} + } +

    + } +
    +
  • + } +
+ + @if (hiddenCount() > 0) { + + Afficher plus ({{ hiddenCount() }} restantes) + + } +
diff --git a/apps/frontend/src/app/shared/components/alert-feed/alert-feed.scss b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.scss new file mode 100644 index 0000000..f5aa194 --- /dev/null +++ b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.scss @@ -0,0 +1,154 @@ +:host { + display: block; +} + +.alert-feed__header { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-3); +} + +.alert-feed__title { + margin: 0; + font-size: var(--font-size-lg); + font-weight: 600; +} + +.alert-feed__count { + margin: 0.15rem 0 0; + font-size: var(--font-size-sm); + color: var(--color-text-muted); +} + +.alert-feed__filters { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.alert-feed__filter { + display: flex; + flex-direction: column; + min-width: 10rem; + + .form-label { + margin-top: 0; + } +} + +.alert-feed__banner { + display: block; + margin-bottom: var(--space-3); +} + +.alert-feed__state { + margin: 0 0 var(--space-3); + font-size: var(--font-size-sm); + color: var(--color-text-muted); +} + +.alert-feed__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.alert-feed__item { + display: flex; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-md); + background: var(--color-surface); + border: 1px solid var(--color-border-light); + border-left: 4px solid var(--color-border); +} + +.alert-feed__item--medium { + border-left-color: var(--color-warning); + background: var(--color-warning-bg); + + .alert-feed__icon { + color: var(--color-warning-text); + } +} + +.alert-feed__item--high { + border-left-color: var(--color-danger); + background: var(--color-danger-bg); + + .alert-feed__icon { + color: var(--color-danger); + } +} + +.alert-feed__item--critical { + border-left-color: var(--color-critical); + background: var(--color-danger-bg); + + .alert-feed__icon { + color: var(--color-critical); + } +} + +.alert-feed__icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; + background: var(--color-surface); + color: var(--color-text-muted); + font-size: var(--font-size-lg); + box-shadow: var(--shadow-card); +} + +.alert-feed__body { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.alert-feed__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + font-size: var(--font-size-xs); + color: var(--color-text-muted); +} + +.alert-feed__type { + font-weight: 600; + color: var(--color-text); +} + +.alert-feed__message { + margin: 0; + font-size: var(--font-size-sm); +} + +.alert-feed__values { + display: flex; + gap: var(--space-2); + margin: 0; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + + strong { + color: var(--color-text); + } +} + +.alert-feed__more { + display: inline-block; + margin-top: var(--space-3); +} diff --git a/apps/frontend/src/app/shared/components/alert-feed/alert-feed.spec.ts b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.spec.ts new file mode 100644 index 0000000..e2068ec --- /dev/null +++ b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.spec.ts @@ -0,0 +1,218 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { of, throwError } from 'rxjs'; +import { AlertFeed } from './alert-feed'; +import { AlertsService } from '../../../core/services/alerts.service'; +import { SitesService } from '../../../core/services/sites.service'; +import { Alert } from '../../models/alert.model'; +import { Site } from '../../models/site.model'; + +const SITES: Site[] = [ + { + site_id: 'SITE001', + site_name: 'Usine Nantes', + site_type: 'industriel', + location: 'Nantes', + capacity_kw: 500, + status: 'actif', + }, +]; + +function alerte(surcharges: Partial = {}): Alert { + return { + alert_id: 1, + site_id: 'SITE001', + timestamp: '2026-09-15T11:12:00Z', + type: 'spike', + severity: 'critical', + message: 'Variation brutale entre deux lectures consécutives', + value: 812.5, + threshold: 400, + metric: 'consumption_kw', + prediction_id: null, + ...surcharges, + }; +} + +function setup( + alertsMock: { getAlerts: ReturnType }, + sitesMock: { getSites: ReturnType } = { + getSites: vi.fn().mockReturnValue(of(SITES)), + }, +) { + TestBed.configureTestingModule({ + imports: [AlertFeed], + providers: [ + { provide: AlertsService, useValue: alertsMock }, + { provide: SitesService, useValue: sitesMock }, + ], + }); + return TestBed.createComponent(AlertFeed); +} + +function premierChargement(fixture: ComponentFixture) { + fixture.detectChanges(); + vi.advanceTimersByTime(1); + fixture.detectChanges(); +} + +function texte(fixture: ComponentFixture): string { + return (fixture.nativeElement as HTMLElement).textContent ?? ''; +} + +function choisir(fixture: ComponentFixture, testId: string, value: string) { + const select = fixture.nativeElement.querySelector( + `[data-testid="${testId}"]`, + ) as HTMLSelectElement; + select.value = value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + vi.advanceTimersByTime(1); + fixture.detectChanges(); +} + +describe('AlertFeed', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('charge les alertes au démarrage sans filtre et les affiche avec leur contexte', () => { + const getAlerts = vi.fn().mockReturnValue(of([alerte()])); + const fixture = setup({ getAlerts }); + + premierChargement(fixture); + + expect(getAlerts).toHaveBeenCalledTimes(1); + expect(getAlerts.mock.calls[0][0]).toEqual({}); + const contenu = texte(fixture); + expect(contenu).toContain('Usine Nantes'); + expect(contenu).toContain('Critique'); + expect(contenu).toContain('Pic de consommation'); + expect(contenu).toContain('15/09/2026'); + expect(contenu).toContain('812.5 kW'); + expect(contenu).toContain('seuil 400 kW'); + expect(fixture.nativeElement.querySelector('ev-icon svg')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.alert-feed__item--critical')).not.toBeNull(); + }); + + it('annonce le chargement avant la première réponse', () => { + const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of([])) }); + + fixture.detectChanges(); + + expect(fixture.componentInstance.loading()).toBe(true); + expect(texte(fixture)).toContain('Chargement des alertes'); + }); + + it("annonce l'absence d'alerte pour les critères choisis", () => { + const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of([])) }); + + premierChargement(fixture); + + expect(texte(fixture)).toContain('Aucune alerte pour ces critères.'); + expect(fixture.nativeElement.querySelectorAll('li').length).toBe(0); + }); + + it('relance la requête avec la sévérité choisie et revient à la première page', () => { + const getAlerts = vi.fn().mockReturnValue(of([alerte()])); + const fixture = setup({ getAlerts }); + premierChargement(fixture); + fixture.componentInstance.showMore(); + + choisir(fixture, 'severity-filter', 'high'); + + expect(getAlerts).toHaveBeenCalledTimes(2); + expect(getAlerts.mock.calls[1][0]).toEqual({ severity: 'high' }); + expect(fixture.componentInstance.visibleCount()).toBe(10); + }); + + it('relance la requête avec le site choisi dans le filtre', () => { + const getAlerts = vi.fn().mockReturnValue(of([])); + const fixture = setup({ getAlerts }); + premierChargement(fixture); + + choisir(fixture, 'site-filter', 'SITE001'); + + expect(getAlerts.mock.calls[1][0]).toEqual({ site_id: 'SITE001' }); + }); + + it('masque le filtre site et force site_id quand le parent fixe le site', () => { + const getAlerts = vi.fn().mockReturnValue(of([])); + const fixture = setup({ getAlerts }); + fixture.componentRef.setInput('siteId', 'SITE001'); + + premierChargement(fixture); + + expect(getAlerts.mock.calls[0][0]).toEqual({ site_id: 'SITE001' }); + expect(fixture.nativeElement.querySelector('[data-testid="site-filter"]')).toBeNull(); + expect(fixture.nativeElement.querySelector('[data-testid="severity-filter"]')).not.toBeNull(); + }); + + it("signale l'indisponibilité en gardant la liste, puis repart au rafraîchissement suivant", () => { + const getAlerts = vi + .fn() + .mockReturnValueOnce(of([alerte()])) + .mockReturnValueOnce(throwError(() => new Error('API injoignable'))) + .mockReturnValue(of([alerte(), alerte({ alert_id: 2 })])); + const fixture = setup({ getAlerts }); + premierChargement(fixture); + + vi.advanceTimersByTime(60_000); + fixture.detectChanges(); + + expect(getAlerts).toHaveBeenCalledTimes(2); + expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.alerts().length).toBe(1); + expect(texte(fixture)).toContain('Alertes indisponibles'); + + vi.advanceTimersByTime(60_000); + fixture.detectChanges(); + + expect(getAlerts).toHaveBeenCalledTimes(3); + expect(fixture.componentInstance.error()).toBeNull(); + expect(fixture.componentInstance.alerts().length).toBe(2); + }); + + it('pagine côté client par dix et dévoile le reste à la demande', () => { + const alertes = Array.from({ length: 25 }, (_, i) => alerte({ alert_id: i + 1 })); + const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(alertes)) }); + premierChargement(fixture); + + expect(fixture.nativeElement.querySelectorAll('li').length).toBe(10); + expect(texte(fixture)).toContain('Afficher plus (15 restantes)'); + + fixture.nativeElement.querySelector('[data-testid="show-more"]').click(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelectorAll('li').length).toBe(20); + + fixture.nativeElement.querySelector('[data-testid="show-more"]').click(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelectorAll('li').length).toBe(25); + expect(fixture.nativeElement.querySelector('[data-testid="show-more"]')).toBeNull(); + }); + + it("replie sur l'identifiant quand le site est inconnu ou que la liste des sites échoue", () => { + const fixture = setup( + { getAlerts: vi.fn().mockReturnValue(of([alerte({ site_id: 'SITE999' })])) }, + { getSites: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }, + ); + + premierChargement(fixture); + + expect(texte(fixture)).toContain('SITE999'); + }); + + it("n'affiche pas de mesure pour une alerte sans valeur", () => { + const fixture = setup({ + getAlerts: vi + .fn() + .mockReturnValue( + of([alerte({ type: 'outage', value: null, threshold: null, metric: null })]), + ), + }); + + premierChargement(fixture); + + expect(fixture.nativeElement.querySelector('.alert-feed__values')).toBeNull(); + expect(texte(fixture)).toContain('Coupure'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/alert-feed/alert-feed.ts b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.ts new file mode 100644 index 0000000..91f5d42 --- /dev/null +++ b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.ts @@ -0,0 +1,130 @@ +import { Component, DestroyRef, computed, inject, input, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { DatePipe, DecimalPipe } from '@angular/common'; +import { catchError, EMPTY, Observable, of, switchMap, tap, timer } from 'rxjs'; +import { AlertFilters, AlertsService } from '../../../core/services/alerts.service'; +import { SitesService } from '../../../core/services/sites.service'; +import { Alert, AlertMetric, AlertSeverity, AlertType } from '../../models/alert.model'; +import { Site } from '../../models/site.model'; +import { + LIBELLE_PAR_SEVERITE, + LIBELLE_PAR_TYPE, + SEVERITES, + TON_PAR_SEVERITE, + UNITE_PAR_METRIQUE, +} from '../../models/alert-presentation'; +import { Badge, BadgeTone } from '../ui/badge/badge'; +import { Button } from '../ui/button/button'; +import { Alert as EvAlert } from '../ui/alert/alert'; +import { Icon } from '../ui/icon/icon'; + +// Le DAG de détection tourne toutes les heures : une minute suffit largement pour suivre le flux. +const REFRESH_INTERVAL_MS = 60_000; +const PAGE_SIZE = 10; +const UNAVAILABLE_MESSAGE = 'Alertes indisponibles, la liste affichée date du dernier chargement.'; + +@Component({ + selector: 'app-alert-feed', + standalone: true, + imports: [DatePipe, DecimalPipe, Badge, Button, EvAlert, Icon], + templateUrl: './alert-feed.html', + styleUrl: './alert-feed.scss', +}) +export class AlertFeed { + private alertsService = inject(AlertsService); + private sitesService = inject(SitesService); + private destroyRef = inject(DestroyRef); + + siteId = input(null); + + readonly severites = SEVERITES; + severity = signal(null); + siteFilter = signal(null); + + alerts = signal([]); + loading = signal(true); + error = signal(null); + visibleCount = signal(PAGE_SIZE); + + sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), { + initialValue: [] as Site[], + }); + + private filters = computed(() => ({ + site_id: this.siteId() ?? this.siteFilter() ?? undefined, + severity: this.severity() ?? undefined, + })); + + private siteNameById = computed( + () => new Map(this.sites().map((site) => [site.site_id, site.site_name])), + ); + + visibleAlerts = computed(() => this.alerts().slice(0, this.visibleCount())); + hiddenCount = computed(() => Math.max(this.alerts().length - this.visibleCount(), 0)); + + constructor() { + toObservable(this.filters) + .pipe( + tap(() => { + this.loading.set(true); + this.visibleCount.set(PAGE_SIZE); + }), + // Piège : catchError sur l'observable interne ; sur le flux externe il terminerait le + // timer et le rafraîchissement ne repartirait jamais. + switchMap((filters) => + timer(0, REFRESH_INTERVAL_MS).pipe( + switchMap(() => + this.alertsService + .getAlerts(filters) + .pipe(catchError(() => this.reportUnavailable())), + ), + ), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((alerts) => { + this.loading.set(false); + this.error.set(null); + this.alerts.set(alerts); + }); + } + + onSiteChange(event: Event): void { + this.siteFilter.set((event.target as HTMLSelectElement).value || null); + } + + onSeverityChange(event: Event): void { + const value = (event.target as HTMLSelectElement).value; + this.severity.set(value ? (value as AlertSeverity) : null); + } + + showMore(): void { + this.visibleCount.update((count) => count + PAGE_SIZE); + } + + toneFor(severity: AlertSeverity): BadgeTone { + return TON_PAR_SEVERITE[severity]; + } + + severityLabel(severity: AlertSeverity): string { + return LIBELLE_PAR_SEVERITE[severity]; + } + + typeLabel(type: AlertType): string { + return LIBELLE_PAR_TYPE[type]; + } + + siteName(siteId: string): string { + return this.siteNameById().get(siteId) ?? siteId; + } + + unitFor(metric: AlertMetric | null): string { + return metric ? UNITE_PAR_METRIQUE[metric] : ''; + } + + private reportUnavailable(): Observable { + this.loading.set(false); + this.error.set(UNAVAILABLE_MESSAGE); + return EMPTY; + } +} diff --git a/apps/frontend/src/app/shared/components/ui/icon/icon.html b/apps/frontend/src/app/shared/components/ui/icon/icon.html new file mode 100644 index 0000000..869ccd1 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/icon/icon.html @@ -0,0 +1,37 @@ + + @switch (name()) { + @case ('spike') { + + + } + @case ('threshold') { + + + } + @case ('anomaly') { + + } + @case ('outage') { + + + } + @case ('sensor') { + + + + + + } + } + diff --git a/apps/frontend/src/app/shared/components/ui/icon/icon.scss b/apps/frontend/src/app/shared/components/ui/icon/icon.scss new file mode 100644 index 0000000..c90a9ce --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/icon/icon.scss @@ -0,0 +1,12 @@ +:host { + display: inline-flex; + flex-shrink: 0; + width: 1em; + height: 1em; + vertical-align: -0.125em; +} + +svg { + width: 100%; + height: 100%; +} diff --git a/apps/frontend/src/app/shared/components/ui/icon/icon.spec.ts b/apps/frontend/src/app/shared/components/ui/icon/icon.spec.ts new file mode 100644 index 0000000..ce3dedc --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/icon/icon.spec.ts @@ -0,0 +1,49 @@ +import { TestBed } from '@angular/core/testing'; +import { Icon, IconName } from './icon'; + +const NOMS: IconName[] = ['spike', 'threshold', 'anomaly', 'outage', 'sensor']; + +function rendre(name: IconName, label: string | null = null) { + const fixture = TestBed.createComponent(Icon); + fixture.componentRef.setInput('name', name); + fixture.componentRef.setInput('label', label); + fixture.detectChanges(); + return fixture.nativeElement as HTMLElement; +} + +describe('Icon', () => { + beforeEach(() => { + TestBed.configureTestingModule({ imports: [Icon] }); + }); + + it('dessine un tracé distinct pour chacun des cinq types', () => { + const traces = NOMS.map((name) => rendre(name).querySelector('svg')?.innerHTML.trim()); + + for (const trace of traces) { + expect(trace).toBeTruthy(); + } + expect(new Set(traces).size).toBe(NOMS.length); + }); + + it('reste décoratif sans libellé', () => { + const svg = rendre('spike').querySelector('svg'); + + expect(svg?.getAttribute('aria-hidden')).toBe('true'); + expect(svg?.hasAttribute('role')).toBe(false); + }); + + it('expose un rôle image et un libellé accessible quand on lui en donne un', () => { + const svg = rendre('outage', 'Coupure').querySelector('svg'); + + expect(svg?.getAttribute('role')).toBe('img'); + expect(svg?.getAttribute('aria-label')).toBe('Coupure'); + expect(svg?.hasAttribute('aria-hidden')).toBe(false); + }); + + it('hérite de la couleur du parent via currentColor', () => { + const svg = rendre('sensor').querySelector('svg'); + + expect(svg?.getAttribute('stroke')).toBe('currentColor'); + expect(svg?.getAttribute('fill')).toBe('none'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/icon/icon.ts b/apps/frontend/src/app/shared/components/ui/icon/icon.ts new file mode 100644 index 0000000..dc302e9 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/icon/icon.ts @@ -0,0 +1,14 @@ +import { Component, input } from '@angular/core'; + +export type IconName = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor'; + +@Component({ + selector: 'ev-icon', + standalone: true, + templateUrl: './icon.html', + styleUrl: './icon.scss', +}) +export class Icon { + name = input.required(); + label = input(null); +} diff --git a/apps/frontend/src/app/shared/models/alert-presentation.spec.ts b/apps/frontend/src/app/shared/models/alert-presentation.spec.ts new file mode 100644 index 0000000..b31fdb8 --- /dev/null +++ b/apps/frontend/src/app/shared/models/alert-presentation.spec.ts @@ -0,0 +1,37 @@ +import { + LIBELLE_PAR_SEVERITE, + LIBELLE_PAR_TYPE, + SEVERITES, + TON_PAR_SEVERITE, + TYPES_ALERTE, + UNITE_PAR_METRIQUE, +} from './alert-presentation'; + +describe('alert-presentation', () => { + it('distingue le ton des sévérités high et critical', () => { + expect(TON_PAR_SEVERITE.high).toBe('danger'); + expect(TON_PAR_SEVERITE.critical).toBe('critical'); + expect(TON_PAR_SEVERITE.high).not.toBe(TON_PAR_SEVERITE.critical); + }); + + it("n'affiche pas une alerte faible avec le ton de succès", () => { + expect(TON_PAR_SEVERITE.low).toBe('neutral'); + expect(TON_PAR_SEVERITE.medium).toBe('warning'); + }); + + it('donne un libellé français à chaque sévérité et à chaque type', () => { + for (const severite of SEVERITES) { + expect(LIBELLE_PAR_SEVERITE[severite]).toBeTruthy(); + } + for (const type of TYPES_ALERTE) { + expect(LIBELLE_PAR_TYPE[type]).toBeTruthy(); + } + expect(SEVERITES.length).toBe(4); + expect(TYPES_ALERTE.length).toBe(5); + }); + + it('associe une unité à chaque métrique du contrat', () => { + expect(UNITE_PAR_METRIQUE.consumption_kw).toBe('kW'); + expect(UNITE_PAR_METRIQUE.consumption_kwh).toBe('kWh'); + }); +}); diff --git a/apps/frontend/src/app/shared/models/alert-presentation.ts b/apps/frontend/src/app/shared/models/alert-presentation.ts new file mode 100644 index 0000000..cffa772 --- /dev/null +++ b/apps/frontend/src/app/shared/models/alert-presentation.ts @@ -0,0 +1,41 @@ +import { BadgeTone } from '../components/ui/badge/badge'; +import { AlertMetric, AlertSeverity, AlertType } from './alert.model'; + +// Pourquoi : `low` en neutre plutôt qu'en vert, une alerte faible reste une alerte ; le vert se +// lisait comme « tout va bien » à côté des rouges. +export const TON_PAR_SEVERITE: Record = { + low: 'neutral', + medium: 'warning', + high: 'danger', + critical: 'critical', +}; + +export const LIBELLE_PAR_SEVERITE: Record = { + low: 'Faible', + medium: 'Moyenne', + high: 'Élevée', + critical: 'Critique', +}; + +export const LIBELLE_PAR_TYPE: Record = { + spike: 'Pic de consommation', + threshold: 'Seuil dépassé', + anomaly: 'Anomalie', + outage: 'Coupure', + sensor: 'Capteur', +}; + +export const UNITE_PAR_METRIQUE: Record = { + consumption_kw: 'kW', + consumption_kwh: 'kWh', +}; + +export const SEVERITES: readonly AlertSeverity[] = ['low', 'medium', 'high', 'critical']; + +export const TYPES_ALERTE: readonly AlertType[] = [ + 'spike', + 'threshold', + 'anomaly', + 'outage', + 'sensor', +]; diff --git a/apps/frontend/src/app/shared/models/alert.model.ts b/apps/frontend/src/app/shared/models/alert.model.ts index 028f35a..8a51c93 100644 --- a/apps/frontend/src/app/shared/models/alert.model.ts +++ b/apps/frontend/src/app/shared/models/alert.model.ts @@ -1,13 +1,16 @@ export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical'; export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor'; +export type AlertMetric = 'consumption_kw' | 'consumption_kwh'; export interface Alert { - alert_id: string; - timestamp: string; + alert_id: number; site_id: string; - severity: AlertSeverity; + timestamp: string; type: AlertType; + severity: AlertSeverity; message: string; - value: number; - threshold: number; + value: number | null; + threshold: number | null; + metric: AlertMetric | null; + prediction_id: number | null; } diff --git a/apps/frontend/src/styles.scss b/apps/frontend/src/styles.scss index 5599780..179d4ec 100644 --- a/apps/frontend/src/styles.scss +++ b/apps/frontend/src/styles.scss @@ -2,6 +2,7 @@ @use 'styles/forms'; @use 'styles/auth-page'; @use 'styles/links'; +@use 'styles/tables'; body { margin: 0; diff --git a/apps/frontend/src/styles/_forms.scss b/apps/frontend/src/styles/_forms.scss index 9bbfb0c..7cde5d1 100644 --- a/apps/frontend/src/styles/_forms.scss +++ b/apps/frontend/src/styles/_forms.scss @@ -29,3 +29,18 @@ color: var(--color-disabled); margin-top: 0.25rem; } + +// Piège : le chevron est un SVG en data URI, où aucun token CSS n'est lisible ; sa couleur +// reprend en dur la valeur de --color-text-muted. +.form-select { + @extend .form-input; + padding-right: 2.25rem; + color: var(--color-text); + background-color: var(--color-surface); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.6rem center; + background-size: 1rem; + appearance: none; + cursor: pointer; +} diff --git a/apps/frontend/src/styles/_tables.scss b/apps/frontend/src/styles/_tables.scss new file mode 100644 index 0000000..36b0026 --- /dev/null +++ b/apps/frontend/src/styles/_tables.scss @@ -0,0 +1,43 @@ +// Piège : `ev-card` pose son padding sur `:host`, compilé en `[_nghost-…]` (même spécificité +// qu'une classe) et injecté après la feuille globale ; il faut le sélecteur d'élément pour gagner. +ev-card.ev-table-card { + padding: 0; + overflow: hidden; +} + +.ev-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: var(--font-size-xs); + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + tbody tr:last-child td { + border-bottom: none; + } + + tbody tr:hover td { + background: var(--color-bg); + } +} + +.ev-table__number { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.ev-table__muted { + color: var(--color-text-muted); +} diff --git a/apps/frontend/src/styles/_tokens.scss b/apps/frontend/src/styles/_tokens.scss index 2e7663b..dd44c0e 100644 --- a/apps/frontend/src/styles/_tokens.scss +++ b/apps/frontend/src/styles/_tokens.scss @@ -29,10 +29,17 @@ // Typo, rayons, ombre --font-family: 'Segoe UI', system-ui, sans-serif; + --font-size-xs: 0.75rem; + --font-size-sm: 0.85rem; + --font-size-md: 1rem; + --font-size-lg: 1.25rem; + --font-size-xl: 1.75rem; + --font-size-2xl: 2.25rem; --radius-sm: 8px; --radius-md: 12px; --radius-pill: 999px; --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06); + --shadow-card-hover: 0 6px 16px rgba(0, 0, 0, 0.08); // Espacements --space-1: 0.35rem; diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index d2fbb9c..888336f 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -3,6 +3,7 @@ { "compileOnSave": false, "compilerOptions": { + "strict": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index 14a7012..5109a61 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -4,39 +4,47 @@ Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps ## État actuel -Statut : `En cours`. L'application sert une première page métier, le tableau de bord, alimentée -par des fixtures : les endpoints qu'elle appelle n'existent pas encore côté API. +Statut : `En cours`. L'application sert le tableau de bord, la liste et le détail des sites, la +supervision des capteurs (admin) et le flux des alertes actives, tous branchés sur l'API réelle. Ce qui est en place : - Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt. - `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et - `provideHttpClient(withInterceptors([mockApiInterceptor]))`. -- Une route `/dashboard` en composant différé, et une redirection depuis la racine. -- `core/services` porte `StatsService`, `AlertsService`, `PredictionsService`, `SitesService` et - `AuthService`, `core/interceptors` l'intercepteur de fixtures et l'intercepteur d'authentification - (jeton porteur, rafraîchissement sur 401), `core/guards` la garde de route `authGuard`, - `features/dashboard` la page principale, `shared/components` la jauge de consommation et le - graphique de charge par site, tous deux construits sur Chart.js. + `provideHttpClient(withInterceptors([authInterceptor, mockApiInterceptor]))`. +- Des routes en composants différés (`/dashboard`, `/sites`, `/sites/:siteId`, + `/monitoring/sensors` réservée au rôle `admin`) et une redirection depuis la racine. +- `core/services` porte un service HTTP par domaine (`StatsService`, `AlertsService` avec ses + filtres `site_id` et `severity`, `PredictionsService`, `SitesService`, `ReadingsService`, + `SensorsService`, `AuthService`), `core/interceptors` l'intercepteur de fixtures et l'intercepteur + d'authentification (jeton porteur, rafraîchissement sur 401), `core/guards` la garde `authGuard`. +- `features/` porte une page par domaine. `shared/components` porte la jauge de consommation et + les graphiques Chart.js, le widget `app-alert-feed` (flux d'alertes filtrable par site et + sévérité, rafraîchi toutes les 60 s, première vue de l'application avec des états chargement / + vide / indisponible) et, dans `shared/models`, des types alignés sur les schémas Pydantic du + backend, plus les tables de présentation partagées (`alert-presentation.ts` : ton, libellé et + unité par sévérité, type et métrique). - Une authentification complète côté interface : connexion, mot de passe oublié/réinitialisation, - changement de mot de passe, garde de route sur `/dashboard` et `/sites`. Détail : + changement de mot de passe, garde de route sur toute la zone authentifiée. Détail : [31-contrat-authentification.md](31-contrat-authentification.md). - Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`, - `ev-badge`, `ev-brand`, tokens CSS dans `styles/_tokens.scss`) que toute nouvelle page doit - réutiliser plutôt que redéfinir ses propres styles. Détail : + `ev-badge`, `ev-brand`, `ev-icon`, tokens CSS dans `styles/_tokens.scss`, classes globales de + formulaire, de navigation et de tableau) que toute nouvelle page doit réutiliser plutôt que + redéfinir ses propres styles. Détail : [32-design-systeme-frontend.md](32-design-systeme-frontend.md). - L'état vit dans des signaux, sans bibliothèque dédiée. +- TypeScript en `"strict": true` ; `strictTemplates` n'est pas encore activé. - Vitest via le builder `@angular/build:unit-test`, couverture activée. - Prettier configuré, parser `angular` pour les gabarits HTML. Ce qui n'existe pas encore : -- **`stats`/`alerts` restent sur fixtures.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts` - sont servis par l'intercepteur de fixtures ; l'API expose bien ces routes désormais, mais rien - ne bascule `useMockFixtures` à `false` en développement pour les consommer réellement. - `GET /api/v1/predictions` fait exception : jamais mocké, branché sur l'API réelle depuis cette - PR (voir plus bas). -- Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide. +- **Le mode fixtures est inactif.** `useMockFixtures` vaut `false` dans `environment.ts` comme dans + `environment.development.ts` : `mockApiInterceptor` ne sert `/stats/summary` et `/alerts` que + dans son propre spec. En développement, toutes les pages exigent un backend joignable et un jeton + valide. +- Un état de chargement généralisé : seul `app-alert-feed` en a un, les autres pages restent vides + tant que la première réponse n'est pas arrivée. - Aucun lint : ESLint n'est pas installé. ## Arborescence @@ -87,11 +95,11 @@ sequenceDiagram ``` `mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si -`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en -production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel. -`/predictions` est volontairement exclu de cette liste (contrairement à `stats`/`alerts`) : il -suit toujours le chemin réel, comme `/auth/*` - en développement, ça veut dire qu'un jeton valide -et un backend joignable sont nécessaires pour que la section prévisions du dashboard s'affiche. +`environment.useMockFixtures` est vrai. Le drapeau vaut `false` dans les deux fichiers +d'environnement : en pratique toutes les requêtes suivent le chemin réel et l'intercepteur n'est +exercé que par son spec. `/predictions` et `/auth/*` ne sont de toute façon jamais mockés. En +développement, un jeton valide et un backend joignable sont donc nécessaires pour que le tableau de +bord s'affiche. En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un diff --git a/docs/architecture/32-design-systeme-frontend.md b/docs/architecture/32-design-systeme-frontend.md index a7894d8..790c60f 100644 --- a/docs/architecture/32-design-systeme-frontend.md +++ b/docs/architecture/32-design-systeme-frontend.md @@ -20,16 +20,18 @@ seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentai | `--color-success` / `-bg`, `--color-warning` / `-bg` / `-text`, `--color-danger` / `-hover` / `-bg` / `-border`, `--color-critical` | États sémantiques (alertes, badges) | | `--color-text-inverse` | Texte sur fond coloré plein (boutons/badges) | | `--font-family` | Police unique de l'application | +| `--font-size-xs` à `--font-size-2xl` | Échelle typographique (0.75rem à 2.25rem) : libellés, corps, titres, grands nombres | | `--radius-sm`, `--radius-md`, `--radius-pill` | Rayons de bordure (input/bouton, carte, pastille) | -| `--shadow-card` | Ombre portée des cartes | +| `--shadow-card`, `--shadow-card-hover` | Ombre portée des cartes, au repos et au survol | | `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) | -Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`) sont dans -`apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles -s'appliquent directement à des `
Nom{{ site.site_type }} {{ site.location || '-' }} {{ site.capacity_kw ?? '-' }}{{ site.status ?? '-' }} + {{ site.status ?? '-' }} + Détail
`, `.ev-table__number` pour une cellule numérique en chiffres +tabulaires, `.ev-table__muted` pour une cellule sans valeur. ## Logo