diff --git a/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.html b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.html
new file mode 100644
index 0000000..d844bdc
--- /dev/null
+++ b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.html
@@ -0,0 +1,48 @@
+@if (error(); as message) {
+ {{ message }}
+} @else if (loading() && !hasData()) {
+
Chargement des recommandations…
+} @else if (visibleGroups().length === 0) {
+ {{ emptyMessage() }}
+}
+
+
+ @for (group of visibleGroups(); track group.alert.alert_id) {
+
+
+
+ @for (reco of group.recommendations; track reco.recommendation_id) {
+ -
+
+ {{ reco.action }}
+ {{
+ ruleLabel(reco.rule_reference)
+ }}
+
+ {{ reco.explanation }}
+
+ }
+
+
+ }
+
diff --git a/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.scss b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.scss
new file mode 100644
index 0000000..9456376
--- /dev/null
+++ b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.scss
@@ -0,0 +1,94 @@
+:host {
+ display: block;
+}
+
+.reco-list__banner {
+ display: block;
+ margin-bottom: var(--space-3);
+}
+
+.reco-list__state {
+ margin: 0 0 var(--space-3);
+ font-size: 0.9rem;
+ color: var(--color-text-muted);
+}
+
+.reco-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.reco-group {
+ padding: var(--space-4);
+ gap: var(--space-3);
+}
+
+.reco-group--focus {
+ border-color: var(--color-primary);
+ box-shadow: 0 0 0 3px var(--color-primary-light);
+}
+
+.reco-group__alert {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ padding-bottom: var(--space-3);
+ border-bottom: 1px solid var(--color-border-light);
+}
+
+.reco-group__meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: 0.8rem;
+ color: var(--color-text-muted);
+}
+
+.reco-group__type {
+ font-weight: 600;
+ color: var(--color-text);
+}
+
+.reco-group__message {
+ margin: 0;
+ font-size: 0.9rem;
+}
+
+.reco-group__items {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.reco {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-sm);
+ background: var(--color-bg);
+ border-left: 3px solid var(--color-primary);
+}
+
+.reco__head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+}
+
+.reco__action {
+ font-size: 0.95rem;
+}
+
+.reco__explanation {
+ margin: 0;
+ font-size: 0.85rem;
+ color: var(--color-text-muted);
+}
diff --git a/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.spec.ts b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.spec.ts
new file mode 100644
index 0000000..1f3ae01
--- /dev/null
+++ b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.spec.ts
@@ -0,0 +1,244 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { provideRouter } from '@angular/router';
+import { vi } from 'vitest';
+import { NEVER, of, throwError } from 'rxjs';
+import { RecommendationList, joinByAlert } from './recommendation-list';
+import { AlertsService } from '../../../core/services/alerts.service';
+import { RecommendationsService } from '../../../core/services/recommendations.service';
+import { Alert } from '../../models/alert.model';
+import { Recommendation } from '../../models/recommendation.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',
+ },
+ {
+ site_id: 'SITE002',
+ site_name: 'Bureau Lille',
+ site_type: 'bureau',
+ location: 'Lille',
+ capacity_kw: 80,
+ status: 'actif',
+ },
+];
+
+function alerte(surcharges: Partial): Alert {
+ return {
+ alert_id: 1,
+ site_id: 'SITE001',
+ timestamp: '2026-09-15T09:00:00Z',
+ type: 'threshold',
+ severity: 'high',
+ message: 'Puissance appelée au-dessus de la capacité du site',
+ value: 812.5,
+ threshold: 720,
+ metric: 'consumption_kw',
+ prediction_id: null,
+ ...surcharges,
+ };
+}
+
+function reco(surcharges: Partial): Recommendation {
+ return {
+ recommendation_id: 1,
+ alert_id: 1,
+ action: 'Ramener la puissance appelée sous le seuil contractuel',
+ explanation: 'Seuil de consommation dépassé sur le site SITE001.',
+ rule_reference: 'threshold-reduction-v1',
+ created_at: '2026-09-15T09:05:00Z',
+ ...surcharges,
+ };
+}
+
+const ALERTES: Alert[] = [
+ alerte({ alert_id: 1, site_id: 'SITE001', timestamp: '2026-09-15T09:00:00Z' }),
+ alerte({
+ alert_id: 2,
+ site_id: 'SITE002',
+ timestamp: '2026-09-15T11:00:00Z',
+ severity: 'critical',
+ type: 'spike',
+ message: 'Variation brutale entre deux lectures consécutives',
+ }),
+ alerte({ alert_id: 3, site_id: 'SITE001', timestamp: '2026-09-15T10:00:00Z', severity: 'low' }),
+];
+
+const RECOMMANDATIONS: Recommendation[] = [
+ reco({
+ recommendation_id: 3,
+ alert_id: 2,
+ action: "Escalader à l'astreinte sous une heure",
+ rule_reference: 'escalade-astreinte-v1',
+ }),
+ reco({ recommendation_id: 1, alert_id: 1 }),
+ reco({
+ recommendation_id: 2,
+ alert_id: 2,
+ action: 'Délester les équipements non prioritaires sur le créneau du pic',
+ rule_reference: 'spike-delestage-v1',
+ }),
+ reco({ recommendation_id: 4, alert_id: 99, rule_reference: 'orpheline-v1' }),
+];
+
+function setup(
+ alertsMock: { getAlerts: ReturnType },
+ recosMock: { getRecommendations: ReturnType },
+ inputs: Record = {},
+) {
+ TestBed.configureTestingModule({
+ imports: [RecommendationList],
+ providers: [
+ provideRouter([]),
+ { provide: AlertsService, useValue: alertsMock },
+ { provide: RecommendationsService, useValue: recosMock },
+ ],
+ });
+ const fixture = TestBed.createComponent(RecommendationList);
+ for (const [nom, valeur] of Object.entries(inputs)) {
+ fixture.componentRef.setInput(nom, valeur);
+ }
+ return fixture;
+}
+
+function rendre(fixture: ComponentFixture) {
+ fixture.detectChanges();
+ fixture.detectChanges();
+}
+
+function texte(fixture: ComponentFixture): string {
+ return (fixture.nativeElement as HTMLElement).textContent ?? '';
+}
+
+const recosOk = () => ({ getRecommendations: vi.fn().mockReturnValue(of(RECOMMANDATIONS)) });
+
+describe('joinByAlert', () => {
+ it('groupe par alerte, du plus récent au plus ancien, recommandations par identifiant', () => {
+ const groupes = joinByAlert(ALERTES, RECOMMANDATIONS, new Map([['SITE001', 'Usine Nantes']]));
+
+ expect(groupes.map((g) => g.alert.alert_id)).toEqual([2, 1]);
+ expect(groupes[0].recommendations.map((r) => r.recommendation_id)).toEqual([2, 3]);
+ expect(groupes[1].siteName).toBe('Usine Nantes');
+ expect(groupes[0].siteName).toBe('SITE002');
+ });
+
+ it('ignore les alertes sans recommandation et les recommandations orphelines', () => {
+ const groupes = joinByAlert(ALERTES, RECOMMANDATIONS, new Map());
+
+ expect(groupes.some((g) => g.alert.alert_id === 3)).toBe(false);
+ expect(groupes.flatMap((g) => g.recommendations).some((r) => r.alert_id === 99)).toBe(false);
+ });
+});
+
+describe('RecommendationList', () => {
+ it('charge alertes et recommandations puis affiche les groupes avec leur contexte', () => {
+ const getAlerts = vi.fn().mockReturnValue(of(ALERTES));
+ const fixture = setup({ getAlerts }, recosOk(), { sites: SITES });
+
+ rendre(fixture);
+
+ expect(getAlerts).toHaveBeenCalledWith({});
+ expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(2);
+ const contenu = texte(fixture);
+ expect(contenu).toContain('Usine Nantes');
+ expect(contenu).toContain('Bureau Lille');
+ expect(contenu).toContain('Critique');
+ expect(contenu).toContain('Pic de consommation');
+ expect(contenu).toContain('Escalade astreinte');
+ expect(contenu).toContain('Délester les équipements');
+ expect(contenu).toContain('15/09/2026');
+ expect(fixture.nativeElement.querySelector('a[href="/sites/SITE002"]')).not.toBeNull();
+ expect(fixture.componentInstance.total()).toBe(3);
+ expect(fixture.componentInstance.error()).toBeNull();
+ });
+
+ it('filtre les alertes du site côté API et masque le lien vers le site', () => {
+ const getAlerts = vi.fn().mockReturnValue(of(ALERTES.filter((a) => a.site_id === 'SITE001')));
+ const fixture = setup({ getAlerts }, recosOk(), { siteId: 'SITE001', sites: SITES });
+
+ rendre(fixture);
+
+ expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
+ expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(1);
+ expect(fixture.nativeElement.querySelector('a[href^="/sites/"]')).toBeNull();
+ });
+
+ it("ne garde que le groupe de l'alerte ciblée et le met en évidence", () => {
+ const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) }, recosOk(), {
+ alertId: 2,
+ });
+
+ rendre(fixture);
+
+ const groupes = fixture.nativeElement.querySelectorAll('.reco-group');
+ expect(groupes.length).toBe(1);
+ expect(groupes[0].classList.contains('reco-group--focus')).toBe(true);
+ expect(groupes[0].id).toBe('alerte-2');
+ });
+
+ it("annonce l'absence de recommandation pour une alerte inconnue", () => {
+ const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) }, recosOk(), {
+ alertId: 123,
+ });
+
+ rendre(fixture);
+
+ expect(texte(fixture)).toContain('Aucune recommandation pour cette alerte.');
+ });
+
+ it("annonce l'absence de recommandation pour le site consulté", () => {
+ const fixture = setup(
+ { getAlerts: vi.fn().mockReturnValue(of([])) },
+ { getRecommendations: vi.fn().mockReturnValue(of([])) },
+ { siteId: 'SITE001' },
+ );
+
+ rendre(fixture);
+
+ expect(texte(fixture)).toContain('Aucune recommandation pour ce site.');
+ });
+
+ it("signale l'indisponibilité et n'affiche aucun groupe si un des deux appels échoue", () => {
+ const fixture = setup(
+ { getAlerts: vi.fn().mockReturnValue(of(ALERTES)) },
+ { getRecommendations: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
+ );
+
+ rendre(fixture);
+
+ expect(fixture.componentInstance.error()).not.toBeNull();
+ expect(fixture.componentInstance.groups()).toEqual([]);
+ expect(texte(fixture)).toContain('Recommandations indisponibles');
+ expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(0);
+ });
+
+ it('annonce le chargement tant que la réponse ne vient pas', () => {
+ const fixture = setup(
+ { getAlerts: vi.fn().mockReturnValue(NEVER) },
+ { getRecommendations: vi.fn().mockReturnValue(NEVER) },
+ );
+
+ rendre(fixture);
+
+ expect(fixture.componentInstance.loading()).toBe(true);
+ expect(texte(fixture)).toContain('Chargement des recommandations');
+ });
+
+ it('recharge les deux flux à la demande', () => {
+ const getAlerts = vi.fn().mockReturnValue(of(ALERTES));
+ const recos = recosOk();
+ const fixture = setup({ getAlerts }, recos);
+ rendre(fixture);
+
+ fixture.componentInstance.reload();
+ rendre(fixture);
+
+ expect(getAlerts).toHaveBeenCalledTimes(2);
+ expect(recos.getRecommendations).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.ts b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.ts
new file mode 100644
index 0000000..05b3829
--- /dev/null
+++ b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.ts
@@ -0,0 +1,163 @@
+import { Component, DestroyRef, computed, inject, input, signal } from '@angular/core';
+import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
+import { DatePipe } from '@angular/common';
+import { RouterLink } from '@angular/router';
+import { catchError, EMPTY, forkJoin, Observable, switchMap, tap } from 'rxjs';
+import { AlertsService } from '../../../core/services/alerts.service';
+import { RecommendationsService } from '../../../core/services/recommendations.service';
+import { Alert, AlertSeverity, AlertType } from '../../models/alert.model';
+import { Recommendation } from '../../models/recommendation.model';
+import { Site } from '../../models/site.model';
+import {
+ LIBELLE_PAR_SEVERITE,
+ LIBELLE_PAR_TYPE,
+ TON_PAR_SEVERITE,
+} from '../../models/alert-presentation';
+import { libelleRegle, tonRegle } from '../../models/recommendation-presentation';
+import { Card } from '../ui/card/card';
+import { Badge, BadgeTone } from '../ui/badge/badge';
+import { Alert as EvAlert } from '../ui/alert/alert';
+
+const UNAVAILABLE_MESSAGE = 'Recommandations indisponibles, réessayez plus tard.';
+
+export interface RecommendedAlertView {
+ alert: Alert;
+ siteName: string;
+ recommendations: Recommendation[];
+}
+
+interface Chargement {
+ alerts: Alert[];
+ recommendations: Recommendation[];
+}
+
+// Pourquoi : une recommandation ne porte que alert_id, jamais site_id, et /recommendations n'a
+// aucun filtre ; la jointure se fait ici, en O(alertes), acceptable à la taille du jeu de données.
+export function joinByAlert(
+ alerts: Alert[],
+ recommendations: Recommendation[],
+ siteNames: Map,
+): RecommendedAlertView[] {
+ const parAlerte = new Map();
+ for (const recommandation of recommendations) {
+ const liste = parAlerte.get(recommandation.alert_id) ?? [];
+ liste.push(recommandation);
+ parAlerte.set(recommandation.alert_id, liste);
+ }
+ return alerts
+ .filter((alert) => parAlerte.has(alert.alert_id))
+ .map((alert) => ({
+ alert,
+ siteName: siteNames.get(alert.site_id) ?? alert.site_id,
+ recommendations: [...(parAlerte.get(alert.alert_id) ?? [])].sort(
+ (a, b) => a.recommendation_id - b.recommendation_id,
+ ),
+ }))
+ .sort((a, b) => Date.parse(b.alert.timestamp) - Date.parse(a.alert.timestamp));
+}
+
+@Component({
+ selector: 'app-recommendation-list',
+ standalone: true,
+ imports: [DatePipe, RouterLink, Card, Badge, EvAlert],
+ templateUrl: './recommendation-list.html',
+ styleUrl: './recommendation-list.scss',
+})
+export class RecommendationList {
+ private alertsService = inject(AlertsService);
+ private recommendationsService = inject(RecommendationsService);
+ private destroyRef = inject(DestroyRef);
+
+ siteId = input(null);
+ alertId = input(null);
+ sites = input([]);
+
+ private data = signal(null);
+ private reloadTick = signal(0);
+ loading = signal(true);
+ error = signal(null);
+
+ private trigger = computed(() => ({ siteId: this.siteId(), tick: this.reloadTick() }));
+
+ private siteNameById = computed(
+ () => new Map(this.sites().map((site) => [site.site_id, site.site_name])),
+ );
+
+ hasData = computed(() => this.data() !== null);
+
+ groups = computed(() => {
+ const data = this.data();
+ return data ? joinByAlert(data.alerts, data.recommendations, this.siteNameById()) : [];
+ });
+
+ visibleGroups = computed(() => {
+ const alertId = this.alertId();
+ const groups = this.groups();
+ return alertId === null ? groups : groups.filter((group) => group.alert.alert_id === alertId);
+ });
+
+ total = computed(() =>
+ this.visibleGroups().reduce((somme, group) => somme + group.recommendations.length, 0),
+ );
+
+ emptyMessage = computed(() => {
+ if (this.alertId() !== null) {
+ return 'Aucune recommandation pour cette alerte.';
+ }
+ return this.siteId()
+ ? 'Aucune recommandation pour ce site.'
+ : 'Aucune recommandation pour le moment.';
+ });
+
+ constructor() {
+ toObservable(this.trigger)
+ .pipe(
+ tap(() => this.loading.set(true)),
+ switchMap(({ siteId }) =>
+ forkJoin({
+ alerts: this.alertsService.getAlerts(siteId ? { site_id: siteId } : {}),
+ recommendations: this.recommendationsService.getRecommendations(),
+ }).pipe(catchError(() => this.reportUnavailable())),
+ ),
+ takeUntilDestroyed(this.destroyRef),
+ )
+ .subscribe((data) => {
+ this.loading.set(false);
+ this.error.set(null);
+ this.data.set(data);
+ });
+ }
+
+ reload(): void {
+ this.reloadTick.update((tick) => tick + 1);
+ }
+
+ 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];
+ }
+
+ ruleLabel(reference: string): string {
+ return libelleRegle(reference);
+ }
+
+ ruleTone(reference: string): BadgeTone {
+ return tonRegle(reference);
+ }
+
+ // Piège : vider les données avec l'erreur ; une demi-jointure (alertes sans recommandations,
+ // ou l'inverse) afficherait des groupes faux plutôt que rien.
+ private reportUnavailable(): Observable {
+ this.loading.set(false);
+ this.error.set(UNAVAILABLE_MESSAGE);
+ this.data.set(null);
+ return EMPTY;
+ }
+}