feat(frontend): design système - tokens, composants ui et restylage des pages

Centralise les couleurs/rayons/espacements dispersés en dur dans chaque page
(login, change-password, dashboard) en tokens CSS partagés, ajoute un petit
set de composants standalone réutilisables (ev-button, ev-card, ev-alert,
ev-badge) et intègre le logo EnerVision en en-tête des pages ainsi que dans
Swagger/ReDoc côté backend.

Refs #91
This commit is contained in:
Johan LEROY
2026-09-17 11:14:02 +02:00
parent 3692d486c6
commit 9c78c6dc38
35 changed files with 724 additions and 288 deletions
+48 -2
View File
@@ -1,9 +1,15 @@
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.openapi.utils import get_openapi
from fastapi.staticfiles import StaticFiles
from prometheus_fastapi_instrumentator import Instrumentator from prometheus_fastapi_instrumentator import Instrumentator
from starlette.requests import Request
from starlette.responses import HTMLResponse
from app.api.errors import register_error_handlers from app.api.errors import register_error_handlers
from app.api.middleware import SecurityHeadersMiddleware from app.api.middleware import SecurityHeadersMiddleware
@@ -18,6 +24,8 @@ logger = get_logger(__name__)
METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"]
EN_TETES_AUTORISES = ["Authorization", "Content-Type"] EN_TETES_AUTORISES = ["Authorization", "Content-Type"]
STATIC_DIR = Path(__file__).parent / "static"
LOGO_URL = "/static/logo-icon.png"
@asynccontextmanager @asynccontextmanager
@@ -43,11 +51,49 @@ def create_app(settings: Settings | None = None) -> FastAPI:
openapi_tags=TAGS, openapi_tags=TAGS,
debug=resolved.debug, debug=resolved.debug,
lifespan=lifespan, lifespan=lifespan,
docs_url="/docs" if documentee else None, docs_url=None,
redoc_url="/redoc" if documentee else None, redoc_url=None,
openapi_url="/openapi.json" if documentee else None, openapi_url="/openapi.json" if documentee else None,
) )
if documentee:
application.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# ReDoc supporte nativement `info.x-logo` (extension Redocly) pour afficher un logo
# en en-tête ; Swagger UI n'a pas d'equivalent, il ne reprend que le favicon.
def openapi_avec_logo() -> dict[str, object]:
if application.openapi_schema:
return application.openapi_schema
schema = get_openapi(
title=application.title,
version=application.version,
summary=application.summary,
description=application.description,
routes=application.routes,
tags=application.openapi_tags,
)
schema["info"]["x-logo"] = {"url": LOGO_URL, "altText": "EnerVision"}
application.openapi_schema = schema
return application.openapi_schema
application.openapi = openapi_avec_logo # type: ignore[method-assign]
@application.get("/docs", include_in_schema=False)
async def docs_swagger(_: Request) -> HTMLResponse:
return get_swagger_ui_html(
openapi_url="/openapi.json",
title=f"{application.title} · Swagger UI",
swagger_favicon_url=LOGO_URL,
)
@application.get("/redoc", include_in_schema=False)
async def docs_redoc(_: Request) -> HTMLResponse:
return get_redoc_html(
openapi_url="/openapi.json",
title=f"{application.title} · ReDoc",
redoc_favicon_url=LOGO_URL,
)
application.add_middleware(SecurityHeadersMiddleware) application.add_middleware(SecurityHeadersMiddleware)
if resolved.allowed_origins: if resolved.allowed_origins:
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+7
View File
@@ -76,6 +76,13 @@ Points à vérifier après toute regénération :
côté backend. Le `docker-compose.yml` n'a aucun service frontend. côté backend. Le `docker-compose.yml` n'a aucun service frontend.
4. Ajouter le `Dockerfile` multi-stage (build Angular puis service statique nginx). 4. Ajouter le `Dockerfile` multi-stage (build Angular puis service statique nginx).
## Design système
Tokens (couleurs, typo, espacements) et composants partagés (`ev-button`, `ev-card`,
`ev-alert`, `ev-badge`) sont documentés dans
[`docs/architecture/32-design-systeme-frontend.md`](../../docs/architecture/32-design-systeme-frontend.md).
Toute nouvelle page doit les réutiliser plutôt que définir ses propres valeurs.
## Additional Resources ## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

@@ -1,31 +1,36 @@
<div class="auth-page"> <div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()"> <form class="auth-card-wrapper" [formGroup]="form" (ngSubmit)="onSubmit()">
<ev-card>
<img src="logo.png" alt="EnerVision" class="auth-logo" />
<h1>Nouveau mot de passe</h1> <h1>Nouveau mot de passe</h1>
<p class="auth-subtitle">Votre mot de passe est provisoire, vous devez le modifier avant de continuer</p> <p class="auth-subtitle">Votre mot de passe est provisoire, vous devez le modifier avant de continuer</p>
<label for="current_password">Mot de passe actuel</label> <label class="form-label" for="current_password">Mot de passe actuel</label>
<input <input
id="current_password" id="current_password"
class="form-input"
type="password" type="password"
formControlName="current_password" formControlName="current_password"
autocomplete="current-password" autocomplete="current-password"
/> />
<label for="new_password">Nouveau mot de passe</label> <label class="form-label" for="new_password">Nouveau mot de passe</label>
<input <input
id="new_password" id="new_password"
class="form-input"
type="password" type="password"
formControlName="new_password" formControlName="new_password"
autocomplete="new-password" autocomplete="new-password"
/> />
<span class="auth-hint">12 à 128 caractères</span> <span class="form-hint">12 à 128 caractères</span>
@if (errorMessage()) { @if (errorMessage()) {
<p class="auth-error">{{ errorMessage() }}</p> <ev-alert severity="danger">{{ errorMessage() }}</ev-alert>
} }
<button type="submit" [disabled]="form.invalid || isLoading()"> <ev-button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Modification...' : 'Valider' }} {{ isLoading() ? 'Modification...' : 'Valider' }}
</button> </ev-button>
</ev-card>
</form> </form>
</div> </div>
@@ -3,86 +3,42 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; min-height: 100vh;
background: #f3f4f6; background: var(--color-bg);
font-family: 'Segoe UI', system-ui, sans-serif;
} }
.auth-card { .auth-card-wrapper {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 2.5rem;
width: 100%; width: 100%;
max-width: 360px; max-width: 360px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); }
display: flex;
flex-direction: column;
h1 { .auth-logo {
display: block;
height: 48px;
margin: 0 auto 1rem;
}
h1 {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 700; font-weight: 700;
color: #1f2937; color: var(--color-text);
} text-align: center;
}
.auth-subtitle { .auth-subtitle {
margin: 0.25rem 0 1.5rem; margin: 0.25rem 0 1.5rem;
color: #6b7280; color: var(--color-text-muted);
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.4; line-height: 1.4;
} text-align: center;
}
label { ev-alert {
font-size: 0.85rem; display: block;
font-weight: 600; margin-top: 0.75rem;
color: #374151; }
margin-bottom: 0.35rem;
margin-top: 1rem;
}
input { ev-button {
padding: 0.6rem 0.75rem; display: block;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
button {
margin-top: 1.5rem; margin-top: 1.5rem;
padding: 0.7rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
&:disabled {
background: #9ca3af;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: #2563eb;
}
}
}
.auth-hint {
font-size: 0.75rem;
color: #9ca3af;
margin-top: 0.25rem;
}
.auth-error {
margin: 0.75rem 0 0;
color: #dc2626;
font-size: 0.85rem;
} }
@@ -54,7 +54,7 @@ describe('ChangePassword', () => {
fixture.detectChanges(); // rend le bloc @if (errorMessage()) fixture.detectChanges(); // rend le bloc @if (errorMessage())
expect(component.errorMessage()).toContain('incorrect'); expect(component.errorMessage()).toContain('incorrect');
const errorEl = fixture.nativeElement.querySelector('.auth-error'); const errorEl = fixture.nativeElement.querySelector('.ev-alert');
expect(errorEl?.textContent).toContain('incorrect'); expect(errorEl?.textContent).toContain('incorrect');
}); });
@@ -64,7 +64,7 @@ describe('ChangePassword', () => {
const button = fixture.nativeElement.querySelector('button[type="submit"]'); const button = fixture.nativeElement.querySelector('button[type="submit"]');
expect(button.disabled).toBe(true); expect(button.disabled).toBe(true);
expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull(); expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
}); });
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
@@ -2,11 +2,14 @@ import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { Button } from '../../../shared/components/ui/button/button';
import { Card } from '../../../shared/components/ui/card/card';
import { Alert } from '../../../shared/components/ui/alert/alert';
@Component({ @Component({
selector: 'app-change-password', selector: 'app-change-password',
standalone: true, standalone: true,
imports: [ReactiveFormsModule], imports: [ReactiveFormsModule, Button, Card, Alert],
templateUrl: './change-password.html', templateUrl: './change-password.html',
styleUrl: './change-password.scss', styleUrl: './change-password.scss',
}) })
@@ -1,36 +1,41 @@
<div class="auth-page"> <div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()"> <form class="auth-card-wrapper" [formGroup]="form" (ngSubmit)="onSubmit()">
<ev-card>
<img src="logo.png" alt="EnerVision" class="auth-logo" />
<h1>Connexion</h1> <h1>Connexion</h1>
<p class="auth-subtitle">Accédez à votre espace EnerVision</p> <p class="auth-subtitle">Accédez à votre espace EnerVision</p>
<label for="email">Email</label> <label class="form-label" for="email">Email</label>
<input <input
id="email" id="email"
class="form-input"
type="email" type="email"
formControlName="email" formControlName="email"
autocomplete="username" autocomplete="username"
placeholder="vous@enervision.fr" placeholder="vous@enervision.fr"
/> />
<label for="password">Mot de passe</label> <label class="form-label" for="password">Mot de passe</label>
<input <input
id="password" id="password"
class="form-input"
type="password" type="password"
formControlName="password" formControlName="password"
autocomplete="current-password" autocomplete="current-password"
/> />
@if (errorMessage()) { @if (errorMessage()) {
<p class="auth-error"> <ev-alert severity="danger">
{{ errorMessage() }} {{ errorMessage() }}
@if (retryAfterSeconds(); as seconds) { @if (retryAfterSeconds(); as seconds) {
(réessayez dans {{ seconds }}s) (réessayez dans {{ seconds }}s)
} }
</p> </ev-alert>
} }
<button type="submit" [disabled]="form.invalid || isLoading()"> <ev-button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Connexion...' : 'Se connecter' }} {{ isLoading() ? 'Connexion...' : 'Se connecter' }}
</button> </ev-button>
</ev-card>
</form> </form>
</div> </div>
@@ -3,79 +3,41 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; min-height: 100vh;
background: #f3f4f6; background: var(--color-bg);
font-family: 'Segoe UI', system-ui, sans-serif;
} }
.auth-card { .auth-card-wrapper {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 2.5rem;
width: 100%; width: 100%;
max-width: 360px; max-width: 360px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); }
display: flex;
flex-direction: column;
h1 { .auth-logo {
display: block;
height: 48px;
margin: 0 auto 1rem;
}
h1 {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 700; font-weight: 700;
color: #1f2937; color: var(--color-text);
} text-align: center;
}
.auth-subtitle { .auth-subtitle {
margin: 0.25rem 0 1.5rem; margin: 0.25rem 0 1.5rem;
color: #6b7280; color: var(--color-text-muted);
font-size: 0.9rem; font-size: 0.9rem;
} text-align: center;
}
label { ev-alert {
font-size: 0.85rem; display: block;
font-weight: 600; margin-top: 0.75rem;
color: #374151; }
margin-bottom: 0.35rem;
margin-top: 1rem;
}
input { ev-button {
padding: 0.6rem 0.75rem; display: block;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
button {
margin-top: 1.5rem; margin-top: 1.5rem;
padding: 0.7rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
&:disabled {
background: #9ca3af;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: #2563eb;
}
}
}
.auth-error {
margin: 0.75rem 0 0;
color: #dc2626;
font-size: 0.85rem;
} }
@@ -63,7 +63,7 @@ describe('Login', () => {
fixture.detectChanges(); // rend le bloc @if (errorMessage()) du template fixture.detectChanges(); // rend le bloc @if (errorMessage()) du template
expect(component.errorMessage()).toBe('Email ou mot de passe incorrect.'); expect(component.errorMessage()).toBe('Email ou mot de passe incorrect.');
const errorEl = fixture.nativeElement.querySelector('.auth-error'); const errorEl = fixture.nativeElement.querySelector('.ev-alert');
expect(errorEl?.textContent).toContain('Email ou mot de passe incorrect.'); expect(errorEl?.textContent).toContain('Email ou mot de passe incorrect.');
}); });
@@ -80,7 +80,7 @@ describe('Login', () => {
fixture.detectChanges(); // rend aussi le sous-bloc @if (retryAfterSeconds(); as seconds) fixture.detectChanges(); // rend aussi le sous-bloc @if (retryAfterSeconds(); as seconds)
expect(component.retryAfterSeconds()).toBe(30); expect(component.retryAfterSeconds()).toBe(30);
const errorEl = fixture.nativeElement.querySelector('.auth-error'); const errorEl = fixture.nativeElement.querySelector('.ev-alert');
expect(errorEl?.textContent).toContain('30s'); expect(errorEl?.textContent).toContain('30s');
}); });
@@ -90,7 +90,7 @@ describe('Login', () => {
const button = fixture.nativeElement.querySelector('button[type="submit"]'); const button = fixture.nativeElement.querySelector('button[type="submit"]');
expect(button.disabled).toBe(true); expect(button.disabled).toBe(true);
expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull(); expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
}); });
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
@@ -3,11 +3,14 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { Button } from '../../../shared/components/ui/button/button';
import { Card } from '../../../shared/components/ui/card/card';
import { Alert } from '../../../shared/components/ui/alert/alert';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
standalone: true, standalone: true,
imports: [ReactiveFormsModule], imports: [ReactiveFormsModule, Button, Card, Alert],
templateUrl: './login.html', templateUrl: './login.html',
styleUrl: './login.scss', styleUrl: './login.scss',
}) })
@@ -1,19 +1,22 @@
<div class="dashboard"> <div class="dashboard">
<header class="dashboard__header"> <header class="dashboard__header">
<div class="dashboard__brand">
<img src="logo.png" alt="EnerVision" class="dashboard__logo" />
<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>
<button type="button" class="logout-button" (click)="onLogout()">Déconnexion</button> <button type="button" class="logout-button" (click)="onLogout()">Déconnexion</button>
</header> </header>
@if (error(); as message) { @if (error(); as message) {
<p class="banner-error" role="alert">{{ message }}</p> <ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
} }
@if (stats(); as s) { @if (stats(); as s) {
<section class="overview"> <section class="overview">
<div class="card card--gauge"> <ev-card class="card card--gauge">
<span class="card__label">Consommation vs capacité</span> <span class="card__label">Consommation vs capacité</span>
<app-consumption-gauge <app-consumption-gauge
[consumption]="s.total_consumption_kw" [consumption]="s.total_consumption_kw"
@@ -23,20 +26,20 @@
>{{ s.total_consumption_kw | number: '1.0-1' }} / >{{ s.total_consumption_kw | number: '1.0-1' }} /
{{ s.total_capacity_kw | number }} kW</span {{ s.total_capacity_kw | number }} kW</span
> >
</div> </ev-card>
<div class="card"> <ev-card class="card">
<span class="card__label">Charge moyenne du parc</span> <span class="card__label">Charge moyenne du parc</span>
<span class="card__value">{{ s.average_load_percent }} %</span> <span class="card__value">{{ s.average_load_percent }} %</span>
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div> <div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div>
</div> </div>
</div> </ev-card>
<div class="card"> <ev-card class="card">
<span class="card__label">Sites suivis</span> <span class="card__label">Sites suivis</span>
<span class="card__value">{{ s.total_sites }}</span> <span class="card__value">{{ s.total_sites }}</span>
</div> </ev-card>
</section> </section>
<section class="chart-section"> <section class="chart-section">
@@ -50,8 +53,8 @@
<h2>Alertes actives</h2> <h2>Alertes actives</h2>
<ul class="alerts-list"> <ul class="alerts-list">
@for (alert of alerts(); track alert.alert_id) { @for (alert of alerts(); track alert.alert_id) {
<li class="alert-item" [class]="'alert-item--' + alert.severity"> <li class="alert-item">
<span class="alert-item__badge">{{ alert.severity }}</span> <ev-badge [tone]="badgeToneForSeverity(alert.severity)">{{ alert.severity }}</ev-badge>
<span class="alert-item__message">{{ alert.message }}</span> <span class="alert-item__message">{{ alert.message }}</span>
</li> </li>
} }
@@ -1,23 +1,22 @@
:host { :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; display: block;
font-family: 'Segoe UI', system-ui, sans-serif; color: var(--color-text);
color: #1f2937;
padding: 2rem; padding: 2rem;
max-width: 1100px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
} }
.dashboard__header { .dashboard__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 2rem; margin-bottom: 2rem;
}
.dashboard__brand {
display: flex;
align-items: center;
gap: 0.85rem;
h1 { h1 {
margin: 0; margin: 0;
@@ -26,6 +25,11 @@
} }
} }
.dashboard__logo {
height: 40px;
width: auto;
}
.dashboard__subtitle { .dashboard__subtitle {
margin: 0.25rem 0 0; margin: 0.25rem 0 0;
color: var(--color-text-muted); color: var(--color-text-muted);
@@ -38,13 +42,8 @@ h2 {
} }
.banner-error { .banner-error {
display: block;
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
padding: 0.75rem 1rem;
border: 1px solid var(--color-critical);
border-left-width: 4px;
border-radius: var(--radius);
background: #fdecea;
color: var(--color-critical);
} }
.overview { .overview {
@@ -55,14 +54,8 @@ h2 {
} }
.card { .card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.25rem; padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.35rem; gap: 0.35rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
} }
.card--gauge { .card--gauge {
@@ -84,16 +77,16 @@ h2 {
.progress-bar { .progress-bar {
height: 6px; height: 6px;
background: #e5e7eb; background: var(--color-border-light);
border-radius: 999px; border-radius: var(--radius-pill);
overflow: hidden; overflow: hidden;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.progress-bar__fill { .progress-bar__fill {
height: 100%; height: 100%;
background: #3b82f6; background: var(--color-primary);
border-radius: 999px; border-radius: var(--radius-pill);
transition: width 0.3s ease; transition: width 0.3s ease;
} }
@@ -115,59 +108,26 @@ h2 {
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 0.7rem 1rem; padding: 0.7rem 1rem;
border-radius: var(--radius); border-radius: var(--radius-md);
background: #fef2f2; background: var(--color-danger-bg);
border: 1px solid #fecaca; border: 1px solid var(--color-danger-border);
}
.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 { .alert-item__message {
font-size: 0.9rem; font-size: 0.9rem;
} }
.dashboard__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 2rem;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
}
.logout-button { .logout-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: #ffffff; background: var(--color-surface);
border: 1px solid #d1d5db; border: 1px solid var(--color-border);
border-radius: 8px; border-radius: var(--radius-sm);
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
color: #374151; color: var(--color-label);
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background: #f3f4f6; background: var(--color-bg);
} }
} }
@@ -9,16 +9,26 @@ import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load
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 { StatsSummary } from '../../shared/models/stats.model'; import { StatsSummary } from '../../shared/models/stats.model';
import { Alert } from '../../shared/models/alert.model'; import { Alert, AlertSeverity } from '../../shared/models/alert.model';
import { Card } from '../../shared/components/ui/card/card';
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
import { Badge, BadgeTone } from '../../shared/components/ui/badge/badge';
const REFRESH_INTERVAL_MS = 10000; const REFRESH_INTERVAL_MS = 10000;
const UNAVAILABLE_MESSAGE = const UNAVAILABLE_MESSAGE =
'Données indisponibles, les valeurs affichées datent du dernier relevé.'; 'Données indisponibles, les valeurs affichées datent du dernier relevé.';
const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
low: 'success',
medium: 'warning',
high: 'danger',
critical: 'danger',
};
@Component({ @Component({
selector: 'app-dashboard', selector: 'app-dashboard',
standalone: true, standalone: true,
imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart], imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart, Card, EvAlert, Badge],
templateUrl: './dashboard.html', templateUrl: './dashboard.html',
styleUrl: './dashboard.scss', styleUrl: './dashboard.scss',
}) })
@@ -54,6 +64,10 @@ export class Dashboard implements OnInit {
}); });
} }
badgeToneForSeverity(severity: AlertSeverity): BadgeTone {
return TON_PAR_SEVERITE[severity];
}
onLogout(): void { onLogout(): void {
this.auth.logout().subscribe({ this.auth.logout().subscribe({
next: () => this.router.navigate(['/login']), next: () => this.router.navigate(['/login']),
@@ -0,0 +1 @@
<ng-content></ng-content>
@@ -0,0 +1,27 @@
:host {
display: block;
margin: 0;
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
border: 1px solid transparent;
font-size: 0.85rem;
line-height: 1.4;
}
:host.ev-alert--success {
background: var(--color-success-bg);
border-color: var(--color-success);
color: var(--color-success);
}
:host.ev-alert--warning {
background: var(--color-warning-bg);
border-color: var(--color-warning);
color: #92400e;
}
:host.ev-alert--danger {
background: var(--color-danger-bg);
border-color: var(--color-danger-border);
color: var(--color-danger);
}
@@ -0,0 +1,30 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Alert } from './alert';
@Component({
standalone: true,
imports: [Alert],
template: `<ev-alert severity="success">C'est fait</ev-alert>`,
})
class AlertHost {}
describe('Alert', () => {
it('applique la classe danger par défaut', async () => {
await TestBed.configureTestingModule({ imports: [Alert] }).compileComponents();
const fixture = TestBed.createComponent(Alert);
fixture.detectChanges();
expect(fixture.nativeElement.classList).toContain('ev-alert--danger');
});
it('applique la sévérité demandée et projette le contenu', async () => {
await TestBed.configureTestingModule({ imports: [AlertHost] }).compileComponents();
const fixture = TestBed.createComponent(AlertHost);
fixture.detectChanges();
const el = fixture.nativeElement.querySelector('.ev-alert');
expect(el.classList).toContain('ev-alert--success');
expect(el.textContent).toContain("C'est fait");
});
});
@@ -0,0 +1,21 @@
import { Component, HostBinding, input } from '@angular/core';
export type AlertSeverity = 'success' | 'warning' | 'danger';
@Component({
selector: 'ev-alert',
standalone: true,
templateUrl: './alert.html',
styleUrl: './alert.scss',
})
export class Alert {
severity = input<AlertSeverity>('danger');
@HostBinding('class')
get hostClass(): string {
return `ev-alert ev-alert--${this.severity()}`;
}
@HostBinding('attr.role')
readonly role = 'alert';
}
@@ -0,0 +1,3 @@
<span class="ev-badge" [class]="'ev-badge--' + tone()">
<ng-content></ng-content>
</span>
@@ -0,0 +1,27 @@
.ev-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
padding: 0.2rem 0.55rem;
border-radius: var(--radius-pill);
color: #fff;
flex-shrink: 0;
}
.ev-badge--success {
background: var(--color-success);
}
.ev-badge--warning {
background: var(--color-warning);
}
.ev-badge--danger {
background: var(--color-danger);
}
.ev-badge--neutral {
background: var(--color-text-muted);
}
@@ -0,0 +1,30 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Badge } from './badge';
@Component({
standalone: true,
imports: [Badge],
template: `<ev-badge tone="danger">critique</ev-badge>`,
})
class BadgeHost {}
describe('Badge', () => {
it('applique le ton neutral par défaut', async () => {
await TestBed.configureTestingModule({ imports: [Badge] }).compileComponents();
const fixture = TestBed.createComponent(Badge);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.ev-badge').classList).toContain('ev-badge--neutral');
});
it('applique le ton demandé et projette le contenu', async () => {
await TestBed.configureTestingModule({ imports: [BadgeHost] }).compileComponents();
const fixture = TestBed.createComponent(BadgeHost);
fixture.detectChanges();
const el = fixture.nativeElement.querySelector('.ev-badge');
expect(el.classList).toContain('ev-badge--danger');
expect(el.textContent).toContain('critique');
});
});
@@ -0,0 +1,13 @@
import { Component, input } from '@angular/core';
export type BadgeTone = 'success' | 'warning' | 'danger' | 'neutral';
@Component({
selector: 'ev-badge',
standalone: true,
templateUrl: './badge.html',
styleUrl: './badge.scss',
})
export class Badge {
tone = input<BadgeTone>('neutral');
}
@@ -0,0 +1,3 @@
<button [type]="type()" class="ev-button" [class]="'ev-button--' + variant()" [disabled]="disabled()">
<ng-content></ng-content>
</button>
@@ -0,0 +1,51 @@
.ev-button {
width: 100%;
padding: 0.7rem;
border: none;
border-radius: var(--radius-sm);
font-size: 0.95rem;
font-weight: 600;
font-family: var(--font-family);
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.7;
}
}
.ev-button--primary {
background: var(--color-primary);
color: #fff;
&:disabled {
background: var(--color-disabled);
}
&:not(:disabled):hover {
background: var(--color-primary-hover);
}
}
.ev-button--secondary {
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-label);
&:not(:disabled):hover {
background: var(--color-bg);
}
}
.ev-button--danger {
background: var(--color-danger);
color: #fff;
&:disabled {
background: var(--color-disabled);
}
&:not(:disabled):hover {
background: #b91c1c;
}
}
@@ -0,0 +1,50 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Button } from './button';
@Component({
standalone: true,
imports: [Button],
template: `<ev-button>Valider</ev-button>`,
})
class ButtonHost {}
describe('Button', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [Button] }).compileComponents();
});
it('applique la classe de la variante primary par défaut', () => {
const fixture = TestBed.createComponent(Button);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.classList).toContain('ev-button--primary');
});
it('applique la classe de la variante demandée', () => {
const fixture = TestBed.createComponent(Button);
fixture.componentRef.setInput('variant', 'danger');
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.classList).toContain('ev-button--danger');
});
it('désactive le bouton natif quand disabled est vrai', () => {
const fixture = TestBed.createComponent(Button);
fixture.componentRef.setInput('disabled', true);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.disabled).toBe(true);
});
it('projette le contenu', async () => {
await TestBed.configureTestingModule({ imports: [ButtonHost] }).compileComponents();
const fixture = TestBed.createComponent(ButtonHost);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('button').textContent).toContain('Valider');
});
});
@@ -0,0 +1,15 @@
import { Component, input } from '@angular/core';
export type ButtonVariant = 'primary' | 'secondary' | 'danger';
@Component({
selector: 'ev-button',
standalone: true,
templateUrl: './button.html',
styleUrl: './button.scss',
})
export class Button {
variant = input<ButtonVariant>('primary');
type = input<'button' | 'submit'>('button');
disabled = input(false);
}
@@ -0,0 +1 @@
<ng-content></ng-content>
@@ -0,0 +1,10 @@
:host {
background: var(--color-surface);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--space-5);
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
box-sizing: border-box;
}
@@ -0,0 +1,22 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Card } from './card';
@Component({
standalone: true,
imports: [Card],
template: `<ev-card><p>Contenu</p></ev-card>`,
})
class CardHost {}
describe('Card', () => {
it('projette son contenu', async () => {
await TestBed.configureTestingModule({ imports: [CardHost] }).compileComponents();
const fixture = TestBed.createComponent(CardHost);
fixture.detectChanges();
const card = fixture.nativeElement.querySelector('ev-card');
expect(card).toBeTruthy();
expect(card.textContent).toContain('Contenu');
});
});
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
selector: 'ev-card',
standalone: true,
templateUrl: './card.html',
styleUrl: './card.scss',
})
export class Card {}
+8 -1
View File
@@ -1 +1,8 @@
/* You can add global styles to this file, and also import other style files */ @use 'styles/tokens';
@use 'styles/forms';
body {
margin: 0;
font-family: var(--font-family);
color: var(--color-text);
}
+37
View File
@@ -0,0 +1,37 @@
.form-label {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-label);
margin-bottom: var(--space-1);
margin-top: var(--space-3);
}
.form-input {
width: 100%;
padding: var(--space-2) 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.95rem;
font-family: var(--font-family);
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.15);
}
}
.form-hint {
display: block;
font-size: 0.75rem;
color: var(--color-disabled);
margin-top: 0.25rem;
}
.form-error {
margin: var(--space-2) 0 0;
color: var(--color-danger);
font-size: 0.85rem;
}
+39
View File
@@ -0,0 +1,39 @@
:root {
// Marque (dérivé du logo : vert feuille/éclair, halo)
--color-primary: #16a34a;
--color-primary-hover: #15803d;
--color-primary-light: #dcfce7;
// Neutres (texte, bordures, fonds)
--color-text: #1f2937;
--color-text-muted: #6b7280;
--color-label: #374151;
--color-border: #d1d5db;
--color-border-light: #e5e7eb;
--color-bg: #f3f4f6;
--color-surface: #ffffff;
--color-disabled: #9ca3af;
// Sémantique (statuts, alertes)
--color-success: #16a34a;
--color-success-bg: #dcfce7;
--color-warning: #f9a825;
--color-warning-bg: #fef9e7;
--color-danger: #dc2626;
--color-danger-bg: #fef2f2;
--color-danger-border: #fecaca;
// Typo, rayons, ombre
--font-family: 'Segoe UI', system-ui, sans-serif;
--radius-sm: 8px;
--radius-md: 12px;
--radius-pill: 999px;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06);
// Espacements
--space-1: 0.35rem;
--space-2: 0.6rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
}
@@ -0,0 +1,76 @@
# Design système frontend
Ce que toute nouvelle page ou tout nouveau composant Angular doit réutiliser, plutôt que
redéfinir ses propres couleurs, rayons ou espacements en dur. Contexte : issue
[#91](https://github.com/ineszang/ProjetPiscine_EnerVision/issues/91), née d'une incohérence
visuelle accumulée page après page (aucun jeton partagé n'existait avant ce chantier).
## Tokens
Déclarés en CSS custom properties dans `apps/frontend/src/styles/_tokens.scss`, importés une
seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentaire.
| Variable | Rôle |
|---|---|
| `--color-primary`, `--color-primary-hover`, `--color-primary-light` | Couleur de marque (vert, dérivé du logo), actions principales |
| `--color-text`, `--color-text-muted`, `--color-label` | Hiérarchie de texte (titres, texte secondaire, labels de formulaire) |
| `--color-border`, `--color-border-light` | Bordures d'inputs et de cartes |
| `--color-bg`, `--color-surface` | Fond de page vs fond des cartes/panneaux |
| `--color-disabled` | Éléments désactivés |
| `--color-success` / `-bg`, `--color-warning` / `-bg`, `--color-danger` / `-bg` / `-border` | États sémantiques (alertes, badges) |
| `--font-family` | Police unique de l'application |
| `--radius-sm`, `--radius-md`, `--radius-pill` | Rayons de bordure (input/bouton, carte, pastille) |
| `--shadow-card` | Ombre portée des cartes |
| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) |
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`, `.form-error`)
sont dans `apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
s'appliquent directement à des `<label>`/`<input>` natifs liés par `formControlName` : pas de
composant `ControlValueAccessor` dédié, le gain n'en vaut pas la complexité pour des formulaires
aussi simples que ceux de ce projet.
## Composants partagés
Dans `apps/frontend/src/app/shared/components/ui/`, chacun standalone, à importer directement
dans le tableau `imports` du composant qui l'utilise.
- **`<ev-button>`** (`button/`) : `variant` (`primary` / `secondary` / `danger`, défaut
`primary`), `type` (`button` / `submit`, défaut `button`), `disabled`.
```html
<ev-button type="submit" [disabled]="form.invalid">Valider</ev-button>
```
- **`<ev-card>`** (`card/`) : conteneur à padding/rayon/ombre standard, sans input, tout est le
contenu projeté (`<ng-content>`). Le style vit sur `:host` : une classe externe passée par le
parent (`<ev-card class="ma-classe">`) se combine avec le style du composant sans le masquer.
```html
<ev-card><h1>Titre</h1></ev-card>
```
- **`<ev-alert>`** (`alert/`) : `severity` (`success` / `warning` / `danger`, défaut `danger`),
`role="alert"` posé automatiquement. Même principe de style sur `:host`.
```html
<ev-alert severity="danger">Erreur : {{ message }}</ev-alert>
```
- **`<ev-badge>`** (`badge/`) : `tone` (`success` / `warning` / `danger` / `neutral`, défaut
`neutral`), pastille à bord arrondi pour un statut court.
```html
<ev-badge tone="danger">critique</ev-badge>
```
## Logo
`apps/frontend/public/logo.png` (192×128, recadré et compressé depuis l'asset source du projet)
est affiché en en-tête des pages d'authentification et du tableau de bord :
```html
<img src="logo.png" alt="EnerVision" class="auth-logo" />
```
Le favicon reste `apps/frontend/public/favicon.ico` (non remplacé par ce chantier).
Côté backend, l'icône seule (sans le mot-symbole) est servie depuis
`apps/backend/app/static/logo-icon.png` et référencée par `/docs` (favicon Swagger) et `/redoc`
(logo natif via l'extension `x-logo` du schéma OpenAPI, voir `app/main.py`).
## Règle pour toute nouvelle page
Utiliser les tokens et les composants ci-dessus plutôt que des valeurs en dur (couleurs
hexadécimales, rayons, espacements). Étendre ce document si un nouveau composant partagé est
créé.