From d16de84559fe169a0ec50a25876ccd5a1af99972 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 14:41:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20vue=20/recommendations=20avec?= =?UTF-8?q?=20filtre=20site,=20focus=20=3Falert=3D=20et=20g=C3=A9n=C3=A9ra?= =?UTF-8?q?tion=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Page derrière authGuard, ouverte à tous les rôles : filtre site (.form-select, présélectionné par ?site=), focus sur une alerte par ?alert= (entier strictement positif, sinon ignoré), et pour les admins un bouton « Générer les recommandations » qui appelle POST /recommendations/generate sur le site filtré, affiche le bilan accordé en nombre et recharge la liste. Route ajoutée dans app.routes.ts, lien « Recommandations » dans la navigation du tableau de bord. --- apps/frontend/src/app/app.routes.ts | 35 +++- .../src/app/features/dashboard/dashboard.html | 1 + .../recommendations/recommendations.html | 58 ++++++ .../recommendations/recommendations.scss | 59 ++++++ .../recommendations/recommendations.spec.ts | 169 ++++++++++++++++++ .../recommendations/recommendations.ts | 85 +++++++++ 6 files changed, 400 insertions(+), 7 deletions(-) create mode 100644 apps/frontend/src/app/features/recommendations/recommendations.html create mode 100644 apps/frontend/src/app/features/recommendations/recommendations.scss create mode 100644 apps/frontend/src/app/features/recommendations/recommendations.spec.ts create mode 100644 apps/frontend/src/app/features/recommendations/recommendations.ts diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 40e814f..d2f1079 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -1,21 +1,36 @@ import { Routes } from '@angular/router'; -import {authGuard} from './core/guards/auth-guard'; +import { authGuard } from './core/guards/auth-guard'; export const routes: Routes = [ { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, - { path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) }, - { path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) }, - { path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) }, - { path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) }, + { + path: 'login', + loadComponent: () => import('./features/auth/login/login').then((m) => m.Login), + }, + { + path: 'change-password', + loadComponent: () => + import('./features/auth/change-password/change-password').then((m) => m.ChangePassword), + }, + { + path: 'forgot-password', + loadComponent: () => + import('./features/auth/forgot-password/forgot-password').then((m) => m.ForgotPassword), + }, + { + path: 'reset-password', + loadComponent: () => + import('./features/auth/reset-password/reset-password').then((m) => m.ResetPassword), + }, { path: 'dashboard', canActivate: [authGuard], - loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard), + 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), + loadComponent: () => import('./features/sites/site-list/site-list').then((m) => m.SiteList), }, { path: 'sites/:siteId', @@ -23,6 +38,12 @@ export const routes: Routes = [ loadComponent: () => import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail), }, + { + path: 'recommendations', + canActivate: [authGuard], + loadComponent: () => + import('./features/recommendations/recommendations').then((m) => m.RecommendationsView), + }, { path: 'monitoring/sensors', canActivate: [authGuard], diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index f9a3fb2..ace0a14 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -14,6 +14,7 @@ Supervision des capteurs } Voir les sites + Recommandations + + +
+ + +
+

Recommandations

+

+ Actions proposées par le moteur de règles à partir des alertes +

+
+
+ +
+ + @if (isAdmin()) { + + {{ generating() ? 'Génération en cours…' : 'Générer les recommandations' }} + + } +
+ + @if (generationReport(); as report) { + {{ bilan(report) }}. + } + @if (generationError(); as message) { + {{ message }} + } + + @if (alertId(); as id) { +

+ Alerte n° {{ id }} · + Toutes les recommandations +

+ } + + + diff --git a/apps/frontend/src/app/features/recommendations/recommendations.scss b/apps/frontend/src/app/features/recommendations/recommendations.scss new file mode 100644 index 0000000..ba95139 --- /dev/null +++ b/apps/frontend/src/app/features/recommendations/recommendations.scss @@ -0,0 +1,59 @@ +:host { + display: block; + color: var(--color-text); + padding: 2.5rem 2rem; + max-width: 1100px; + margin: 0 auto; +} + +.recommendations__header { + display: flex; + align-items: center; + gap: 0.85rem; + margin-bottom: 2rem; + + h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + } +} + +.recommendations__logo { + font-size: 1.3rem; +} + +.recommendations__subtitle { + margin: 0.25rem 0 0; + color: var(--color-text-muted); +} + +.recommendations__toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-4); +} + +.recommendations__filter { + display: flex; + flex-direction: column; + min-width: 14rem; + + .form-label { + margin-top: 0; + } +} + +.recommendations__banner { + display: block; + margin-bottom: var(--space-3); +} + +.recommendations__focus { + margin: 0 0 var(--space-3); + font-size: 0.9rem; + color: var(--color-text-muted); +} diff --git a/apps/frontend/src/app/features/recommendations/recommendations.spec.ts b/apps/frontend/src/app/features/recommendations/recommendations.spec.ts new file mode 100644 index 0000000..dcb8e62 --- /dev/null +++ b/apps/frontend/src/app/features/recommendations/recommendations.spec.ts @@ -0,0 +1,169 @@ +import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; +import { vi } from 'vitest'; +import { BehaviorSubject, of, throwError } from 'rxjs'; +import { RecommendationsView, parseAlertId } from './recommendations'; +import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list'; +import { SitesService } from '../../core/services/sites.service'; +import { AlertsService } from '../../core/services/alerts.service'; +import { RecommendationsService } from '../../core/services/recommendations.service'; +import { AuthService } from '../../core/services/auth.service'; + +const SITES = [ + { + site_id: 'SITE001', + site_name: 'Usine Nantes', + site_type: 'industriel', + location: 'Nantes', + capacity_kw: 500, + status: 'actif', + }, +]; + +const BILAN = { alerts_examined: 2, recommendations_created: 3, already_present: 1 }; + +function setup(options: { query?: Record; role?: string } = {}) { + const query = options.query ?? {}; + const queryParamMap = new BehaviorSubject(convertToParamMap(query)); + const generate = vi.fn().mockReturnValue(of(BILAN)); + const getRecommendations = vi.fn().mockReturnValue(of([])); + const getAlerts = vi.fn().mockReturnValue(of([])); + TestBed.configureTestingModule({ + imports: [RecommendationsView], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { queryParamMap, snapshot: { queryParamMap: convertToParamMap(query) } }, + }, + { provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of(SITES)) } }, + { provide: AlertsService, useValue: { getAlerts } }, + { provide: RecommendationsService, useValue: { getRecommendations, generate } }, + { + provide: AuthService, + useValue: { principal: vi.fn().mockReturnValue({ role: options.role ?? 'lecteur' }) }, + }, + ], + }); + const fixture = TestBed.createComponent(RecommendationsView); + fixture.detectChanges(); + fixture.detectChanges(); + return { fixture, queryParamMap, generate, getRecommendations, getAlerts }; +} + +function listeEnfant(fixture: ReturnType['fixture']): RecommendationList { + return fixture.debugElement.query(By.directive(RecommendationList)).componentInstance; +} + +describe('parseAlertId', () => { + it("n'accepte qu'un entier strictement positif", () => { + expect(parseAlertId('12')).toBe(12); + expect(parseAlertId('0')).toBeNull(); + expect(parseAlertId('-3')).toBeNull(); + expect(parseAlertId('abc')).toBeNull(); + expect(parseAlertId('12abc')).toBeNull(); + expect(parseAlertId(null)).toBeNull(); + }); +}); + +describe('RecommendationsView', () => { + it("cible l'alerte donnée par ?alert= et la transmet à la liste", () => { + const { fixture } = setup({ query: { alert: '12' } }); + + expect(fixture.componentInstance.alertId()).toBe(12); + expect(listeEnfant(fixture).alertId()).toBe(12); + expect(fixture.nativeElement.textContent).toContain('Alerte n° 12'); + expect(fixture.nativeElement.querySelector('a[href="/recommendations"]')).not.toBeNull(); + }); + + it('ignore un paramètre alert invalide', () => { + const { fixture } = setup({ query: { alert: 'abc' } }); + + expect(fixture.componentInstance.alertId()).toBeNull(); + expect(fixture.nativeElement.textContent).not.toContain('Alerte n°'); + }); + + it('applique le site donné par ?site= au filtre et à la liste', () => { + const { fixture, getAlerts } = setup({ query: { site: 'SITE001' } }); + + expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' }); + const option = fixture.nativeElement.querySelector( + 'option[value="SITE001"]', + ) as HTMLOptionElement; + expect(option.selected).toBe(true); + }); + + it('relance la liste sur le site choisi dans le filtre', () => { + const { fixture, getAlerts } = setup(); + const select = fixture.nativeElement.querySelector( + '[data-testid="site-filter"]', + ) as HTMLSelectElement; + + select.value = 'SITE001'; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + fixture.detectChanges(); + + expect(getAlerts).toHaveBeenLastCalledWith({ site_id: 'SITE001' }); + expect(listeEnfant(fixture).siteId()).toBe('SITE001'); + }); + + it('cache le bouton de génération aux lecteurs', () => { + const { fixture } = setup({ role: 'lecteur' }); + + expect(fixture.nativeElement.querySelector('[data-testid="generate"]')).toBeNull(); + }); + + it('permet à un admin de générer pour le site filtré, affiche le bilan et recharge la liste', () => { + const { fixture, generate, getRecommendations } = setup({ + role: 'admin', + query: { site: 'SITE001' }, + }); + + fixture.nativeElement.querySelector('[data-testid="generate"]').click(); + fixture.detectChanges(); + fixture.detectChanges(); + + expect(generate).toHaveBeenCalledWith('SITE001'); + expect(fixture.nativeElement.textContent).toContain( + '3 recommandations créées, 1 déjà présente, 2 alertes examinées.', + ); + expect(getRecommendations).toHaveBeenCalledTimes(2); + expect(fixture.componentInstance.generating()).toBe(false); + }); + + it('génère pour tout le parc quand aucun site n’est filtré', () => { + const { fixture, generate } = setup({ role: 'admin' }); + + fixture.componentInstance.onGenerate(); + + expect(generate).toHaveBeenCalledWith(undefined); + }); + + it("signale l'échec de la génération sans casser la page", () => { + const { fixture, generate } = setup({ role: 'admin' }); + generate.mockReturnValue(throwError(() => new Error('403'))); + + fixture.componentInstance.onGenerate(); + fixture.detectChanges(); + + expect(fixture.componentInstance.generationError()).not.toBeNull(); + expect(fixture.nativeElement.textContent).toContain( + 'La génération des recommandations a échoué', + ); + expect(fixture.componentInstance.generating()).toBe(false); + }); + + it('accorde le bilan au singulier', () => { + const { fixture } = setup(); + + expect( + fixture.componentInstance.bilan({ + alerts_examined: 1, + recommendations_created: 1, + already_present: 0, + }), + ).toBe('1 recommandation créée, 0 déjà présente, 1 alerte examinée'); + }); +}); diff --git a/apps/frontend/src/app/features/recommendations/recommendations.ts b/apps/frontend/src/app/features/recommendations/recommendations.ts new file mode 100644 index 0000000..985c5af --- /dev/null +++ b/apps/frontend/src/app/features/recommendations/recommendations.ts @@ -0,0 +1,85 @@ +import { Component, computed, inject, signal, viewChild } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { catchError, map, of } from 'rxjs'; +import { SitesService } from '../../core/services/sites.service'; +import { RecommendationsService } from '../../core/services/recommendations.service'; +import { AuthService } from '../../core/services/auth.service'; +import { Site } from '../../shared/models/site.model'; +import { RecommendationGenerationReport } from '../../shared/models/recommendation.model'; +import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list'; +import { Alert as EvAlert } from '../../shared/components/ui/alert/alert'; +import { Brand } from '../../shared/components/ui/brand/brand'; +import { Button } from '../../shared/components/ui/button/button'; + +const GENERATION_FAILED_MESSAGE = + 'La génération des recommandations a échoué, réessayez plus tard.'; + +export function parseAlertId(raw: string | null): number | null { + return raw !== null && /^[1-9]\d*$/.test(raw) ? Number(raw) : null; +} + +function pluriel(nombre: number, singulier: string, plurielForme: string): string { + return `${nombre} ${nombre > 1 ? plurielForme : singulier}`; +} + +@Component({ + selector: 'app-recommendations', + standalone: true, + imports: [RouterLink, RecommendationList, EvAlert, Brand, Button], + templateUrl: './recommendations.html', + styleUrl: './recommendations.scss', +}) +export class RecommendationsView { + private route = inject(ActivatedRoute); + private sitesService = inject(SitesService); + private recommendationsService = inject(RecommendationsService); + private auth = inject(AuthService); + + alertId = toSignal( + this.route.queryParamMap.pipe(map((params) => parseAlertId(params.get('alert')))), + { initialValue: null }, + ); + siteFilter = signal(this.route.snapshot.queryParamMap.get('site')); + sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), { + initialValue: [] as Site[], + }); + + list = viewChild.required(RecommendationList); + + isAdmin = computed(() => this.auth.principal()?.role === 'admin'); + generating = signal(false); + generationReport = signal(null); + generationError = signal(null); + + onSiteChange(event: Event): void { + this.siteFilter.set((event.target as HTMLSelectElement).value || null); + } + + onGenerate(): void { + if (this.generating()) { + return; + } + this.generating.set(true); + this.generationError.set(null); + this.recommendationsService.generate(this.siteFilter() ?? undefined).subscribe({ + next: (report) => { + this.generating.set(false); + this.generationReport.set(report); + this.list().reload(); + }, + error: () => { + this.generating.set(false); + this.generationError.set(GENERATION_FAILED_MESSAGE); + }, + }); + } + + bilan(report: RecommendationGenerationReport): string { + return [ + pluriel(report.recommendations_created, 'recommandation créée', 'recommandations créées'), + pluriel(report.already_present, 'déjà présente', 'déjà présentes'), + pluriel(report.alerts_examined, 'alerte examinée', 'alertes examinées'), + ].join(', '); + } +}