Creation dashboard (graph chart.js) + tests
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
<div class="dashboard">
|
||||
<header class="dashboard__header">
|
||||
<h1>Vue d'ensemble</h1>
|
||||
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
|
||||
</header>
|
||||
|
||||
@if (stats(); as s) {
|
||||
<section class="overview">
|
||||
<div class="card card--gauge">
|
||||
<span class="card__label">Consommation vs capacité</span>
|
||||
<app-consumption-gauge [consumption]="s.total_consumption_kw" [capacity]="s.total_capacity_kw" />
|
||||
<span class="card__value">{{ s.total_consumption_kw | number:'1.0-1' }} / {{ s.total_capacity_kw | number }} kW</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="card__label">Charge moyenne du parc</span>
|
||||
<span class="card__value">{{ s.average_load_percent }} %</span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="card__label">Sites suivis</span>
|
||||
<span class="card__value">{{ s.total_sites }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chart-section">
|
||||
<h2>Charge et alerte visuelle par site</h2>
|
||||
<app-site-load-chart [sites]="s.sites" />
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (alerts().length > 0) {
|
||||
<section class="alerts-section">
|
||||
<h2>Alertes actives</h2>
|
||||
<ul class="alerts-list">
|
||||
@for (alert of alerts(); track alert.alert_id) {
|
||||
<li class="alert-item" [class]="'alert-item--' + alert.severity">
|
||||
<span class="alert-item__badge">{{ alert.severity }}</span>
|
||||
<span class="alert-item__message">{{ alert.message }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,130 @@
|
||||
:host {
|
||||
--color-good: #2e7d32;
|
||||
--color-partial: #f9a825;
|
||||
--color-degraded: #ef6c00;
|
||||
--color-critical: #c62828;
|
||||
--color-bg-card: #ffffff;
|
||||
--color-border: #e5e7eb;
|
||||
--color-text-muted: #6b7280;
|
||||
--radius: 10px;
|
||||
|
||||
display: block;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
color: #1f2937;
|
||||
padding: 2rem;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard__header {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard__subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card--gauge {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card__label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.card__value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.progress-bar__fill {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.alerts-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.alert-item__badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: var(--color-critical);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-item--high .alert-item__badge { background: var(--color-degraded); }
|
||||
.alert-item--medium .alert-item__badge { background: var(--color-partial); }
|
||||
.alert-item--low .alert-item__badge { background: var(--color-good); }
|
||||
|
||||
.alert-item__message {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { of } from 'rxjs';
|
||||
import { Dashboard } from './dashboard';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import { AlertsService } from '../../core/services/alerts.service';
|
||||
|
||||
vi.mock('chart.js', () => {
|
||||
class ChartMock {
|
||||
update = vi.fn();
|
||||
data = { datasets: [{}] };
|
||||
static register = vi.fn();
|
||||
}
|
||||
return { Chart: ChartMock, registerables: [] };
|
||||
});
|
||||
|
||||
describe('Dashboard', () => {
|
||||
it('charge les stats et les alertes au démarrage', async () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
// laisse le timer(0, ...) se déclencher avant de vérifier
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(statsMock.getSummary).toHaveBeenCalled();
|
||||
expect(alertsMock.getAlerts).toHaveBeenCalled();
|
||||
expect(fixture.componentInstance.alerts().length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { timer, switchMap } from 'rxjs';
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
|
||||
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
|
||||
import {AlertsService} from '../../core/services/alerts.service';
|
||||
import {StatsSummary} from '../../shared/models/stats.model';
|
||||
import {Alert} from '../../shared/models/alert.model';
|
||||
|
||||
const REFRESH_INTERVAL_MS = 10000;
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart],
|
||||
templateUrl: './dashboard.html',
|
||||
styleUrl: './dashboard.scss',
|
||||
})
|
||||
export class Dashboard implements OnInit {
|
||||
private statsService = inject(StatsService);
|
||||
private alertsService = inject(AlertsService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
stats = signal<StatsSummary | null>(null);
|
||||
alerts = signal<Alert[]>([]);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.alertsService.getAlerts().subscribe((alerts) => this.alerts.set(alerts));
|
||||
|
||||
timer(0, REFRESH_INTERVAL_MS)
|
||||
.pipe(
|
||||
switchMap(() => this.statsService.getSummary()),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe((stats) => this.stats.set(stats));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user