Merge pull request #95 from ineszang/feat/site-list-view
feat(frontend): vue liste des sites
This commit is contained in:
@@ -12,4 +12,17 @@ export const routes: Routes = [
|
|||||||
canActivate: [authGuard],
|
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),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sites/:siteId',
|
||||||
|
canActivate: [authGuard],
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/sites/site-detail-placeholder/site-detail-placeholder').then(
|
||||||
|
(m) => m.SiteDetailPlaceholder,
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { SitesService } from './sites.service';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
describe('SitesService', () => {
|
||||||
|
let service: SitesService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||||
|
});
|
||||||
|
service = TestBed.inject(SitesService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
it('appelle le bon endpoint et retourne la liste des sites', () => {
|
||||||
|
let result: unknown;
|
||||||
|
service.getSites().subscribe((r) => (result = r));
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(`${environment.apiUrl}/sites`);
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
|
||||||
|
req.flush([
|
||||||
|
{
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Site 1',
|
||||||
|
site_type: 'industriel',
|
||||||
|
location: 'Nantes',
|
||||||
|
capacity_kw: 500,
|
||||||
|
status: 'actif',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect((result as { site_id: string }[])[0].site_id).toBe('SITE001');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Service, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { Site } from '../../shared/models/site.model';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class SitesService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
getSites() {
|
||||||
|
return this.http.get<Site[]>(`${environment.apiUrl}/sites`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,24 @@
|
|||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
<header class="dashboard__header">
|
<header class="dashboard__header">
|
||||||
<div class="dashboard__brand">
|
<div class="dashboard__brand">
|
||||||
<ev-brand class="dashboard__logo" />
|
<a routerLink="/dashboard" class="ev-brand-link">
|
||||||
|
<ev-brand class="dashboard__logo" />
|
||||||
|
</a>
|
||||||
<div>
|
<div>
|
||||||
<h1>Vue d'ensemble</h1>
|
<h1>Vue d'ensemble</h1>
|
||||||
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
|
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ev-button class="logout-button" variant="secondary" [fullWidth]="false" (click)="onLogout()"
|
<div class="dashboard__actions">
|
||||||
>Déconnexion</ev-button
|
<a routerLink="/sites" class="ev-link">Voir les sites</a>
|
||||||
>
|
<ev-button
|
||||||
|
class="logout-button"
|
||||||
|
variant="secondary"
|
||||||
|
[fullWidth]="false"
|
||||||
|
(click)="onLogout()"
|
||||||
|
>Déconnexion</ev-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@if (error(); as message) {
|
@if (error(); as message) {
|
||||||
|
|||||||
@@ -34,6 +34,12 @@
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Dashboard } from './dashboard';
|
|||||||
import { StatsService } from '../../core/services/stats.service';
|
import { StatsService } from '../../core/services/stats.service';
|
||||||
import { AlertsService } from '../../core/services/alerts.service';
|
import { AlertsService } from '../../core/services/alerts.service';
|
||||||
import {AuthService} from '../../core/services/auth.service';
|
import {AuthService} from '../../core/services/auth.service';
|
||||||
import {Router} from '@angular/router';
|
import {Router, provideRouter} from '@angular/router';
|
||||||
|
|
||||||
vi.mock('chart.js', () => {
|
vi.mock('chart.js', () => {
|
||||||
class ChartMock {
|
class ChartMock {
|
||||||
@@ -29,6 +29,7 @@ describe('Dashboard', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
{ provide: StatsService, useValue: statsMock },
|
{ provide: StatsService, useValue: statsMock },
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
{ provide: AlertsService, useValue: alertsMock },
|
||||||
|
provideRouter([]),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ describe('Dashboard', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
{ provide: StatsService, useValue: statsMock },
|
{ provide: StatsService, useValue: statsMock },
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
{ provide: AlertsService, useValue: alertsMock },
|
||||||
|
provideRouter([]),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,6 +88,7 @@ describe('Dashboard', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
{ provide: StatsService, useValue: statsMock },
|
{ provide: StatsService, useValue: statsMock },
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
{ provide: AlertsService, useValue: alertsMock },
|
||||||
|
provideRouter([]),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,7 +102,6 @@ describe('Dashboard', () => {
|
|||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||||
const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() };
|
const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() };
|
||||||
const routerMock = { navigate: vi.fn() };
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [Dashboard],
|
imports: [Dashboard],
|
||||||
@@ -107,18 +109,21 @@ describe('Dashboard', () => {
|
|||||||
{ provide: StatsService, useValue: statsMock },
|
{ provide: StatsService, useValue: statsMock },
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
{ provide: AlertsService, useValue: alertsMock },
|
||||||
{ provide: AuthService, useValue: authMock },
|
{ provide: AuthService, useValue: authMock },
|
||||||
{ provide: Router, useValue: routerMock },
|
provideRouter([]),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
const fixture = TestBed.createComponent(Dashboard);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||||
|
|
||||||
const button = fixture.nativeElement.querySelector('.logout-button');
|
const button = fixture.nativeElement.querySelector('.logout-button');
|
||||||
button.click();
|
button.click();
|
||||||
|
|
||||||
expect(authMock.logout).toHaveBeenCalled();
|
expect(authMock.logout).toHaveBeenCalled();
|
||||||
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
|
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||||
});
|
});
|
||||||
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
|
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||||
@@ -127,26 +132,27 @@ describe('Dashboard', () => {
|
|||||||
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
|
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
|
||||||
clearSession: vi.fn(),
|
clearSession: vi.fn(),
|
||||||
};
|
};
|
||||||
const routerMock = { navigate: vi.fn() };
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [Dashboard],
|
imports: [Dashboard],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: StatsService, useValue: statsMock },
|
{ provide: StatsService, useValue: statsMock },
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
{ provide: AlertsService, useValue: alertsMock },
|
||||||
{ provide: AuthService, useValue: authMock },
|
{ provide: AuthService, useValue: authMock },
|
||||||
{ provide: Router, useValue: routerMock },
|
provideRouter([]),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
const fixture = TestBed.createComponent(Dashboard);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||||
|
|
||||||
const button = fixture.nativeElement.querySelector('.logout-button');
|
const button = fixture.nativeElement.querySelector('.logout-button');
|
||||||
button.click();
|
button.click();
|
||||||
|
|
||||||
expect(authMock.clearSession).toHaveBeenCalled();
|
expect(authMock.clearSession).toHaveBeenCalled();
|
||||||
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
|
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('distingue le ton des sévérités high et critical', () => {
|
it('distingue le ton des sévérités high et critical', () => {
|
||||||
@@ -158,6 +164,7 @@ describe('Dashboard', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
{ provide: StatsService, useValue: statsMock },
|
{ provide: StatsService, useValue: statsMock },
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
{ provide: AlertsService, useValue: alertsMock },
|
||||||
|
provideRouter([]),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core';
|
|||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs';
|
import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs';
|
||||||
import { DecimalPipe } from '@angular/common';
|
import { DecimalPipe } from '@angular/common';
|
||||||
import { Router } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import { StatsService } from '../../core/services/stats.service';
|
import { StatsService } from '../../core/services/stats.service';
|
||||||
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
|
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
|
||||||
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
|
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
|
||||||
@@ -30,7 +30,17 @@ const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-dashboard',
|
selector: 'app-dashboard',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart, Card, EvAlert, Badge, Brand, Button],
|
imports: [
|
||||||
|
DecimalPipe,
|
||||||
|
RouterLink,
|
||||||
|
ConsumptionGauge,
|
||||||
|
SiteLoadChart,
|
||||||
|
Card,
|
||||||
|
EvAlert,
|
||||||
|
Badge,
|
||||||
|
Brand,
|
||||||
|
Button,
|
||||||
|
],
|
||||||
templateUrl: './dashboard.html',
|
templateUrl: './dashboard.html',
|
||||||
styleUrl: './dashboard.scss',
|
styleUrl: './dashboard.scss',
|
||||||
})
|
})
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
<div class="site-detail-placeholder">
|
||||||
|
<nav class="ev-breadcrumb">
|
||||||
|
<a routerLink="/dashboard">Tableau de bord</a>
|
||||||
|
<span>/</span>
|
||||||
|
<a routerLink="/sites">Sites</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="site-detail-placeholder__header">
|
||||||
|
<a routerLink="/dashboard" class="ev-brand-link">
|
||||||
|
<ev-brand class="site-detail-placeholder__logo" />
|
||||||
|
</a>
|
||||||
|
<h1>Site {{ siteId() }}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ev-card>
|
||||||
|
<p>Le détail de ce site est à venir (voir issue #51).</p>
|
||||||
|
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
|
||||||
|
</ev-card>
|
||||||
|
</div>
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-detail-placeholder__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-detail-placeholder__logo {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ev-card p {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
import { SiteDetailPlaceholder } from './site-detail-placeholder';
|
||||||
|
|
||||||
|
describe('SiteDetailPlaceholder', () => {
|
||||||
|
it("affiche l'identifiant du site depuis la route", () => {
|
||||||
|
const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' }));
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [SiteDetailPlaceholder],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: ActivatedRoute, useValue: { paramMap } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SiteDetailPlaceholder);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('SITE001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('met à jour l\'affichage quand le paramètre change sans recréer le composant', () => {
|
||||||
|
const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' }));
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [SiteDetailPlaceholder],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: ActivatedRoute, useValue: { paramMap } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SiteDetailPlaceholder);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
paramMap.next(convertToParamMap({ siteId: 'SITE002' }));
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('SITE002');
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('SITE001');
|
||||||
|
});
|
||||||
|
});
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import { map } from 'rxjs';
|
||||||
|
import { Card } from '../../../shared/components/ui/card/card';
|
||||||
|
import { Brand } from '../../../shared/components/ui/brand/brand';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-site-detail-placeholder',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, Card, Brand],
|
||||||
|
templateUrl: './site-detail-placeholder.html',
|
||||||
|
styleUrl: './site-detail-placeholder.scss',
|
||||||
|
})
|
||||||
|
export class SiteDetailPlaceholder {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
|
||||||
|
siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId'))));
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<div class="site-list">
|
||||||
|
<nav class="ev-breadcrumb">
|
||||||
|
<a routerLink="/dashboard">Tableau de bord</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="site-list__header">
|
||||||
|
<a routerLink="/dashboard" class="ev-brand-link">
|
||||||
|
<ev-brand class="site-list__logo" />
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<h1>Sites</h1>
|
||||||
|
<p class="site-list__subtitle">Vue d'ensemble du parc suivi</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (error(); as message) {
|
||||||
|
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ev-card class="table-card">
|
||||||
|
<table class="sites-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Localisation</th>
|
||||||
|
<th>Capacité (kW)</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (site of sites(); track site.site_id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ site.site_name }}</td>
|
||||||
|
<td>{{ site.site_type }}</td>
|
||||||
|
<td>{{ site.location || '-' }}</td>
|
||||||
|
<td>{{ site.capacity_kw ?? '-' }}</td>
|
||||||
|
<td><ev-badge [tone]="badgeToneForStatus(site.status)">{{ site.status ?? '-' }}</ev-badge></td>
|
||||||
|
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</ev-card>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-list__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-list__logo {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-list__subtitle {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-error {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import { SiteList } from './site-list';
|
||||||
|
import { SitesService } from '../../../core/services/sites.service';
|
||||||
|
|
||||||
|
describe('SiteList', () => {
|
||||||
|
it('charge et affiche les sites au démarrage', () => {
|
||||||
|
const sitesMock = {
|
||||||
|
getSites: vi.fn().mockReturnValue(
|
||||||
|
of([
|
||||||
|
{
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Site 1',
|
||||||
|
site_type: 'industriel',
|
||||||
|
location: 'Nantes',
|
||||||
|
capacity_kw: 500,
|
||||||
|
status: 'actif',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [SiteList],
|
||||||
|
providers: [{ provide: SitesService, useValue: sitesMock }, provideRouter([])],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SiteList);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(sitesMock.getSites).toHaveBeenCalled();
|
||||||
|
expect(fixture.componentInstance.sites().length).toBe(1);
|
||||||
|
expect(fixture.componentInstance.error()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signale l'indisponibilité quand le chargement échoue", () => {
|
||||||
|
const sitesMock = { getSites: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) };
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [SiteList],
|
||||||
|
providers: [{ provide: SitesService, useValue: sitesMock }, provideRouter([])],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SiteList);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.error()).not.toBeNull();
|
||||||
|
expect(fixture.componentInstance.sites().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('affiche un tiret pour les champs nullables', () => {
|
||||||
|
const sitesMock = {
|
||||||
|
getSites: vi.fn().mockReturnValue(
|
||||||
|
of([
|
||||||
|
{
|
||||||
|
site_id: 'SITE002',
|
||||||
|
site_name: 'Site 2',
|
||||||
|
site_type: 'bureau',
|
||||||
|
location: null,
|
||||||
|
capacity_kw: null,
|
||||||
|
status: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [SiteList],
|
||||||
|
providers: [{ provide: SitesService, useValue: sitesMock }, provideRouter([])],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SiteList);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const cells = fixture.nativeElement.querySelectorAll('td');
|
||||||
|
expect(cells[2].textContent.trim()).toBe('-');
|
||||||
|
expect(cells[3].textContent.trim()).toBe('-');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { catchError, EMPTY, Observable } from 'rxjs';
|
||||||
|
import { SitesService } from '../../../core/services/sites.service';
|
||||||
|
import { Site } from '../../../shared/models/site.model';
|
||||||
|
import { Card } from '../../../shared/components/ui/card/card';
|
||||||
|
import { Alert } from '../../../shared/components/ui/alert/alert';
|
||||||
|
import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge';
|
||||||
|
import { Brand } from '../../../shared/components/ui/brand/brand';
|
||||||
|
|
||||||
|
const UNAVAILABLE_MESSAGE = 'Liste des sites indisponible, réessayez plus tard.';
|
||||||
|
|
||||||
|
const TON_PAR_STATUT: Record<string, BadgeTone> = {
|
||||||
|
actif: 'success',
|
||||||
|
maintenance: 'warning',
|
||||||
|
hors_service: 'danger',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-site-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, Card, Alert, Badge, Brand],
|
||||||
|
templateUrl: './site-list.html',
|
||||||
|
styleUrl: './site-list.scss',
|
||||||
|
})
|
||||||
|
export class SiteList implements OnInit {
|
||||||
|
private sitesService = inject(SitesService);
|
||||||
|
|
||||||
|
sites = signal<Site[]>([]);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.sitesService
|
||||||
|
.getSites()
|
||||||
|
.pipe(catchError(() => this.reportUnavailable()))
|
||||||
|
.subscribe((sites) => this.sites.set(sites));
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeToneForStatus(status: string | null): BadgeTone {
|
||||||
|
return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportUnavailable(): Observable<never> {
|
||||||
|
this.error.set(UNAVAILABLE_MESSAGE);
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface Site {
|
||||||
|
site_id: string;
|
||||||
|
site_name: string;
|
||||||
|
site_type: string;
|
||||||
|
location: string | null;
|
||||||
|
capacity_kw: number | null;
|
||||||
|
status: string | null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
@use 'styles/tokens';
|
@use 'styles/tokens';
|
||||||
@use 'styles/forms';
|
@use 'styles/forms';
|
||||||
@use 'styles/auth-page';
|
@use 'styles/auth-page';
|
||||||
|
@use 'styles/links';
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
.ev-link {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ev-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ev-brand-link {
|
||||||
|
display: inline-flex;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
@@ -36,6 +36,13 @@ le fond dégradé et le centrage commun aux pages d'authentification (`login`, `
|
|||||||
et à terme `forgot-password`/`reset-password`) : elle enveloppe la carte, pas de duplication du
|
et à terme `forgot-password`/`reset-password`) : elle enveloppe la carte, pas de duplication du
|
||||||
fond par page.
|
fond par page.
|
||||||
|
|
||||||
|
Les classes de navigation partagées (`.ev-link`, `.ev-breadcrumb`, `.ev-brand-link`) sont dans
|
||||||
|
`apps/frontend/src/styles/_links.scss`, importées globalement. Convention pour toute page de la
|
||||||
|
zone authentifiée (derrière `authGuard`) : le logo (`<ev-brand>`) est enveloppé dans
|
||||||
|
`<a routerLink="/dashboard" class="ev-brand-link">` pour ramener au tableau de bord en un clic,
|
||||||
|
et un `<nav class="ev-breadcrumb">` liste le chemin de retour vers les pages parentes quand la
|
||||||
|
page n'est pas à la racine (voir `site-list`/`site-detail-placeholder` pour l'exemple).
|
||||||
|
|
||||||
## Composants partagés
|
## Composants partagés
|
||||||
|
|
||||||
Dans `apps/frontend/src/app/shared/components/ui/`, chacun standalone, à importer directement
|
Dans `apps/frontend/src/app/shared/components/ui/`, chacun standalone, à importer directement
|
||||||
|
|||||||
Reference in New Issue
Block a user