diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 3d52c11..78e7fca 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -32,69 +32,115 @@ } @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 (stats(); as s) { +
+

Charge par site

+ + + +
+ } - @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 - } -
  • +
    +

    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.

    } -
-
- } +
+ + + + diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index af0dd39..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,97 +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-section { - margin-bottom: 2.5rem; +.chart-card { + padding: var(--space-3); } -.predictions-list { - list-style: none; +.dashboard__empty { 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 a3c52d7..e8a696e 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -161,6 +161,71 @@ describe('Dashboard', () => { expect(navigateSpy).toHaveBeenCalledWith(['/login']); }); + 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: [], + }), + }); + + 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.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 { fixture } = setup(); const dashboard = fixture.componentInstance; diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index 3d0b422..2e345ab 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -30,6 +30,11 @@ const TON_PAR_STATUT_PREDICTION: Record = { error: 'danger', }; +const SEUIL_CHARGE_SOUTENUE = 70; +const SEUIL_CHARGE_CRITIQUE = 90; + +export type LoadTone = 'success' | 'warning' | 'danger'; + @Component({ selector: 'app-dashboard', standalone: true, @@ -97,6 +102,24 @@ export class Dashboard implements OnInit { 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/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/_tables.scss b/apps/frontend/src/styles/_tables.scss new file mode 100644 index 0000000..fb350e9 --- /dev/null +++ b/apps/frontend/src/styles/_tables.scss @@ -0,0 +1,41 @@ +.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); +}
Nom{{ site.site_type }} {{ site.location || '-' }} {{ site.capacity_kw ?? '-' }}{{ site.status ?? '-' }} + {{ site.status ?? '-' }} + Détail