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) { + +
+
+ {{ + severityLabel(group.alert.severity) + }} + {{ typeLabel(group.alert.type) }} + @if (!siteId()) { + {{ + group.siteName + }} + } + +
+

{{ group.alert.message }}

+
+
    + @for (reco of group.recommendations; track reco.recommendation_id) { +
  1. +
    + {{ reco.action }} + {{ + ruleLabel(reco.rule_reference) + }} +
    +

    {{ reco.explanation }}

    +
  2. + } +
+
+ } +
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; + } +}