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
+
+
+
+
+
+
+ @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(', ');
+ }
+}