feat(frontend): vue liste des sites
Nouveau SitesService (GET /sites) et page SiteList consommant le design système (ev-card, ev-badge, ev-alert, ev-brand). Ajoute la route /sites, un lien depuis le dashboard, et une route détail /sites/:siteId pointant vers un placeholder minimal en attendant l'issue #51. Closes #49
This commit is contained in:
+5
@@ -0,0 +1,5 @@
|
||||
<div class="site-detail-placeholder">
|
||||
<ev-card>
|
||||
<p>Détail du site {{ siteId }} : à venir (voir issue #51).</p>
|
||||
</ev-card>
|
||||
</div>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
:host {
|
||||
display: block;
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { SiteDetailPlaceholder } from './site-detail-placeholder';
|
||||
|
||||
describe('SiteDetailPlaceholder', () => {
|
||||
it("affiche l'identifiant du site depuis la route", () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [SiteDetailPlaceholder],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: convertToParamMap({ siteId: 'SITE001' }) } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(SiteDetailPlaceholder);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('SITE001');
|
||||
});
|
||||
});
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Card } from '../../../shared/components/ui/card/card';
|
||||
|
||||
@Component({
|
||||
selector: 'app-site-detail-placeholder',
|
||||
standalone: true,
|
||||
imports: [Card],
|
||||
templateUrl: './site-detail-placeholder.html',
|
||||
styleUrl: './site-detail-placeholder.scss',
|
||||
})
|
||||
export class SiteDetailPlaceholder {
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
siteId = this.route.snapshot.paramMap.get('siteId');
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<div class="site-list">
|
||||
<header class="site-list__header">
|
||||
<ev-brand class="site-list__logo" />
|
||||
<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="site-list__detail-link">Détail</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</ev-card>
|
||||
</div>
|
||||
@@ -0,0 +1,73 @@
|
||||
: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;
|
||||
}
|
||||
}
|
||||
|
||||
.site-list__detail-link {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user