From f2aa073525ff219ecf942408f4d5ec41cfa11ec8 Mon Sep 17 00:00:00 2001 From: Matheus Santana <130666046+mathsant-js@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:02:06 -0300 Subject: [PATCH 1/7] feat(analytics): add dashboard and sustainability APIs Aggregate persisted sessions, energy readings, and invoices in service-layer calculations. Add date, station, and user filters with access control, a configurable emission factor, Pydantic response schemas, tests, and API contract documentation. --- .env.example | 1 + README.md | 1 + backend/app/api/router.py | 2 + backend/app/api/routes/analytics.py | 84 +++++++++++ backend/app/core/config.py | 1 + backend/app/schemas/analytics.py | 29 ++++ backend/app/services/analytics.py | 118 +++++++++++++++ backend/tests/test_analytics.py | 222 ++++++++++++++++++++++++++++ docs/PHASE_6_ANALYTICS.md | 15 ++ 9 files changed, 473 insertions(+) create mode 100644 backend/app/api/routes/analytics.py create mode 100644 backend/app/schemas/analytics.py create mode 100644 backend/app/services/analytics.py create mode 100644 backend/tests/test_analytics.py create mode 100644 docs/PHASE_6_ANALYTICS.md diff --git a/.env.example b/.env.example index c6c737a..f618594 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ APP_CORS_ORIGINS=["http://localhost:5173"] JWT_SECRET_KEY=development-only-change-me-minimum-32-bytes JWT_EXPIRATION_MINUTES=60 JWT_ALGORITHM=HS256 +GRID_EMISSION_FACTOR_KG_PER_KWH=0.0 POSTGRES_DB=chargegrid POSTGRES_USER=chargegrid diff --git a/README.md b/README.md index 16520f2..0235a30 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ O alvo executa testes, lint e verificação de tipos no backend e no frontend, a - [Fase 3 — Simulação e leituras (concluída)](docs/PHASE_3.md) - [Fase 4 — Gestão energética (concluída)](docs/PHASE_4.md) - [Fase 5 — Billing e histórico de invoices (concluída)](docs/PHASE_5.md) +- [Fase 6 — Contratos da API de analytics](docs/PHASE_6_ANALYTICS.md) ## Estado atual diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 446beea..23566eb 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,6 +1,7 @@ from fastapi import APIRouter from app.api.routes.alerts import router as alerts_router +from app.api.routes.analytics import router as analytics_router from app.api.routes.auth import router as auth_router from app.api.routes.billing import router as billing_router from app.api.routes.chargers import router as chargers_router @@ -27,5 +28,6 @@ api_router.include_router(solar_router) api_router.include_router(billing_router) api_router.include_router(alerts_router) +api_router.include_router(analytics_router) api_router.include_router(predictions_router) api_router.include_router(configuration_router) diff --git a/backend/app/api/routes/analytics.py b/backend/app/api/routes/analytics.py new file mode 100644 index 0000000..5ae0b02 --- /dev/null +++ b/backend/app/api/routes/analytics.py @@ -0,0 +1,84 @@ +from datetime import UTC, datetime +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, HTTPException, Query, status + +from app.api.dependencies import CurrentUser +from app.api.routes.common import NOT_FOUND_RESPONSE, UNAUTHORIZED_RESPONSE, DbSession +from app.core.config import get_settings +from app.models.user import UserRole +from app.schemas.analytics import DashboardResponse, SustainabilityResponse +from app.services.analytics import AnalyticsFilters, dashboard, sustainability + +router = APIRouter(prefix="/analytics", tags=["analytics"]) +DateFrom = Annotated[datetime | None, Query(alias="from")] +DateTo = Annotated[datetime | None, Query(alias="to")] + + +def filters_for_user( + current_user_id: UUID, + role: UserRole, + station_id: UUID | None, + user_id: UUID | None, + date_from: datetime | None, + date_to: datetime | None, +) -> AnalyticsFilters: + if role != UserRole.ADMIN: + if user_id is not None and user_id != current_user_id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Resource not found") + user_id = current_user_id + if date_from is not None and date_from.tzinfo is None: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "from must include a timezone") + if date_to is not None and date_to.tzinfo is None: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "to must include a timezone") + if date_from is not None: + date_from = date_from.astimezone(UTC) + if date_to is not None: + date_to = date_to.astimezone(UTC) + if date_from is not None and date_to is not None and date_from > date_to: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "from must not exceed to") + return AnalyticsFilters(station_id, user_id, date_from, date_to) + + +@router.get( + "/dashboard", + response_model=DashboardResponse, + responses=UNAUTHORIZED_RESPONSE | NOT_FOUND_RESPONSE, +) +async def get_dashboard( + db: DbSession, + current_user: CurrentUser, + date_from: DateFrom = None, + date_to: DateTo = None, + station_id: UUID | None = None, + user_id: UUID | None = None, +) -> DashboardResponse: + return dashboard( + db, + filters_for_user( + current_user.id, current_user.role, station_id, user_id, date_from, date_to + ), + ) + + +@router.get( + "/sustainability", + response_model=SustainabilityResponse, + responses=UNAUTHORIZED_RESPONSE | NOT_FOUND_RESPONSE, +) +async def get_sustainability( + db: DbSession, + current_user: CurrentUser, + date_from: DateFrom = None, + date_to: DateTo = None, + station_id: UUID | None = None, + user_id: UUID | None = None, +) -> SustainabilityResponse: + return sustainability( + db, + filters_for_user( + current_user.id, current_user.role, station_id, user_id, date_from, date_to + ), + get_settings().grid_emission_factor_kg_per_kwh, + ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index d0c3d7f..d14f016 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -20,6 +20,7 @@ class Settings(BaseSettings): app_log_level: str = "INFO" app_cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:5173"]) api_v1_prefix: str = "/api/v1" + grid_emission_factor_kg_per_kwh: float = Field(default=0.0, ge=0) database_url: str = "postgresql+psycopg://chargegrid:chargegrid@localhost:5432/chargegrid" jwt_secret_key: str = Field(default=DEFAULT_JWT_SECRET, min_length=32) jwt_expiration_minutes: int = Field(default=60, gt=0) diff --git a/backend/app/schemas/analytics.py b/backend/app/schemas/analytics.py new file mode 100644 index 0000000..0367125 --- /dev/null +++ b/backend/app/schemas/analytics.py @@ -0,0 +1,29 @@ +from decimal import Decimal +from uuid import UUID + +from pydantic import BaseModel + + +class DashboardResponse(BaseModel): + station_id: UUID | None + user_id: UUID | None + session_count: int + completed_session_count: int + energy_consumed_kwh: float + solar_energy_kwh: float + grid_energy_kwh: float + billed_total: Decimal + currency: str = "BRL" + + +class SustainabilityResponse(BaseModel): + station_id: UUID | None + user_id: UUID | None + energy_consumed_kwh: float + solar_energy_kwh: float + grid_energy_kwh: float + solar_percentage: float + avoided_co2_kg: float + grid_emission_factor_kg_per_kwh: float + estimated_solar_savings: Decimal + currency: str = "BRL" diff --git a/backend/app/services/analytics.py b/backend/app/services/analytics.py new file mode 100644 index 0000000..470b99e --- /dev/null +++ b/backend/app/services/analytics.py @@ -0,0 +1,118 @@ +from dataclasses import dataclass +from datetime import datetime +from decimal import ROUND_HALF_UP, Decimal +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.billing import Invoice, InvoiceStatus +from app.models.energy import ChargingSession, ChargingSessionStatus, EnergyReading +from app.models.infrastructure import Charger +from app.schemas.analytics import DashboardResponse, SustainabilityResponse + + +@dataclass(frozen=True) +class AnalyticsFilters: + station_id: UUID | None = None + user_id: UUID | None = None + date_from: datetime | None = None + date_to: datetime | None = None + + +def _sessions(db: Session, filters: AnalyticsFilters) -> list[ChargingSession]: + statement = select(ChargingSession).join(Charger) + if filters.station_id is not None: + statement = statement.where(Charger.station_id == filters.station_id) + if filters.user_id is not None: + statement = statement.where(ChargingSession.user_id == filters.user_id) + return list(db.scalars(statement).all()) + + +def _in_period(value: datetime | None, filters: AnalyticsFilters) -> bool: + if value is None: + return False + # SQLite test fixtures can return naive UTC timestamps. + if value.tzinfo is None: + from datetime import UTC + + value = value.replace(tzinfo=UTC) + return (filters.date_from is None or value >= filters.date_from) and ( + filters.date_to is None or value <= filters.date_to + ) + + +def _readings( + db: Session, session_ids: list[UUID], filters: AnalyticsFilters +) -> list[EnergyReading]: + if not session_ids: + return [] + statement = select(EnergyReading).where(EnergyReading.session_id.in_(session_ids)) + if filters.date_from is not None: + statement = statement.where(EnergyReading.timestamp >= filters.date_from) + if filters.date_to is not None: + statement = statement.where(EnergyReading.timestamp <= filters.date_to) + return list(db.scalars(statement).all()) + + +def dashboard(db: Session, filters: AnalyticsFilters) -> DashboardResponse: + sessions = _sessions(db, filters) + ids = [item.id for item in sessions] + readings = _readings(db, ids, filters) + invoices = [] + if ids: + invoices = list( + db.scalars( + select(Invoice).where( + Invoice.session_id.in_(ids), Invoice.status == InvoiceStatus.CLOSED + ) + ).all() + ) + return DashboardResponse( + station_id=filters.station_id, + user_id=filters.user_id, + session_count=sum( + item.started_at is not None and _in_period(item.started_at, filters) + for item in sessions + ), + completed_session_count=sum( + item.status == ChargingSessionStatus.COMPLETED and _in_period(item.ended_at, filters) + for item in sessions + ), + energy_consumed_kwh=sum(item.interval_energy_kwh for item in readings), + solar_energy_kwh=sum(item.solar_energy_kwh for item in readings), + grid_energy_kwh=sum(item.grid_energy_kwh for item in readings), + billed_total=sum( + (item.total for item in invoices if _in_period(item.closed_at, filters)), + Decimal("0.00"), + ), + ) + + +def sustainability( + db: Session, filters: AnalyticsFilters, emission_factor: float +) -> SustainabilityResponse: + sessions = _sessions(db, filters) + by_id = {item.id: item for item in sessions} + readings = _readings(db, list(by_id), filters) + consumed = sum(item.interval_energy_kwh for item in readings) + solar = sum(item.solar_energy_kwh for item in readings) + grid = sum(item.grid_energy_kwh for item in readings) + savings = sum( + ( + Decimal(str(item.solar_energy_kwh)) * by_id[item.session_id].tariff_per_kwh + for item in readings + ), + Decimal("0"), + ).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return SustainabilityResponse( + station_id=filters.station_id, + user_id=filters.user_id, + energy_consumed_kwh=consumed, + solar_energy_kwh=solar, + grid_energy_kwh=grid, + solar_percentage=solar / consumed * 100 if consumed else 0, + avoided_co2_kg=solar * emission_factor, + grid_emission_factor_kg_per_kwh=emission_factor, + estimated_solar_savings=savings, + ) diff --git a/backend/tests/test_analytics.py b/backend/tests/test_analytics.py new file mode 100644 index 0000000..3db786a --- /dev/null +++ b/backend/tests/test_analytics.py @@ -0,0 +1,222 @@ +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +import pytest +from httpx import AsyncClient +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.core.security import hash_password +from app.models.billing import Invoice, InvoiceStatus +from app.models.energy import ChargingSession, ChargingSessionStatus, EnergyReading +from app.models.infrastructure import Charger, ChargingStation +from app.models.user import User, UserRole +from app.models.vehicle import Vehicle +from app.services.analytics import AnalyticsFilters, dashboard, sustainability + + +def seed(db: Session) -> tuple[User, User, ChargingStation, ChargingSession, ChargingSession]: + first = User( + name="First", + email="analytics-first@example.com", + password_hash=hash_password("secret123"), + role=UserRole.USER, + ) + second = User( + name="Second", + email="analytics-second@example.com", + password_hash=hash_password("secret123"), + role=UserRole.USER, + ) + station = ChargingStation(name="One", grid_limit_kw=60) + db.add_all([first, second, station]) + db.flush() + charger_a = Charger(station_id=station.id, name="A", code="A", max_power_kw=22) + charger_b = Charger(station_id=station.id, name="B", code="B", max_power_kw=22) + vehicle_a = Vehicle( + user_id=first.id, + name="A", + brand="X", + model="X", + license_plate="ANA-1", + max_charge_power_kw=22, + ) + vehicle_b = Vehicle( + user_id=second.id, + name="B", + brand="X", + model="X", + license_plate="ANA-2", + max_charge_power_kw=22, + ) + db.add_all([charger_a, charger_b, vehicle_a, vehicle_b]) + db.flush() + at = datetime(2026, 9, 1, tzinfo=UTC) + first_session = ChargingSession( + user_id=first.id, + vehicle_id=vehicle_a.id, + charger_id=charger_a.id, + status=ChargingSessionStatus.COMPLETED, + started_at=at, + ended_at=at + timedelta(hours=1), + requested_power_kw=10, + allocated_power_kw=0, + tariff_per_kwh=Decimal("0.9200"), + ) + second_session = ChargingSession( + user_id=second.id, + vehicle_id=vehicle_b.id, + charger_id=charger_b.id, + status=ChargingSessionStatus.COMPLETED, + started_at=at, + ended_at=at + timedelta(hours=1), + requested_power_kw=10, + allocated_power_kw=0, + tariff_per_kwh=Decimal("2.0000"), + ) + db.add_all([first_session, second_session]) + db.flush() + db.add_all( + [ + EnergyReading( + session_id=first_session.id, + timestamp=at + timedelta(minutes=10), + requested_power_kw=10, + allocated_power_kw=10, + solar_power_kw=4, + grid_power_kw=6, + interval_energy_kwh=10, + solar_energy_kwh=4, + grid_energy_kwh=6, + ), + EnergyReading( + session_id=first_session.id, + timestamp=at + timedelta(minutes=20), + requested_power_kw=10, + allocated_power_kw=10, + solar_power_kw=1, + grid_power_kw=9, + interval_energy_kwh=10, + solar_energy_kwh=1, + grid_energy_kwh=9, + ), + EnergyReading( + session_id=second_session.id, + timestamp=at + timedelta(minutes=10), + requested_power_kw=10, + allocated_power_kw=10, + solar_power_kw=5, + grid_power_kw=5, + interval_energy_kwh=10, + solar_energy_kwh=5, + grid_energy_kwh=5, + ), + Invoice( + session_id=first_session.id, + user_id=first.id, + energy_kwh=Decimal("20"), + tariff_per_kwh=Decimal("0.92"), + subtotal=Decimal("18.40"), + total=Decimal("18.40"), + status=InvoiceStatus.CLOSED, + closed_at=at + timedelta(hours=1), + ), + Invoice( + session_id=second_session.id, + user_id=second.id, + energy_kwh=Decimal("10"), + tariff_per_kwh=Decimal("2"), + subtotal=Decimal("20"), + total=Decimal("20"), + status=InvoiceStatus.CANCELLED, + closed_at=at + timedelta(hours=1), + ), + ] + ) + db.commit() + return first, second, station, first_session, second_session + + +def test_analytics_calculation_and_empty_period(db_session: Session) -> None: + first, _, station, _, _ = seed(db_session) + filters = AnalyticsFilters(station_id=station.id, user_id=first.id) + summary = dashboard(db_session, filters) + assert (summary.session_count, summary.completed_session_count) == (1, 1) + assert (summary.energy_consumed_kwh, summary.solar_energy_kwh, summary.grid_energy_kwh) == ( + 20, + 5, + 15, + ) + assert summary.billed_total == Decimal("18.40") + impact = sustainability(db_session, filters, 0.4) + assert impact.solar_percentage == 25 + assert impact.avoided_co2_kg == 2 + assert impact.estimated_solar_savings == Decimal("4.60") + all_users = dashboard(db_session, AnalyticsFilters(station_id=station.id)) + assert all_users.energy_consumed_kwh == 30 + assert all_users.billed_total == Decimal("18.40") + assert sustainability( + db_session, AnalyticsFilters(station_id=station.id), 0.4 + ).estimated_solar_savings == Decimal("14.60") + empty = AnalyticsFilters(date_from=datetime(2026, 10, 1, tzinfo=UTC)) + assert dashboard(db_session, empty).energy_consumed_kwh == 0 + assert dashboard(db_session, empty).billed_total == 0 + assert sustainability(db_session, empty, 0.4).solar_percentage == 0 + + +@pytest.mark.anyio +async def test_analytics_api_scope_filters_and_validation( + client: AsyncClient, db_session: Session +) -> None: + first, second, station, _, _ = seed(db_session) + login = await client.post( + "/api/v1/auth/login", json={"email": first.email, "password": "secret123"} + ) + headers = {"Authorization": f"Bearer {login.json()['access_token']}"} + base = "/api/v1/analytics" + own = await client.get(f"{base}/dashboard", headers=headers) + assert own.status_code == 200 + assert own.json()["energy_consumed_kwh"] == 20 + assert own.json()["user_id"] == str(first.id) + assert (await client.get(f"{base}/sustainability", headers=headers)).json()[ + "solar_percentage" + ] == 25 + for endpoint in ("dashboard", "sustainability"): + assert ( + await client.get( + f"{base}/{endpoint}", headers=headers, params={"user_id": str(second.id)} + ) + ).status_code == 404 + assert ( + await client.get(f"{base}/{endpoint}", headers={"Authorization": "Bearer invalid"}) + ).status_code == 401 + assert ( + await client.get( + f"{base}/{endpoint}", + params={"from": "2026-10-01T00:00:00Z", "to": "2026-09-01T00:00:00Z"}, + ) + ).status_code == 422 + assert ( + await client.get(f"{base}/{endpoint}", params={"from": "2026-09-01T00:00:00"}) + ).status_code == 422 + admin = await client.get(f"{base}/dashboard", params={"station_id": str(station.id)}) + assert admin.json()["energy_consumed_kwh"] == 30 + filtered = await client.get( + f"{base}/dashboard", + params={ + "user_id": str(first.id), + "from": "2026-09-01T00:15:00Z", + "to": "2026-09-01T00:30:00Z", + }, + ) + assert filtered.json()["energy_consumed_kwh"] == 10 + assert filtered.json()["billed_total"] == "0.00" + client.headers.pop("Authorization") + assert (await client.get(f"{base}/dashboard")).status_code == 401 + + +def test_emission_factor_is_configurable(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GRID_EMISSION_FACTOR_KG_PER_KWH", "0.45") + get_settings.cache_clear() + assert get_settings().grid_emission_factor_kg_per_kwh == 0.45 + get_settings.cache_clear() diff --git a/docs/PHASE_6_ANALYTICS.md b/docs/PHASE_6_ANALYTICS.md new file mode 100644 index 0000000..2f03f50 --- /dev/null +++ b/docs/PHASE_6_ANALYTICS.md @@ -0,0 +1,15 @@ +# Fase 6 — Contratos da API de analytics + +`GET /api/v1/analytics/dashboard` e `GET /api/v1/analytics/sustainability` exigem JWT. ADMIN consulta todas as sessões; USER consulta somente as próprias. O filtro `user_id` é permitido para ADMIN e para o próprio USER; outro ID retorna 404. `station_id` limita os dados aos carregadores da estação indicada. IDs válidos sem registros retornam indicadores zerados. + +Ambas as rotas aceitam `station_id`, `user_id`, `from` e `to`. Datas devem incluir fuso horário; os limites são inclusivos e `from` não pode ser posterior a `to` (422). Sem filtros de data, usa-se todo o histórico. + +## Dashboard + +Retorna `station_id`, `user_id`, `session_count`, `completed_session_count`, `energy_consumed_kwh`, `solar_energy_kwh`, `grid_energy_kwh`, `billed_total` e `currency` (`BRL`). As três energias são somas das `EnergyReading` persistidas, sem somar novamente os acumuladores da sessão. O período filtra as leituras por `timestamp`, as sessões iniciadas por `started_at`, as concluídas por `ended_at` e as invoices `CLOSED` por `closed_at`. `billed_total` usa `Invoice.total`, sem recalcular pela tarifa atual. Uma sessão sem leitura não acrescenta energia. Invoices abertas ou canceladas não entram no faturamento. + +## Sustentabilidade + +Retorna os filtros efetivos, as três energias, `solar_percentage`, `avoided_co2_kg`, `grid_emission_factor_kg_per_kwh`, `estimated_solar_savings` e `currency` (`BRL`). O percentual é `solar_energy_kwh / energy_consumed_kwh × 100`, ou zero quando o consumo é zero. CO₂ evitado é energia solar vezes `GRID_EMISSION_FACTOR_KG_PER_KWH`; a configuração tem padrão explícito `0.0` e deve ser definida para obter um indicador de CO₂ diferente de zero. A economia estimada soma a energia solar de cada leitura multiplicada pela tarifa capturada na respectiva sessão; o total é arredondado para centavos. É uma estimativa teórica da compra evitada da rede, não economia financeira real. + +As respostas não incluem previsões de demanda, classificação de pico nem séries derivadas de ML. From 20022c8b66aa49687a19daa8487c27cbf3a6641e Mon Sep 17 00:00:00 2001 From: Matheus Santana <130666046+mathsant-js@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:10:06 -0300 Subject: [PATCH 2/7] feat(alerts): detect high grid demand episodes and restrict alert access to admins --- README.md | 6 +- backend/app/api/routes/alerts.py | 21 +++-- backend/app/simulation/tick.py | 47 +++++++++- backend/tests/test_billing_alerts.py | 89 ++++++++++++++---- backend/tests/test_simulation_tick.py | 129 +++++++++++++++++++++++++- 5 files changed, 262 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 0235a30..c61342f 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,10 @@ A potência solar disponível cobre primeiro a demanda alocada e é rateada proporcionalmente; a parcela restante vem da rede, limitada por estação. Cada tick corresponde à duração configurada do relógio (60 segundos simulados por padrão), calcula energia em kWh e atualiza os acumuladores das sessões. -Esta etapa ainda não gera alertas nem atualiza analytics derivados por tick. +O tick também cria `HIGH_DEMAND` quando a importação da rede atinge o limiar +configurado em `SystemConfiguration` (0,85 na ausência de configuração). O +alerta é emitido uma vez por episódio de alta demanda e participa da mesma +transação das leituras. O tick ainda não atualiza analytics derivados. Cada estação processada recebe uma `SolarReading` única por timestamp simulado; cada sessão recebe uma `EnergyReading` única por timestamp. Uma reexecução no @@ -110,3 +113,4 @@ alerta na mesma transação. O histórico de invoices está disponível em `GET /api/v1/billing/invoices` e `GET /api/v1/billing/invoices/{invoice_id}`, com acesso restrito às próprias invoices para usuários comuns. Consulte [a validação da Fase 5](docs/PHASE_5.md) para os critérios de aceite e testes. +Listagem e reconhecimento de alertas em `/api/v1/alerts` exigem papel ADMIN. diff --git a/backend/app/api/routes/alerts.py b/backend/app/api/routes/alerts.py index 397d519..e631952 100644 --- a/backend/app/api/routes/alerts.py +++ b/backend/app/api/routes/alerts.py @@ -5,16 +5,25 @@ from fastapi import APIRouter, Query from sqlalchemy import select -from app.api.routes.common import DbSession, commit_or_conflict, get_or_404 +from app.api.dependencies import AdminUser +from app.api.routes.common import ( + FORBIDDEN_RESPONSE, + UNAUTHORIZED_RESPONSE, + DbSession, + commit_or_conflict, + get_or_404, +) from app.models.alert import Alert, AlertSeverity, AlertType from app.schemas.alert import AlertResponse router = APIRouter(prefix="/alerts", tags=["alerts"]) +AUTH_RESPONSES = UNAUTHORIZED_RESPONSE | FORBIDDEN_RESPONSE -@router.get("", response_model=list[AlertResponse]) +@router.get("", response_model=list[AlertResponse], responses=AUTH_RESPONSES) async def list_alerts( db: DbSession, + _: AdminUser, station_id: UUID | None = None, alert_type: Annotated[AlertType | None, Query(alias="type")] = None, severity: AlertSeverity | None = None, @@ -29,16 +38,14 @@ async def list_alerts( statement = statement.where(Alert.severity == severity) if acknowledged is not None: condition = ( - Alert.acknowledged_at.is_not(None) - if acknowledged - else Alert.acknowledged_at.is_(None) + Alert.acknowledged_at.is_not(None) if acknowledged else Alert.acknowledged_at.is_(None) ) statement = statement.where(condition) return list(db.scalars(statement.order_by(Alert.created_at.desc(), Alert.id)).all()) -@router.patch("/{alert_id}/acknowledge", response_model=AlertResponse) -async def acknowledge_alert(alert_id: UUID, db: DbSession) -> Alert: +@router.patch("/{alert_id}/acknowledge", response_model=AlertResponse, responses=AUTH_RESPONSES) +async def acknowledge_alert(alert_id: UUID, db: DbSession, _: AdminUser) -> Alert: alert = get_or_404(db, Alert, alert_id) if alert.acknowledged_at is None: alert.acknowledged_at = datetime.now(UTC) diff --git a/backend/app/simulation/tick.py b/backend/app/simulation/tick.py index 629ccf0..4555627 100644 --- a/backend/app/simulation/tick.py +++ b/backend/app/simulation/tick.py @@ -13,11 +13,13 @@ from typing import Protocol from uuid import UUID -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session +from app.models.alert import Alert, AlertSeverity, AlertType from app.models.energy import ChargingSession, ChargingSessionStatus, EnergyReading, SolarReading from app.models.infrastructure import Charger, ChargingStation +from app.models.prediction import SystemConfiguration from app.models.vehicle import Vehicle from app.services.energy_allocation import PowerBreakdown, SessionPowerRequest from app.services.energy_readings import ABSOLUTE_TOLERANCE, build_energy_reading_data @@ -70,6 +72,10 @@ def execute_tick( skipped = 0 try: with db.begin(): + configuration = db.scalar(select(SystemConfiguration).limit(1)) + high_demand_threshold = ( + configuration.high_demand_threshold if configuration is not None else 0.85 + ) rows = db.execute( select(ChargingSession, Charger, Vehicle, ChargingStation) .join(Charger, ChargingSession.charger_id == Charger.id) @@ -141,6 +147,45 @@ def execute_tick( ): raise ValueError("solar power exceeds available generation") + previous_timestamp = timestamp - clock.tick_duration + previous_station_tick = db.scalar( + select(SolarReading.id).where( + SolarReading.station_id == station_id, + SolarReading.timestamp == previous_timestamp, + ) + ) + previous_grid_power = 0.0 + if previous_station_tick is not None: + previous_grid_power = ( + db.scalar( + select(func.coalesce(func.sum(EnergyReading.grid_power_kw), 0.0)) + .join(ChargingSession, EnergyReading.session_id == ChargingSession.id) + .join(Charger, ChargingSession.charger_id == Charger.id) + .where( + Charger.station_id == station_id, + EnergyReading.timestamp == previous_timestamp, + ) + ) + or 0.0 + ) + if ( + grid_total / station.grid_limit_kw >= high_demand_threshold + and previous_grid_power / station.grid_limit_kw < high_demand_threshold + ): + db.add( + Alert( + station_id=station_id, + type=AlertType.HIGH_DEMAND, + severity=AlertSeverity.WARNING, + title="High grid demand", + message=( + f"Grid import reached {grid_total:.2f} kW of the " + f"{station.grid_limit_kw:.2f} kW station limit." + ), + created_at=timestamp, + ) + ) + db.add( SolarReading( station_id=station_id, diff --git a/backend/tests/test_billing_alerts.py b/backend/tests/test_billing_alerts.py index a44c608..0db3f1f 100644 --- a/backend/tests/test_billing_alerts.py +++ b/backend/tests/test_billing_alerts.py @@ -9,13 +9,13 @@ from sqlalchemy.orm import Session from app.core.security import hash_password -from app.models.alert import Alert +from app.models.alert import Alert, AlertSeverity, AlertType from app.models.billing import Invoice, Tariff from app.models.energy import ChargingSession, ChargingSessionStatus -from app.models.infrastructure import Charger, ChargerStatus +from app.models.infrastructure import Charger, ChargerStatus, ChargingStation from app.models.user import User, UserRole from tests.conftest import ADMIN_EMAIL, ADMIN_PASSWORD -from tests.test_auth_authorization import login_headers +from tests.test_auth_authorization import add_user, login_headers from tests.test_charging_session_domain import ( create_charger, create_user_and_headers, @@ -25,6 +25,38 @@ from tests.test_energy import create_session_dependencies +@pytest.mark.anyio +async def test_alert_list_and_acknowledgement_require_admin( + client: AsyncClient, db_session: Session +) -> None: + station = ChargingStation(name="Alert station", grid_limit_kw=20, station_peak_solar_kw=10) + db_session.add(station) + db_session.flush() + alert = Alert( + station_id=station.id, + type=AlertType.HIGH_DEMAND, + severity=AlertSeverity.WARNING, + title="High demand", + message="Grid import reached the configured threshold.", + ) + db_session.add(alert) + db_session.commit() + user = add_user(db_session, email="alert-user@example.com") + user_headers = await login_headers(client, user.email) + admin_headers = dict(client.headers) + client.headers.pop("Authorization") + for method, path in ( + (client.get, "/api/v1/alerts"), + (client.patch, f"/api/v1/alerts/{alert.id}/acknowledge"), + ): + assert (await method(path)).status_code == 401 + assert (await method(path, headers=user_headers)).status_code == 403 + assert (await client.get("/api/v1/alerts", headers=admin_headers)).status_code == 200 + assert ( + await client.patch(f"/api/v1/alerts/{alert.id}/acknowledge", headers=admin_headers) + ).status_code == 200 + + @pytest.mark.parametrize( ("energy_kwh", "expected_total"), [(25, "23.00"), (0.125, "0.12"), (0, "0.00")], @@ -139,8 +171,10 @@ async def test_tariff_crud_keeps_only_latest_active(client: AsyncClient) -> None @pytest.mark.anyio async def test_only_admin_can_change_tariffs(client: AsyncClient, db_session: Session) -> None: user = User( - name="Driver", email="tariff-driver@example.com", - password_hash=hash_password("secret123"), role=UserRole.USER, + name="Driver", + email="tariff-driver@example.com", + password_hash=hash_password("secret123"), + role=UserRole.USER, ) db_session.add(user) db_session.commit() @@ -148,13 +182,15 @@ async def test_only_admin_can_change_tariffs(client: AsyncClient, db_session: Se tariff = db_session.query(Tariff).first() assert tariff is not None payload = { - "name": "Unauthorized", "price_per_kwh": "0.0100", + "name": "Unauthorized", + "price_per_kwh": "0.0100", "valid_from": "2020-01-01T00:00:00Z", } assert (await client.post("/api/v1/tariffs", headers=headers, json=payload)).status_code == 403 assert ( await client.patch( - f"/api/v1/tariffs/{tariff.id}", headers=headers, + f"/api/v1/tariffs/{tariff.id}", + headers=headers, json={"price_per_kwh": "0.0100"}, ) ).status_code == 403 @@ -173,8 +209,11 @@ async def test_database_rejects_two_active_tariffs( ) -> None: db_session.add( Tariff( - name="Duplicate", price_per_kwh=Decimal("1.0000"), currency="BRL", - is_active=True, valid_from=datetime(2020, 1, 1, tzinfo=UTC), + name="Duplicate", + price_per_kwh=Decimal("1.0000"), + currency="BRL", + is_active=True, + valid_from=datetime(2020, 1, 1, tzinfo=UTC), ) ) with pytest.raises(IntegrityError): @@ -193,9 +232,11 @@ async def test_tariff_switch_respects_validity_and_preserves_session_price( assert first.status_code == 201 admin_headers = await login_headers(client, ADMIN_EMAIL, ADMIN_PASSWORD) future = await client.post( - "/api/v1/tariffs", headers=admin_headers, + "/api/v1/tariffs", + headers=admin_headers, json={ - "name": "Future", "price_per_kwh": "1.5000", + "name": "Future", + "price_per_kwh": "1.5000", "valid_from": (datetime.now(UTC) + timedelta(days=1)).isoformat(), }, ) @@ -204,7 +245,8 @@ async def test_tariff_switch_respects_validity_and_preserves_session_price( second_charger = await create_charger(client, suffix="tariff-switch-b") assert (await start_session(client, headers, second_vehicle, second_charger)).status_code == 409 activated = await client.patch( - f"/api/v1/tariffs/{future.json()['id']}", headers=admin_headers, + f"/api/v1/tariffs/{future.json()['id']}", + headers=admin_headers, json={"valid_from": (datetime.now(UTC) - timedelta(minutes=1)).isoformat()}, ) assert activated.status_code == 200 @@ -226,6 +268,7 @@ async def test_tariff_switch_respects_validity_and_preserves_session_price( async def test_stopping_session_creates_closed_invoice_and_alert( client: AsyncClient, db_session: Session ) -> None: + admin_headers = dict(client.headers) user, vehicle, station, charger = await create_session_dependencies(client) started = await client.post( "/api/v1/sessions/start", @@ -252,15 +295,25 @@ async def test_stopping_session_creates_closed_invoice_and_alert( assert invoices[0]["closed_at"] is not None assert (await client.get(f"/api/v1/billing/invoices/{invoices[0]['id']}")).status_code == 200 - alerts = (await client.get( - "/api/v1/alerts", params={"station_id": station["id"], "acknowledged": "false"} - )).json() + alerts = ( + await client.get( + "/api/v1/alerts", + params={"station_id": station["id"], "acknowledged": "false"}, + headers=admin_headers, + ) + ).json() assert len(alerts) == 1 assert alerts[0]["type"] == "SESSION_FINISHED" assert alerts[0]["severity"] == "INFO" - acknowledged = await client.patch(f"/api/v1/alerts/{alerts[0]['id']}/acknowledge") + acknowledged = await client.patch( + f"/api/v1/alerts/{alerts[0]['id']}/acknowledge", headers=admin_headers + ) assert acknowledged.status_code == 200 assert acknowledged.json()["acknowledged_at"] is not None - again = await client.patch(f"/api/v1/alerts/{alerts[0]['id']}/acknowledge") + again = await client.patch( + f"/api/v1/alerts/{alerts[0]['id']}/acknowledge", headers=admin_headers + ) assert again.json()["acknowledged_at"] == acknowledged.json()["acknowledged_at"] - assert (await client.get("/api/v1/alerts", params={"acknowledged": "false"})).json() == [] + assert ( + await client.get("/api/v1/alerts", params={"acknowledged": "false"}, headers=admin_headers) + ).json() == [] diff --git a/backend/tests/test_simulation_tick.py b/backend/tests/test_simulation_tick.py index f6bcd15..2c414cd 100644 --- a/backend/tests/test_simulation_tick.py +++ b/backend/tests/test_simulation_tick.py @@ -10,8 +10,10 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from app.models.alert import Alert, AlertType from app.models.energy import ChargingSession, ChargingSessionStatus, EnergyReading, SolarReading from app.models.infrastructure import Charger, ChargingStation +from app.models.prediction import SystemConfiguration from app.models.user import User from app.models.vehicle import Vehicle from app.simulation.clock import SimulationClock @@ -93,6 +95,127 @@ def counts(db: Session) -> tuple[int, int]: ) +def high_demand_alerts(db: Session) -> list[Alert]: + return list(db.scalars(select(Alert).where(Alert.type == AlertType.HIGH_DEMAND))) + + +def configured_threshold(db: Session, threshold: float) -> None: + db.add( + SystemConfiguration( + simulation_speed=60, + grid_emission_factor_kg_per_kwh=0.1, + high_demand_threshold=threshold, + medium_peak_threshold=0.7, + high_peak_threshold=0.9, + ) + ) + db.commit() + + +def test_high_demand_threshold_and_new_episode(db_session: Session) -> None: + station, session = add_session(db_session) + session_id = session.id + configured_threshold(db_session, 0.5) + resolver = FixedResolver({session_id: PowerBreakdown(10, 0, 10)}) + tick_clock = clock() + + execute_tick( + db_session, + clock=tick_clock, + solar_provider=SimulationEnergyDataProvider(), + power_resolver=resolver, + ) + assert len(high_demand_alerts(db_session)) == 1 + assert high_demand_alerts(db_session)[0].station_id == station.id + assert high_demand_alerts(db_session)[0].severity.value == "WARNING" + + db_session.rollback() + execute_tick( + db_session, + clock=tick_clock, + solar_provider=SimulationEnergyDataProvider(), + power_resolver=resolver, + ) + assert len(high_demand_alerts(db_session)) == 1 + + db_session.rollback() + resolver.powers[session_id] = PowerBreakdown(10, 1, 9) + execute_tick( + db_session, + clock=tick_clock, + solar_provider=SimulationEnergyDataProvider(), + power_resolver=resolver, + ) + assert len(high_demand_alerts(db_session)) == 1 + + db_session.rollback() + resolver.powers[session_id] = PowerBreakdown(9, 1, 8) + execute_tick( + db_session, + clock=tick_clock, + solar_provider=SimulationEnergyDataProvider(), + power_resolver=resolver, + ) + assert len(high_demand_alerts(db_session)) == 1 + + db_session.rollback() + resolver.powers[session_id] = PowerBreakdown(10, 0, 10) + execute_tick( + db_session, + clock=tick_clock, + solar_provider=SimulationEnergyDataProvider(), + power_resolver=resolver, + ) + assert len(high_demand_alerts(db_session)) == 2 + + +def test_high_demand_uses_grid_import_and_default_threshold(db_session: Session) -> None: + _, session = add_session(db_session) + session_id = session.id + tick_clock = clock() + resolver = FixedResolver({session_id: PowerBreakdown(10, 10, 0)}) + execute_tick( + db_session, + clock=tick_clock, + solar_provider=SimulationEnergyDataProvider(), + power_resolver=resolver, + ) + assert high_demand_alerts(db_session) == [] + db_session.rollback() + resolver.powers[session_id] = PowerBreakdown(11, 0, 11) + execute_tick( + db_session, + clock=tick_clock, + solar_provider=SimulationEnergyDataProvider(), + power_resolver=resolver, + ) + assert high_demand_alerts(db_session) == [] + + +def test_high_demand_alert_rolls_back_with_later_station_failure(db_session: Session) -> None: + station_a, first = add_session(db_session) + station_b, second = add_session(db_session) + configured_threshold(db_session, 0.5) + ordered = sorted(((station_a.id, first.id), (station_b.id, second.id))) + resolver = FixedResolver( + { + ordered[0][1]: PowerBreakdown(10, 0, 10), + ordered[1][1]: PowerBreakdown(11, 0, 21), + } + ) + tick_clock = clock() + with pytest.raises(ValueError, match="grid power exceeds station limit"): + execute_tick( + db_session, + clock=tick_clock, + solar_provider=SimulationEnergyDataProvider(), + power_resolver=resolver, + ) + assert high_demand_alerts(db_session) == [] + assert counts(db_session) == (0, 0) + assert tick_clock.current_instant == INSTANT + + def test_empty_and_noncharging_sessions_produce_nothing(db_session: Session) -> None: resolver = FixedResolver({}) first = execute_tick( @@ -103,6 +226,7 @@ def test_empty_and_noncharging_sessions_produce_nothing(db_session: Session) -> ) assert first.energy_readings_created == 0 assert counts(db_session) == (0, 0) + assert high_demand_alerts(db_session) == [] db_session.rollback() for status in ( ChargingSessionStatus.CREATED, @@ -120,6 +244,7 @@ def test_empty_and_noncharging_sessions_produce_nothing(db_session: Session) -> ) assert result.stations_processed == 0 assert counts(db_session) == (0, 0) + assert high_demand_alerts(db_session) == [] assert not resolver.calls @@ -128,9 +253,7 @@ def test_multiple_sessions_stations_and_accumulators( ) -> None: station_a, first = add_session(db_session) _, second = add_session(db_session, station=station_a) - _, paused = add_session( - db_session, station=station_a, status=ChargingSessionStatus.PAUSED - ) + _, paused = add_session(db_session, station=station_a, status=ChargingSessionStatus.PAUSED) station_b, third = add_session(db_session) resolver = FixedResolver( { From 2eff408cbdf7472cbecefcaa6090e5c38a7245a1 Mon Sep 17 00:00:00 2001 From: Matheus Santana <130666046+mathsant-js@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:18:53 -0300 Subject: [PATCH 3/7] feat(frontend): add authentication and API integration foundation - Add typed API client for auth, sessions, energy, billing, and analytics - Persist and restore authenticated sessions - Protect admin and user routes and handle 401/403 responses - Add loading, empty, and error states with flow tests --- README.md | 2 + frontend/src/App.test.tsx | 70 ++++++++++++++++++++++++++++ frontend/src/App.tsx | 27 +++++++---- frontend/src/api/client.ts | 62 +++++++++++++++++++++--- frontend/src/auth/AuthProvider.tsx | 28 +++++++++++ frontend/src/auth/ProtectedRoute.tsx | 12 +++++ frontend/src/auth/context.ts | 12 +++++ frontend/src/layouts/AppShell.tsx | 2 +- frontend/src/pages/LoginPage.tsx | 32 +++++++++++++ frontend/src/pages/WorkspacePage.tsx | 26 +++++++++++ frontend/src/styles.css | 10 ++++ 11 files changed, 268 insertions(+), 15 deletions(-) create mode 100644 frontend/src/App.test.tsx create mode 100644 frontend/src/auth/AuthProvider.tsx create mode 100644 frontend/src/auth/ProtectedRoute.tsx create mode 100644 frontend/src/auth/context.ts create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/WorkspacePage.tsx diff --git a/README.md b/README.md index c61342f..79474a6 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ docker compose up --build Serviços: - frontend: + +O frontend usa `VITE_API_URL` (padrão: `http://localhost:8000/api/v1`). Entre com uma conta existente da API. O token é mantido no armazenamento local do navegador e validado em `/auth/me` ao recarregar; “Sair” ou uma resposta 401 remove a sessão. As rotas `/admin` e `/user` exigem o perfil correspondente. - API: - OpenAPI: - PostgreSQL: `localhost:5432` diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 0000000..d9639bb --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,70 @@ +import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { App } from './App' +import { tokenStore } from './api/client' + +const user = { id: 'u1', name: 'Ana', email: 'ana@example.com', role: 'USER', is_active: true, created_at: '', updated_at: '' } +const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }) +const show = (path = '/') => render() + +beforeEach(() => localStorage.clear()) +afterEach(() => { cleanup(); vi.restoreAllMocks() }) + +describe('authentication flow', () => { + it('logs in and shows the user workspace with an empty state', async () => { + const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async input => { + const path = String(input) + if (path.endsWith('/auth/login')) return json({ access_token: 'token', token_type: 'bearer' }) + if (path.endsWith('/auth/me')) return json(user) + if (path.endsWith('/sessions')) return json([]) + throw Error(path) + }) + show('/login') + fireEvent.change(screen.getByLabelText('E-mail'), { target: { value: user.email } }) + fireEvent.change(screen.getByLabelText('Senha'), { target: { value: 'password123' } }) + fireEvent.click(screen.getByRole('button', { name: 'Entrar' })) + expect(await screen.findByText('Nenhuma sessão encontrada.')).toBeInTheDocument() + expect(tokenStore.get()).toBe('token') + expect(new Headers(fetch.mock.calls.find(([url]) => String(url).endsWith('/auth/me'))?.[1]?.headers).get('Authorization')).toBe('Bearer token') + }) + + it('restores a saved session and prevents a USER entering admin', async () => { + tokenStore.set('saved') + vi.spyOn(globalThis, 'fetch').mockResolvedValue(json(user)) + show('/admin') + expect(await screen.findByRole('heading', { name: 'Acesso não permitido' })).toBeInTheDocument() + }) + + it('clears an expired token and returns to login', async () => { + tokenStore.set('expired') + vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({ detail: 'expired' }, 401)) + show('/user') + expect(await screen.findByRole('heading', { name: 'Entrar' })).toBeInTheDocument() + expect(tokenStore.get()).toBeNull() + }) + + it('shows login errors without saving a token', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({ detail: 'Invalid email or password' }, 401)) + show('/login') + fireEvent.change(screen.getByLabelText('E-mail'), { target: { value: user.email } }) + fireEvent.change(screen.getByLabelText('Senha'), { target: { value: 'wrong-password' } }) + fireEvent.click(screen.getByRole('button', { name: 'Entrar' })) + expect(await screen.findByRole('alert')).toHaveTextContent('E-mail ou senha inválidos.') + expect(tokenStore.get()).toBeNull() + }) + + it('shows and retries a session loading error', async () => { + tokenStore.set('saved') + let attempts = 0 + vi.spyOn(globalThis, 'fetch').mockImplementation(async input => { + if (String(input).endsWith('/auth/me')) return json({ ...user, role: 'ADMIN' }) + attempts += 1 + return attempts === 1 ? json({ detail: 'error' }, 500) : json([]) + }) + show('/admin') + expect(await screen.findByRole('alert')).toHaveTextContent('Não foi possível carregar as sessões.') + fireEvent.click(screen.getByRole('button', { name: 'Tentar novamente' })) + await waitFor(() => expect(screen.getByText('Nenhuma sessão encontrada.')).toBeInTheDocument()) + }) +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3e6a0c1..19991d3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,23 @@ import { Navigate, Route, Routes } from 'react-router-dom' +import { AuthProvider } from './auth/AuthProvider' +import { useAuth } from './auth/context' +import { ProtectedRoute } from './auth/ProtectedRoute' +import { LoginPage } from './pages/LoginPage' +import { WorkspacePage } from './pages/WorkspacePage' +import { AppShell } from './layouts/AppShell' -import { FoundationPage } from './pages/FoundationPage' - +function Home() { + const { state } = useAuth() + if (state.status === 'loading') return

Verificando sessão...

+ return +} export function App() { - return ( - - } /> - } /> - - ) + return + } /> + } /> + }>} /> + }>} /> +

Acesso não permitido

Seu perfil não tem acesso a esta área.

Voltar
} /> + } /> +
} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9e77b60..ec4b4b9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,15 +1,65 @@ const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000/api/v1' +const TOKEN_KEY = 'chargegrid.access_token' -export interface HealthResponse { - status: 'ok' +export type Role = 'ADMIN' | 'USER' +export interface User { id: string; name: string; email: string; role: Role; is_active: boolean; created_at: string; updated_at: string } +export interface TokenResponse { access_token: string; token_type: 'bearer' } +export interface ChargingSession { id: string; user_id: string; vehicle_id: string; charger_id: string; status: 'CREATED' | 'CHARGING' | 'PAUSED' | 'COMPLETED' | 'CANCELLED'; started_at: string | null; ended_at: string | null; requested_power_kw: number; allocated_power_kw: number; energy_consumed_kwh: number; solar_energy_kwh: number; grid_energy_kwh: number; tariff_per_kwh: string; total_cost: string; created_at: string; updated_at: string } +export interface EnergyReading { id: string; session_id: string; timestamp: string; requested_power_kw: number; allocated_power_kw: number; solar_power_kw: number; grid_power_kw: number; interval_energy_kwh: number; solar_energy_kwh: number; grid_energy_kwh: number } +export interface Invoice { id: string; session_id: string; user_id: string; energy_kwh: string; tariff_per_kwh: string; subtotal: string; total: string; status: 'OPEN' | 'CLOSED' | 'CANCELLED'; created_at: string; closed_at: string | null } +export interface Dashboard { station_id: string | null; user_id: string | null; session_count: number; completed_session_count: number; energy_consumed_kwh: number; solar_energy_kwh: number; grid_energy_kwh: number; billed_total: string; currency: string } +export interface Sustainability { station_id: string | null; user_id: string | null; energy_consumed_kwh: number; solar_energy_kwh: number; grid_energy_kwh: number; solar_percentage: number; avoided_co2_kg: number; grid_emission_factor_kg_per_kwh: number; estimated_solar_savings: string; currency: string } +export interface Filters { station_id?: string; user_id?: string; from?: string; to?: string } + +export class ApiError extends Error { + constructor(public status: number, message: string) { super(message); this.name = 'ApiError' } } -export async function getHealth(signal?: AbortSignal): Promise { - const response = await fetch(`${API_URL}/health`, { signal }) +export const tokenStore = { + get: () => localStorage.getItem(TOKEN_KEY), + set: (token: string) => { localStorage.setItem(TOKEN_KEY, token); window.dispatchEvent(new Event('chargegrid:auth')) }, + clear: () => { localStorage.removeItem(TOKEN_KEY); window.dispatchEvent(new Event('chargegrid:auth')) }, +} + +function query(filters?: Filters): string { + const params = new URLSearchParams() + if (filters) Object.entries(filters).forEach(([key, value]) => { if (value !== undefined) params.set(key, value) }) + return params.size ? `?${params}` : '' +} +async function request(path: string, options: RequestInit = {}, authenticated = true): Promise { + const headers = new Headers(options.headers) + if (options.body) headers.set('Content-Type', 'application/json') + const token = authenticated ? tokenStore.get() : null + if (token) headers.set('Authorization', `Bearer ${token}`) + let response: Response + try { response = await fetch(`${API_URL}${path}`, { ...options, headers }) } + catch { throw new ApiError(0, 'Não foi possível conectar à API.') } + if (response.status === 401 && authenticated && token && tokenStore.get() === token) tokenStore.clear() if (!response.ok) { - throw new Error(`API indisponível (${response.status})`) + const body: unknown = await response.json().catch(() => null) + const detail = typeof body === 'object' && body !== null && 'detail' in body && typeof body.detail === 'string' ? body.detail : undefined + throw new ApiError(response.status, response.status === 403 ? 'Acesso não permitido.' : detail ?? `Erro HTTP ${response.status}`) } + return response.json() as Promise +} + +export const api = { + login: (email: string, password: string) => request('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }, false), + me: () => request('/auth/me'), + sessions: () => request('/sessions'), + session: (id: string) => request(`/sessions/${encodeURIComponent(id)}`), + startSession: (vehicle_id: string, charger_id: string) => request('/sessions/start', { method: 'POST', body: JSON.stringify({ vehicle_id, charger_id }) }), + stopSession: (id: string) => request(`/sessions/${encodeURIComponent(id)}/stop`, { method: 'POST' }), + currentEnergy: (station_id?: string) => request(`/energy/current${query({ station_id })}`), + energyHistory: (filters?: Filters) => request(`/energy/history${query(filters)}`), + invoices: (filters?: { user_id?: string; status?: string }) => request(`/billing/invoices${query(filters)}`), + invoice: (id: string) => request(`/billing/invoices/${encodeURIComponent(id)}`), + dashboard: (filters?: Filters) => request(`/analytics/dashboard${query(filters)}`), + sustainability: (filters?: Filters) => request(`/analytics/sustainability${query(filters)}`), +} - return response.json() as Promise +export interface HealthResponse { status: 'ok' } +export async function getHealth(signal?: AbortSignal): Promise { + return request('/health', { signal }, false) } diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx new file mode 100644 index 0000000..2f979a6 --- /dev/null +++ b/frontend/src/auth/AuthProvider.tsx @@ -0,0 +1,28 @@ +import { useEffect, useState, type PropsWithChildren } from 'react' +import { api, tokenStore } from '../api/client' +import { AuthContext, type AuthState } from './context' + +export function AuthProvider({ children }: PropsWithChildren) { + const [state, setState] = useState({ status: tokenStore.get() ? 'loading' : 'anonymous' }) + + useEffect(() => { + let active = true + if (tokenStore.get()) { + api.me().then(user => { if (active) setState({ status: 'authenticated', user }) }) + .catch(() => { if (active) { tokenStore.clear(); setState({ status: 'anonymous' }) } }) + } + const sync = () => { if (!tokenStore.get()) setState({ status: 'anonymous' }) } + window.addEventListener('chargegrid:auth', sync) + window.addEventListener('storage', sync) + return () => { active = false; window.removeEventListener('chargegrid:auth', sync); window.removeEventListener('storage', sync) } + }, []) + + async function login(email: string, password: string) { + const token = await api.login(email, password) + tokenStore.set(token.access_token) + try { const user = await api.me(); setState({ status: 'authenticated', user }) } + catch (error) { tokenStore.clear(); throw error } + } + function logout() { tokenStore.clear(); setState({ status: 'anonymous' }) } + return {children} +} diff --git a/frontend/src/auth/ProtectedRoute.tsx b/frontend/src/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..31d75d2 --- /dev/null +++ b/frontend/src/auth/ProtectedRoute.tsx @@ -0,0 +1,12 @@ +import { Navigate, Outlet, useLocation } from 'react-router-dom' +import { useAuth } from './context' +import type { Role } from '../api/client' + +export function ProtectedRoute({ role }: { role: Role }) { + const { state } = useAuth() + const location = useLocation() + if (state.status === 'loading') return

Verificando sessão...

+ if (state.status === 'anonymous') return + if (state.user.role !== role) return + return +} diff --git a/frontend/src/auth/context.ts b/frontend/src/auth/context.ts new file mode 100644 index 0000000..d1f7447 --- /dev/null +++ b/frontend/src/auth/context.ts @@ -0,0 +1,12 @@ +import { createContext, useContext } from 'react' +import type { User } from '../api/client' + +export type AuthState = { status: 'loading' } | { status: 'anonymous' } | { status: 'authenticated'; user: User } +export interface AuthContextValue { state: AuthState; login: (email: string, password: string) => Promise; logout: () => void } +export const AuthContext = createContext(null) + +export function useAuth(): AuthContextValue { + const value = useContext(AuthContext) + if (!value) throw new Error('AuthProvider ausente') + return value +} diff --git a/frontend/src/layouts/AppShell.tsx b/frontend/src/layouts/AppShell.tsx index ffd3c4d..5aa708a 100644 --- a/frontend/src/layouts/AppShell.tsx +++ b/frontend/src/layouts/AppShell.tsx @@ -8,7 +8,7 @@ export function AppShell({ children }: PropsWithChildren) { CG ChargeGrid Intelligence - Fase 1 · Fundação + ChargeGrid Intelligence
{children}
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..68f5ab5 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,32 @@ +import { useState, type FormEvent } from 'react' +import { Navigate, useLocation, useNavigate } from 'react-router-dom' +import { ApiError } from '../api/client' +import { useAuth } from '../auth/context' +import { AppShell } from '../layouts/AppShell' + +export function LoginPage() { + const { state, login } = useAuth() + const navigate = useNavigate() + const location = useLocation() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + if (state.status === 'authenticated') return + async function submit(event: FormEvent) { + event.preventDefault(); setBusy(true); setError('') + try { + await login(email, password) + const from = (location.state as { from?: string } | null)?.from + navigate(from?.startsWith('/') && !from.startsWith('//') ? from : '/', { replace: true }) + } catch (cause) { + setError(cause instanceof ApiError && cause.status === 401 ? 'E-mail ou senha inválidos.' : 'Não foi possível entrar. Tente novamente.') + } finally { setBusy(false) } + } + return

Entrar

+ + + {error &&

{error}

} + +
+} diff --git a/frontend/src/pages/WorkspacePage.tsx b/frontend/src/pages/WorkspacePage.tsx new file mode 100644 index 0000000..e49e96e --- /dev/null +++ b/frontend/src/pages/WorkspacePage.tsx @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' +import { api, ApiError, type ChargingSession, type Role } from '../api/client' +import { useAuth } from '../auth/context' +import { AppShell } from '../layouts/AppShell' + +export function WorkspacePage({ role }: { role: Role }) { + const { state, logout } = useAuth() + const [sessions, setSessions] = useState(null) + const [error, setError] = useState('') + const [attempt, setAttempt] = useState(0) + useEffect(() => { + let active = true + api.sessions().then(items => { if (active) setSessions(items) }).catch(cause => { + if (active) setError(cause instanceof ApiError && cause.status === 403 ? 'Acesso não permitido.' : 'Não foi possível carregar as sessões.') + }) + return () => { active = false } + }, [attempt]) + return
+

{role === 'ADMIN' ? 'Administração' : 'Área do usuário'}

Olá, {state.status === 'authenticated' ? state.user.name : ''}

+

Sessões de recarga

+ {error ?

{error}

+ : sessions === null ?

Carregando sessões...

+ : sessions.length === 0 ?

Nenhuma sessão encontrada.

+ :
    {sessions.map(session =>
  • {session.status} · {session.energy_consumed_kwh} kWh
  • )}
} +
+} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6a2f687..0f9a28b 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -68,3 +68,13 @@ a { color: inherit; text-decoration: none; } .brand > span:last-child { font-size: 0.9rem; } .grid { grid-template-columns: 1fr; } } +.panel { max-width: 760px; margin: 5rem auto; padding: 2rem; border: 1px solid #275343; border-radius: 1rem; background: #0c271e; } +.panel h1 { margin-top: 0; } +.panel__heading { display: flex; justify-content: space-between; align-items: start; gap: 1rem; } +.panel form { display: grid; gap: 1rem; } +.panel label { display: grid; gap: 0.4rem; } +.panel input { width: 100%; padding: 0.8rem; border: 1px solid #47705d; border-radius: 0.5rem; color: #e8f5ef; background: #071713; } +.panel button { padding: 0.75rem 1.1rem; border: 0; border-radius: 0.5rem; color: #062019; background: #43e7a3; font-weight: 700; cursor: pointer; } +.panel button:disabled { opacity: 0.6; cursor: wait; } +.panel [role="alert"] { color: #ffb2a9; } +@media (max-width: 760px) { .panel { margin: 2rem 1rem; } } From 64d6aa8fda6dcb7b3b7fcbbcf5acd9ca71917847 Mon Sep 17 00:00:00 2001 From: Matheus Santana <130666046+mathsant-js@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:30:41 -0300 Subject: [PATCH 4/7] feat(frontend): add admin dashboard with live analytics and alerts - Connect KPIs, charts, history, and filters to the existing APIs. - Show peak risk only when a valid demand forecast is available. - Enable alert acknowledgement and cover loading, error, and empty states with tests. --- README.md | 2 + frontend/src/App.test.tsx | 4 +- frontend/src/App.tsx | 3 +- frontend/src/api/client.ts | 11 +++ frontend/src/components/DashboardCard.tsx | 5 ++ .../src/pages/AdminDashboardPage.test.tsx | 89 +++++++++++++++++++ frontend/src/pages/AdminDashboardPage.tsx | 84 +++++++++++++++++ frontend/src/styles.css | 30 +++++++ 8 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/DashboardCard.tsx create mode 100644 frontend/src/pages/AdminDashboardPage.test.tsx create mode 100644 frontend/src/pages/AdminDashboardPage.tsx diff --git a/README.md b/README.md index 79474a6..089d832 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Serviços: - frontend: O frontend usa `VITE_API_URL` (padrão: `http://localhost:8000/api/v1`). Entre com uma conta existente da API. O token é mantido no armazenamento local do navegador e validado em `/auth/me` ao recarregar; “Sair” ou uma resposta 401 remove a sessão. As rotas `/admin` e `/user` exigem o perfil correspondente. + +Em `/admin`, o gestor pode filtrar o dashboard por estação e período, consultar indicadores, gráficos, histórico e alertas, e reconhecer alertas pela API. A previsão e o risco de pico aparecem somente para uma estação com previsão futura válida; na ausência de dados de ML, a tela mostra um estado informativo. - API: - OpenAPI: - PostgreSQL: `localhost:5432` diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index d9639bb..1bf32b3 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -58,11 +58,11 @@ describe('authentication flow', () => { tokenStore.set('saved') let attempts = 0 vi.spyOn(globalThis, 'fetch').mockImplementation(async input => { - if (String(input).endsWith('/auth/me')) return json({ ...user, role: 'ADMIN' }) + if (String(input).endsWith('/auth/me')) return json(user) attempts += 1 return attempts === 1 ? json({ detail: 'error' }, 500) : json([]) }) - show('/admin') + show('/user') expect(await screen.findByRole('alert')).toHaveTextContent('Não foi possível carregar as sessões.') fireEvent.click(screen.getByRole('button', { name: 'Tentar novamente' })) await waitFor(() => expect(screen.getByText('Nenhuma sessão encontrada.')).toBeInTheDocument()) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 19991d3..c91f26b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { ProtectedRoute } from './auth/ProtectedRoute' import { LoginPage } from './pages/LoginPage' import { WorkspacePage } from './pages/WorkspacePage' import { AppShell } from './layouts/AppShell' +import { AdminDashboardPage } from './pages/AdminDashboardPage' function Home() { const { state } = useAuth() @@ -15,7 +16,7 @@ export function App() { return } /> } /> - }>} /> + }>} /> }>} />

Acesso não permitido

Seu perfil não tem acesso a esta área.

Voltar
} /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ec4b4b9..7b6831c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -10,6 +10,11 @@ export interface Invoice { id: string; session_id: string; user_id: string; ener export interface Dashboard { station_id: string | null; user_id: string | null; session_count: number; completed_session_count: number; energy_consumed_kwh: number; solar_energy_kwh: number; grid_energy_kwh: number; billed_total: string; currency: string } export interface Sustainability { station_id: string | null; user_id: string | null; energy_consumed_kwh: number; solar_energy_kwh: number; grid_energy_kwh: number; solar_percentage: number; avoided_co2_kg: number; grid_emission_factor_kg_per_kwh: number; estimated_solar_savings: string; currency: string } export interface Filters { station_id?: string; user_id?: string; from?: string; to?: string } +export interface Station { id: string; name: string; grid_limit_kw: number; station_peak_solar_kw: number; is_active: boolean } +export interface Charger { id: string; station_id: string; name: string; status: 'AVAILABLE' | 'CHARGING' | 'UNAVAILABLE'; is_active: boolean } +export interface SolarReading { id: string; station_id: string; timestamp: string; available_power_kw: number } +export interface DemandPrediction { id: string; station_id: string; generated_at: string; prediction_for: string; predicted_demand_kw: number; capacity_kw: number; risk_level: 'LOW' | 'MEDIUM' | 'HIGH'; prediction_horizon_minutes: number; model_version: string } +export interface Alert { id: string; station_id: string; type: string; severity: 'INFO' | 'WARNING' | 'CRITICAL'; title: string; message: string; created_at: string; acknowledged_at: string | null } export class ApiError extends Error { constructor(public status: number, message: string) { super(message); this.name = 'ApiError' } @@ -57,6 +62,12 @@ export const api = { invoice: (id: string) => request(`/billing/invoices/${encodeURIComponent(id)}`), dashboard: (filters?: Filters) => request(`/analytics/dashboard${query(filters)}`), sustainability: (filters?: Filters) => request(`/analytics/sustainability${query(filters)}`), + stations: () => request('/stations'), + chargers: () => request('/chargers'), + solarHistory: (filters?: Filters) => request(`/solar/history${query(filters)}`), + prediction: (station_id: string) => request(`/predictions/demand${query({ station_id })}`), + alerts: (station_id?: string) => request(`/alerts${query({ station_id })}`), + acknowledgeAlert: (id: string) => request(`/alerts/${encodeURIComponent(id)}/acknowledge`, { method: 'PATCH' }), } export interface HealthResponse { status: 'ok' } diff --git a/frontend/src/components/DashboardCard.tsx b/frontend/src/components/DashboardCard.tsx new file mode 100644 index 0000000..6f51676 --- /dev/null +++ b/frontend/src/components/DashboardCard.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from 'react' + +export function DashboardCard({ label, value, detail }: { label: string; value: ReactNode; detail?: string }) { + return

{label}

{value}{detail &&

{detail}

}
+} diff --git a/frontend/src/pages/AdminDashboardPage.test.tsx b/frontend/src/pages/AdminDashboardPage.test.tsx new file mode 100644 index 0000000..31eb739 --- /dev/null +++ b/frontend/src/pages/AdminDashboardPage.test.tsx @@ -0,0 +1,89 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { App } from '../App' +import { tokenStore } from '../api/client' + +const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }) +const admin = { id: 'u1', name: 'Ana', email: 'ana@example.com', role: 'ADMIN', is_active: true, created_at: '', updated_at: '' } +const station = { id: 's1', name: 'Estação Central', grid_limit_kw: 60, station_peak_solar_kw: 20, is_active: true } +const session = { id: 'se1', user_id: 'u1', charger_id: 'c1', status: 'CHARGING', allocated_power_kw: 20, energy_consumed_kwh: 5, total_cost: '0', created_at: '', updated_at: '' } +const reading = { id: 'r1', session_id: 'se1', timestamp: '2026-09-17T12:00:00Z', allocated_power_kw: 20, solar_power_kw: 8, grid_power_kw: 12 } +const alert = { id: 'a1', station_id: 's1', title: 'Demanda elevada', message: 'Verifique a rede', severity: 'WARNING', type: 'HIGH_DEMAND', created_at: '2026-09-17T12:00:00Z', acknowledged_at: null } +const summary = { session_count: 1, completed_session_count: 0, energy_consumed_kwh: 5, billed_total: '0.00' } +const sustainability = { solar_energy_kwh: 2, avoided_co2_kg: 0.8 } +const prediction = { id: 'p1', station_id: 's1', generated_at: new Date().toISOString(), prediction_for: new Date(Date.now() + 3600000).toISOString(), predicted_demand_kw: 54, capacity_kw: 60, risk_level: 'HIGH', prediction_horizon_minutes: 60, model_version: 'baseline' } + +function mockApi(withPrediction = false) { + return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const url = new URL(String(input)) + if (url.pathname.endsWith('/auth/me')) return json(admin) + if (url.pathname.endsWith('/stations')) return json([station]) + if (url.pathname.endsWith('/chargers')) return json([{ id: 'c1', station_id: 's1', name: 'C1', status: 'CHARGING', is_active: true }]) + if (url.pathname.endsWith('/sessions')) return json([session]) + if (url.pathname.endsWith('/billing/invoices')) return json([]) + if (url.pathname.endsWith('/energy/history')) return json([reading]) + if (url.pathname.endsWith('/solar/history')) return json([{ id: 'sol1', station_id: 's1', timestamp: '2026-09-17T12:00:00Z', available_power_kw: 14 }]) + if (url.pathname.endsWith('/analytics/dashboard')) return json(summary) + if (url.pathname.endsWith('/analytics/sustainability')) return json(sustainability) + if (url.pathname.endsWith('/alerts')) return json([alert]) + if (url.pathname.endsWith('/predictions/demand')) return withPrediction ? json(prediction) : json({ detail: 'Prediction not found' }, 404) + if (url.pathname.endsWith('/alerts/a1/acknowledge') && init?.method === 'PATCH') return json({ ...alert, acknowledged_at: new Date().toISOString() }) + throw Error(String(input)) + }) +} + +function show() { tokenStore.set('token'); render() } +beforeEach(() => localStorage.clear()) +afterEach(() => { cleanup(); vi.restoreAllMocks() }) + +describe('admin dashboard', () => { + it('renders live indicators, history and a clear missing ML state', async () => { + mockApi() + show() + expect(await screen.findByRole('heading', { name: 'Dashboard do gestor' })).toBeInTheDocument() + expect(await screen.findByText('Ainda não há dados válidos de ML para esta estação.')).toBeInTheDocument() + expect(screen.getAllByText('20 kW').length).toBeGreaterThan(0) + expect(screen.getByText('60 kW')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Histórico recente' })).toBeInTheDocument() + expect(screen.queryByText('Risco de pico')).not.toBeInTheDocument() + }) + + it('shows a valid prediction and acknowledges alerts through the API', async () => { + const fetch = mockApi(true) + show() + expect(await screen.findByText('Risco de pico')).toBeInTheDocument() + expect(screen.getByText('54 kW')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Reconhecer' })) + expect(await screen.findByText('Reconhecido')).toBeInTheDocument() + expect(fetch.mock.calls.some(([url, init]) => String(url).endsWith('/alerts/a1/acknowledge') && init?.method === 'PATCH')).toBe(true) + }) + + it('applies station and period filters', async () => { + const fetch = mockApi() + show() + await screen.findByText('Ainda não há dados válidos de ML para esta estação.') + fireEvent.change(screen.getByLabelText('Período'), { target: { value: '24h' } }) + await waitFor(() => expect(fetch.mock.calls.some(([url]) => String(url).includes('/analytics/dashboard?') && String(url).includes('from='))).toBe(true)) + fireEvent.change(screen.getByLabelText('Estação'), { target: { value: 's1' } }) + await waitFor(() => expect(fetch.mock.calls.some(([url]) => String(url).includes('/analytics/dashboard?') && String(url).includes('station_id=s1'))).toBe(true)) + }) + + it('offers retry on load failure and preserves alert on acknowledgement failure', async () => { + const fetch = mockApi() + let failed = false + const original = fetch.getMockImplementation()! + fetch.mockImplementation(async (input, init) => { + if (String(input).endsWith('/analytics/dashboard') && !failed) { failed = true; return json({}, 500) } + if (String(input).endsWith('/alerts/a1/acknowledge')) return json({}, 500) + return original(input, init) + }) + show() + expect(await screen.findByRole('alert')).toHaveTextContent('Não foi possível carregar o dashboard.') + fireEvent.click(screen.getByRole('button', { name: 'Tentar novamente' })) + expect(await screen.findByText('Demanda elevada')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Reconhecer' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Não foi possível reconhecer o alerta.') + expect(screen.getByRole('button', { name: 'Reconhecer' })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/AdminDashboardPage.tsx b/frontend/src/pages/AdminDashboardPage.tsx new file mode 100644 index 0000000..0a75294 --- /dev/null +++ b/frontend/src/pages/AdminDashboardPage.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react' +import { Area, AreaChart, Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' +import { api, ApiError, type Alert, type Charger, type ChargingSession, type Dashboard, type DemandPrediction, type EnergyReading, type Invoice, type SolarReading, type Station, type Sustainability } from '../api/client' +import { useAuth } from '../auth/context' +import { DashboardCard } from '../components/DashboardCard' +import { AppShell } from '../layouts/AppShell' + +type Data = { stations: Station[]; chargers: Charger[]; sessions: ChargingSession[]; invoices: Invoice[]; readings: EnergyReading[]; currentReadings: EnergyReading[]; solar: SolarReading[]; summary: Dashboard; sustainability: Sustainability; alerts: Alert[]; prediction: DemandPrediction | null; predictionError: boolean } +const number = (value: number) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(value) +const money = (value: string) => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(value)) +const time = (value: string) => new Date(value).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' }) +const active = (session: ChargingSession) => session.status === 'CHARGING' || session.status === 'PAUSED' +const validPrediction = (prediction: DemandPrediction | null, stationId: string) => prediction && prediction.station_id === stationId && Date.parse(prediction.prediction_for) > Date.now() && Number.isFinite(prediction.predicted_demand_kw) && prediction.predicted_demand_kw >= 0 && Number.isFinite(prediction.capacity_kw) && prediction.capacity_kw > 0 && ['LOW', 'MEDIUM', 'HIGH'].includes(prediction.risk_level) + +export function AdminDashboardPage() { + const { state, logout } = useAuth() + const [stationId, setStationId] = useState('') + const [period, setPeriod] = useState('all') + const [attempt, setAttempt] = useState(0) + const [data, setData] = useState(null) + const [error, setError] = useState('') + const [actionError, setActionError] = useState('') + const [pendingAlert, setPendingAlert] = useState(null) + + useEffect(() => { + let live = true + const from = period === '24h' ? new Date(Date.now() - 86400000).toISOString() : period === '7d' ? new Date(Date.now() - 7 * 86400000).toISOString() : undefined + const filters = { station_id: stationId || undefined, from } + const readingsPromise = api.energyHistory(filters) + Promise.all([api.stations(), api.chargers(), api.sessions(), api.invoices({ status: 'CLOSED' }), readingsPromise, from ? api.energyHistory({ station_id: stationId || undefined }) : readingsPromise, api.solarHistory({ station_id: stationId || undefined }), api.dashboard(filters), api.sustainability(filters), api.alerts(stationId || undefined)]).then(async ([stations, chargers, sessions, invoices, readings, currentReadings, solar, summary, sustainability, alerts]) => { + const predictionStation = stationId || (stations.length === 1 ? stations[0].id : '') + let prediction: DemandPrediction | null = null + let predictionError = false + if (predictionStation) { + try { prediction = await api.prediction(predictionStation) } + catch (cause) { if (!(cause instanceof ApiError && cause.status === 404)) predictionError = true } + } + if (live) setData({ stations, chargers, sessions, invoices, readings, currentReadings, solar, summary, sustainability, alerts, prediction, predictionError }) + }).catch(() => { if (live) setError('Não foi possível carregar o dashboard.') }) + return () => { live = false } + }, [stationId, period, attempt]) + + async function acknowledge(id: string) { + setPendingAlert(id) + setActionError('') + try { + const updated = await api.acknowledgeAlert(id) + setData(previous => previous && { ...previous, alerts: previous.alerts.map(alert => alert.id === id ? updated : alert) }) + } catch { setActionError('Não foi possível reconhecer o alerta. Tente novamente.') } + finally { setPendingAlert(null) } + } + + const chargerMap = new Map(data?.chargers.map(charger => [charger.id, charger]) ?? []) + const shownSessions = data?.sessions.filter(session => !stationId || chargerMap.get(session.charger_id)?.station_id === stationId) ?? [] + const currentSessions = shownSessions.filter(active) + const activeIds = new Set(currentSessions.map(session => session.id)) + const latest = new Map() + for (const reading of data?.currentReadings ?? []) if (activeIds.has(reading.session_id) && (!latest.has(reading.session_id) || Date.parse(reading.timestamp) > Date.parse(latest.get(reading.session_id)!.timestamp))) latest.set(reading.session_id, reading) + const demand = currentSessions.reduce((total, session) => total + session.allocated_power_kw, 0) + const grid = [...latest.values()].reduce((total, reading) => total + reading.grid_power_kw, 0) + const solarUse = [...latest.values()].reduce((total, reading) => total + reading.solar_power_kw, 0) + const limit = data?.stations.filter(station => !stationId || station.id === stationId).reduce((total, station) => total + station.grid_limit_kw, 0) ?? 0 + const currentSolar = new Map() + for (const reading of data?.solar ?? []) if (!currentSolar.has(reading.station_id) || Date.parse(reading.timestamp) > Date.parse(currentSolar.get(reading.station_id)!.timestamp)) currentSolar.set(reading.station_id, reading) + const solarAvailable = [...currentSolar.values()].reduce((total, reading) => total + reading.available_power_kw, 0) + const predictionStation = stationId || (data?.stations.length === 1 ? data.stations[0].id : '') + const prediction = data?.prediction && validPrediction(data.prediction, predictionStation) ? data.prediction : null + const history = (data?.readings ?? []).map(reading => ({ label: time(reading.timestamp), demand: reading.allocated_power_kw, solar: reading.solar_power_kw, grid: reading.grid_power_kw })) + const periodStart = period === '24h' ? Date.now() - 86400000 : period === '7d' ? Date.now() - 7 * 86400000 : 0 + const shownSessionIds = new Set(shownSessions.map(session => session.id)) + const invoiceHistory = (data?.invoices ?? []).filter(invoice => shownSessionIds.has(invoice.session_id) && invoice.closed_at && Date.parse(invoice.closed_at) >= periodStart).map(invoice => ({ label: invoice.closed_at ? time(invoice.closed_at) : '—', total: Number(invoice.total) })) + + return
+

Administração

Dashboard do gestor

Olá, {state.status === 'authenticated' ? state.user.name : ''}

+
+ {error ?

{error}

: !data ?

Carregando dashboard...

: <> +
session.charger_id)).size} />{prediction && <>}
+ {!prediction &&

{data.predictionError ? 'Previsão indisponível no momento.' : !predictionStation ? 'Selecione uma estação para consultar a previsão de demanda.' : 'Ainda não há dados válidos de ML para esta estação.'}

} +

Demanda ao longo do tempo

{history.length ? :

Sem histórico de demanda no período.

}

Solar vs rede

{history.length ? :

Sem dados de energia no período.

}

Sessões

{data.summary.session_count} iniciadas · {data.summary.completed_session_count} concluídas no período

    {shownSessions.slice(-8).reverse().map(session =>
  • {session.status}{number(session.energy_consumed_kwh)} kWh
  • )}
{!shownSessions.length &&

Nenhuma sessão encontrada.

}

Faturamento

{invoiceHistory.length ? money(String(value))} /> :

Sem faturamento no período.

}
+

Histórico recente

{history.length ?
{(data.readings ?? []).slice(-10).reverse().map(reading => )}
DataDemandaSolarRede
{time(reading.timestamp)}{number(reading.allocated_power_kw)} kW{number(reading.solar_power_kw)} kW{number(reading.grid_power_kw)} kW
:

Nenhum registro no período.

}
+

Alertas recentes

{actionError &&

{actionError}

}{data.alerts.length ?
    {data.alerts.slice(0, 10).map(alert =>
  • {alert.title}

    {alert.message}

    {time(alert.created_at)} · {alert.severity}
    {alert.acknowledged_at ? Reconhecido : }
  • )}
:

Nenhum alerta recente.

}
+ } +
+} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 0f9a28b..bcb6853 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -78,3 +78,33 @@ a { color: inherit; text-decoration: none; } .panel button:disabled { opacity: 0.6; cursor: wait; } .panel [role="alert"] { color: #ffb2a9; } @media (max-width: 760px) { .panel { margin: 2rem 1rem; } } +.dashboard { max-width: 1200px; margin: 0 auto; padding: 2rem; } +.dashboard-heading, .dashboard-filters { display: flex; align-items: end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +.dashboard-heading h1 { font-size: clamp(2rem, 4vw, 3.5rem); margin: .4rem 0; } +.dashboard-heading p:last-child { color: #a9c7ba; } +.dashboard-filters { justify-content: start; margin: 2rem 0; } +.dashboard-filters label { display: grid; gap: .4rem; color: #a9c7ba; } +.dashboard select, .dashboard button { padding: .7rem 1rem; border-radius: .55rem; border: 1px solid #47705d; color: #e8f5ef; background: #103529; } +.dashboard button { cursor: pointer; background: #43e7a3; color: #062019; font-weight: 700; } +.dashboard button:disabled { opacity: .6; cursor: wait; } +.kpi-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1rem; } +.dashboard-card, .chart-panel, .empty-note, .dashboard-error { border: 1px solid #275343; border-radius: 1rem; background: #0c271e; padding: 1.35rem; } +.dashboard-card h3 { margin: 0 0 .8rem; color: #a9c7ba; font-size: .9rem; font-weight: 500; } +.dashboard-card strong { font-size: 1.7rem; } +.dashboard-card p { margin: .5rem 0 0; color: #a9c7ba; font-size: .8rem; } +.empty-note { margin: 1rem 0; } +.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; margin-top: 1rem; } +.chart-panel { min-width: 0; margin-top: 1rem; } +.chart-grid .chart-panel { margin-top: 0; } +.chart-panel h2 { margin: 0 0 1.2rem; font-size: 1.25rem; } +.chart-panel > p { color: #a9c7ba; } +.session-list, .alert-list { list-style: none; padding: 0; } +.session-list li, .alert-list li { display: flex; justify-content: space-between; gap: 1rem; padding: .75rem 0; border-bottom: 1px solid #275343; } +.alert-list p { margin: .4rem 0; } +.alert-list small { color: #a9c7ba; } +.table-wrap { overflow-x: auto; } +.dashboard table { width: 100%; border-collapse: collapse; text-align: left; } +.dashboard th, .dashboard td { padding: .7rem; border-bottom: 1px solid #275343; white-space: nowrap; } +.dashboard [role="alert"] { color: #ffb2a9; } +@media (max-width: 900px) { .kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 650px) { .dashboard { padding: 1rem; } .chart-grid, .kpi-grid { grid-template-columns: 1fr; } } From 4a103cc5d82ea71ee4d60f952ef334bd992b65b5 Mon Sep 17 00:00:00 2001 From: Matheus Santana <130666046+mathsant-js@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:36:43 -0300 Subject: [PATCH 5/7] feat: add user charging dashboard with scoped session and invoice data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show the current charging session, session history, and invoices with loading, empty, and error states. Calculate in-progress cost estimates on the backend using the session’s captured tariff, while using the closed invoice as the final cost. Add access and session flow tests. --- README.md | 1 + backend/app/api/router.py | 2 + backend/app/api/routes/user_dashboard.py | 17 ++++ backend/app/schemas/user_dashboard.py | 29 +++++++ backend/app/services/user_dashboard.py | 86 +++++++++++++++++++ backend/tests/test_user_dashboard.py | 83 ++++++++++++++++++ docs/PHASE_6_ANALYTICS.md | 6 ++ frontend/src/App.test.tsx | 14 +-- frontend/src/App.tsx | 4 +- frontend/src/api/client.ts | 3 + frontend/src/pages/UserDashboardPage.test.tsx | 39 +++++++++ frontend/src/pages/UserDashboardPage.tsx | 35 ++++++++ frontend/src/styles.css | 1 + 13 files changed, 311 insertions(+), 9 deletions(-) create mode 100644 backend/app/api/routes/user_dashboard.py create mode 100644 backend/app/schemas/user_dashboard.py create mode 100644 backend/app/services/user_dashboard.py create mode 100644 backend/tests/test_user_dashboard.py create mode 100644 frontend/src/pages/UserDashboardPage.test.tsx create mode 100644 frontend/src/pages/UserDashboardPage.tsx diff --git a/README.md b/README.md index 089d832..a0cd4fc 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Serviços: O frontend usa `VITE_API_URL` (padrão: `http://localhost:8000/api/v1`). Entre com uma conta existente da API. O token é mantido no armazenamento local do navegador e validado em `/auth/me` ao recarregar; “Sair” ou uma resposta 401 remove a sessão. As rotas `/admin` e `/user` exigem o perfil correspondente. Em `/admin`, o gestor pode filtrar o dashboard por estação e período, consultar indicadores, gráficos, histórico e alertas, e reconhecer alertas pela API. A previsão e o risco de pico aparecem somente para uma estação com previsão futura válida; na ausência de dados de ML, a tela mostra um estado informativo. +Em `/user`, o usuário consulta a recarga atual, o histórico de sessões e as invoices. O custo durante a recarga é uma estimativa; o valor fechado vem da invoice. Os dados são limitados ao usuário autenticado pela API. - API: - OpenAPI: - PostgreSQL: `localhost:5432` diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 23566eb..95c3892 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -12,6 +12,7 @@ from app.api.routes.simulation import router as simulation_router from app.api.routes.solar import router as solar_router from app.api.routes.stations import router as stations_router +from app.api.routes.user_dashboard import router as user_dashboard_router from app.api.routes.users import router as users_router from app.api.routes.vehicles import router as vehicles_router @@ -19,6 +20,7 @@ api_router.include_router(health_router) api_router.include_router(auth_router) api_router.include_router(users_router) +api_router.include_router(user_dashboard_router) api_router.include_router(vehicles_router) api_router.include_router(stations_router) api_router.include_router(chargers_router) diff --git a/backend/app/api/routes/user_dashboard.py b/backend/app/api/routes/user_dashboard.py new file mode 100644 index 0000000..ac45cb7 --- /dev/null +++ b/backend/app/api/routes/user_dashboard.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter + +from app.api.dependencies import RegularUser +from app.api.routes.common import FORBIDDEN_RESPONSE, UNAUTHORIZED_RESPONSE, DbSession +from app.schemas.user_dashboard import UserDashboardResponse +from app.services.user_dashboard import get_user_dashboard + +router = APIRouter(prefix="/user", tags=["user dashboard"]) + + +@router.get( + "/dashboard", + response_model=UserDashboardResponse, + responses=UNAUTHORIZED_RESPONSE | FORBIDDEN_RESPONSE, +) +async def user_dashboard(db: DbSession, current_user: RegularUser) -> UserDashboardResponse: + return get_user_dashboard(db, current_user.id) diff --git a/backend/app/schemas/user_dashboard.py b/backend/app/schemas/user_dashboard.py new file mode 100644 index 0000000..b802354 --- /dev/null +++ b/backend/app/schemas/user_dashboard.py @@ -0,0 +1,29 @@ +from datetime import datetime +from decimal import Decimal +from uuid import UUID + +from pydantic import BaseModel + +from app.models.energy import ChargingSessionStatus +from app.schemas.billing import InvoiceResponse + + +class UserSessionSummary(BaseModel): + id: UUID + status: ChargingSessionStatus + vehicle_name: str + charger_name: str + started_at: datetime | None + ended_at: datetime | None + duration_seconds: int + allocated_power_kw: float + energy_consumed_kwh: float + solar_percentage: float + estimated_cost: Decimal | None + invoice_total: Decimal | None + + +class UserDashboardResponse(BaseModel): + current_session: UserSessionSummary | None + session_history: list[UserSessionSummary] + invoices: list[InvoiceResponse] diff --git a/backend/app/services/user_dashboard.py b/backend/app/services/user_dashboard.py new file mode 100644 index 0000000..198cf78 --- /dev/null +++ b/backend/app/services/user_dashboard.py @@ -0,0 +1,86 @@ +from datetime import UTC, datetime +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.billing import Invoice, InvoiceStatus +from app.models.energy import ChargingSession, ChargingSessionStatus +from app.models.infrastructure import Charger +from app.models.vehicle import Vehicle +from app.schemas.billing import InvoiceResponse +from app.schemas.user_dashboard import UserDashboardResponse, UserSessionSummary +from app.services.billing import calculate_pay_per_use_total + +ACTIVE = { + ChargingSessionStatus.CREATED, + ChargingSessionStatus.CHARGING, + ChargingSessionStatus.PAUSED, +} + + +def get_user_dashboard( + db: Session, user_id: UUID, now: datetime | None = None +) -> UserDashboardResponse: + now = now or datetime.now(UTC) + sessions = db.execute( + select(ChargingSession, Vehicle.name, Charger.name) + .join(Vehicle, ChargingSession.vehicle_id == Vehicle.id) + .join(Charger, ChargingSession.charger_id == Charger.id) + .where(ChargingSession.user_id == user_id, Vehicle.user_id == user_id) + .order_by(ChargingSession.created_at.desc(), ChargingSession.id.desc()) + ).all() + invoices = list( + db.scalars( + select(Invoice) + .where(Invoice.user_id == user_id) + .order_by(Invoice.created_at.desc(), Invoice.id.desc()) + ).all() + ) + closed = { + invoice.session_id: invoice + for invoice in invoices + if invoice.status == InvoiceStatus.CLOSED + } + + def summarize( + session: ChargingSession, vehicle_name: str, charger_name: str + ) -> UserSessionSummary: + start = session.started_at + end = session.ended_at or now + if start is not None and start.tzinfo is None: + start = start.replace(tzinfo=UTC) + if end.tzinfo is None: + end = end.replace(tzinfo=UTC) + invoice = closed.get(session.id) + return UserSessionSummary( + id=session.id, + status=session.status, + vehicle_name=vehicle_name, + charger_name=charger_name, + started_at=start, + ended_at=session.ended_at, + duration_seconds=max(0, int((end - start).total_seconds())) if start else 0, + allocated_power_kw=session.allocated_power_kw, + energy_consumed_kwh=session.energy_consumed_kwh, + solar_percentage=( + 100 * session.solar_energy_kwh / session.energy_consumed_kwh + if session.energy_consumed_kwh > 0 + else 0 + ), + estimated_cost=( + calculate_pay_per_use_total(session.energy_consumed_kwh, session.tariff_per_kwh) + if session.status in ACTIVE + else None + ), + invoice_total=invoice.total if invoice else None, + ) + + summaries = [summarize(session, vehicle, charger) for session, vehicle, charger in sessions] + current = next((item for item in summaries if item.status in ACTIVE), None) + history = [item for item in summaries if item.status not in ACTIVE] + return UserDashboardResponse( + current_session=current, + session_history=history, + invoices=[InvoiceResponse.model_validate(invoice) for invoice in invoices], + ) diff --git a/backend/tests/test_user_dashboard.py b/backend/tests/test_user_dashboard.py new file mode 100644 index 0000000..04dadc1 --- /dev/null +++ b/backend/tests/test_user_dashboard.py @@ -0,0 +1,83 @@ +from decimal import Decimal +from uuid import UUID + +import pytest +from httpx import AsyncClient +from sqlalchemy.orm import Session + +from app.models.billing import Invoice, Tariff +from app.models.energy import ChargingSession +from tests.test_charging_session_domain import ( + create_charger, + create_user_and_headers, + create_vehicle, + start_session, +) + + +@pytest.mark.anyio +async def test_dashboard_session_flow_and_user_scope( + client: AsyncClient, db_session: Session +) -> None: + _, first_headers = await create_user_and_headers(client, suffix="dashboard-a") + first_vehicle = await create_vehicle(client, first_headers, suffix="dashboard-a") + first_charger = await create_charger(client, suffix="dashboard-a") + first = await start_session(client, first_headers, first_vehicle, first_charger) + assert first.status_code == 201 + first_id = UUID(first.json()["id"]) + + _, second_headers = await create_user_and_headers(client, suffix="dashboard-b") + second_vehicle = await create_vehicle(client, second_headers, suffix="dashboard-b") + second_charger = await create_charger(client, suffix="dashboard-b") + second = await start_session(client, second_headers, second_vehicle, second_charger) + assert second.status_code == 201 + + session = db_session.get(ChargingSession, first_id) + assert session is not None + session.energy_consumed_kwh = 10 + session.solar_energy_kwh = 4 + session.grid_energy_kwh = 6 + tariff = db_session.query(Tariff).filter_by(is_active=True).one() + tariff.price_per_kwh = Decimal("2.0000") + db_session.commit() + + current = await client.get("/api/v1/user/dashboard", headers=first_headers) + assert current.status_code == 200 + body = current.json() + assert body["current_session"]["id"] == str(first_id) + assert body["current_session"]["vehicle_name"] == first_vehicle["name"] + assert body["current_session"]["charger_name"] == first_charger["name"] + assert body["current_session"]["solar_percentage"] == 40 + assert body["current_session"]["estimated_cost"] == "9.20" + assert body["current_session"]["invoice_total"] is None + assert body["current_session"]["duration_seconds"] >= 0 + assert body["session_history"] == body["invoices"] == [] + + stopped = await client.post(f"/api/v1/sessions/{first_id}/stop", headers=first_headers) + assert stopped.status_code == 200 + invoice = db_session.query(Invoice).filter_by(session_id=first_id).one() + invoice.total = Decimal("8.75") + db_session.commit() + finished = (await client.get("/api/v1/user/dashboard", headers=first_headers)).json() + assert finished["current_session"] is None + assert finished["session_history"][0]["invoice_total"] == "8.75" + assert finished["session_history"][0]["estimated_cost"] is None + assert finished["invoices"][0]["total"] == "8.75" + second_view = (await client.get("/api/v1/user/dashboard", headers=second_headers)).json() + assert second_view["current_session"]["id"] == second.json()["id"] + assert second_view["session_history"] == second_view["invoices"] == [] + second_stop = await client.post( + f"/api/v1/sessions/{second.json()['id']}/stop", headers=second_headers + ) + assert second_stop.status_code == 200 + first_view = (await client.get("/api/v1/user/dashboard", headers=first_headers)).json() + assert [item["id"] for item in first_view["session_history"]] == [str(first_id)] + assert [item["id"] for item in first_view["invoices"]] == [str(invoice.id)] + + +@pytest.mark.anyio +async def test_dashboard_requires_regular_user(client: AsyncClient) -> None: + assert (await client.get("/api/v1/user/dashboard")).status_code == 403 + assert ( + await client.get("/api/v1/user/dashboard", headers={"Authorization": "Bearer invalid"}) + ).status_code == 401 diff --git a/docs/PHASE_6_ANALYTICS.md b/docs/PHASE_6_ANALYTICS.md index 2f03f50..526fe52 100644 --- a/docs/PHASE_6_ANALYTICS.md +++ b/docs/PHASE_6_ANALYTICS.md @@ -13,3 +13,9 @@ Retorna `station_id`, `user_id`, `session_count`, `completed_session_count`, `en Retorna os filtros efetivos, as três energias, `solar_percentage`, `avoided_co2_kg`, `grid_emission_factor_kg_per_kwh`, `estimated_solar_savings` e `currency` (`BRL`). O percentual é `solar_energy_kwh / energy_consumed_kwh × 100`, ou zero quando o consumo é zero. CO₂ evitado é energia solar vezes `GRID_EMISSION_FACTOR_KG_PER_KWH`; a configuração tem padrão explícito `0.0` e deve ser definida para obter um indicador de CO₂ diferente de zero. A economia estimada soma a energia solar de cada leitura multiplicada pela tarifa capturada na respectiva sessão; o total é arredondado para centavos. É uma estimativa teórica da compra evitada da rede, não economia financeira real. As respostas não incluem previsões de demanda, classificação de pico nem séries derivadas de ML. + +## Dashboard do usuário + +`GET /api/v1/user/dashboard` exige JWT de perfil USER e retorna a sessão atual, o histórico de sessões encerradas e as invoices do usuário autenticado. O servidor filtra sessões e invoices pelo ID do token; não aceita ID de usuário por parâmetro. Veículo e carregador são apresentados pelo nome. A duração considera o horário atual para sessões em andamento e `ended_at` para sessões encerradas. O percentual solar usa os acumuladores da sessão e é zero quando a energia consumida é zero. + +`estimated_cost` aparece somente na sessão em andamento e usa a energia acumulada multiplicada pela tarifa capturada no início da sessão, com arredondamento para centavos. `invoice_total` aparece somente quando há invoice fechada e é a fonte do custo final. A interface identifica explicitamente o valor durante a recarga como estimativa. diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 1bf32b3..d37180b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -12,19 +12,19 @@ beforeEach(() => localStorage.clear()) afterEach(() => { cleanup(); vi.restoreAllMocks() }) describe('authentication flow', () => { - it('logs in and shows the user workspace with an empty state', async () => { + it('logs in and shows the user dashboard with an empty state', async () => { const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async input => { const path = String(input) if (path.endsWith('/auth/login')) return json({ access_token: 'token', token_type: 'bearer' }) if (path.endsWith('/auth/me')) return json(user) - if (path.endsWith('/sessions')) return json([]) + if (path.endsWith('/user/dashboard')) return json({ current_session: null, session_history: [], invoices: [] }) throw Error(path) }) show('/login') fireEvent.change(screen.getByLabelText('E-mail'), { target: { value: user.email } }) fireEvent.change(screen.getByLabelText('Senha'), { target: { value: 'password123' } }) fireEvent.click(screen.getByRole('button', { name: 'Entrar' })) - expect(await screen.findByText('Nenhuma sessão encontrada.')).toBeInTheDocument() + expect(await screen.findByText('Nenhuma recarga em andamento.')).toBeInTheDocument() expect(tokenStore.get()).toBe('token') expect(new Headers(fetch.mock.calls.find(([url]) => String(url).endsWith('/auth/me'))?.[1]?.headers).get('Authorization')).toBe('Bearer token') }) @@ -54,17 +54,17 @@ describe('authentication flow', () => { expect(tokenStore.get()).toBeNull() }) - it('shows and retries a session loading error', async () => { + it('shows and retries a dashboard loading error', async () => { tokenStore.set('saved') let attempts = 0 vi.spyOn(globalThis, 'fetch').mockImplementation(async input => { if (String(input).endsWith('/auth/me')) return json(user) attempts += 1 - return attempts === 1 ? json({ detail: 'error' }, 500) : json([]) + return attempts === 1 ? json({ detail: 'error' }, 500) : json({ current_session: null, session_history: [], invoices: [] }) }) show('/user') - expect(await screen.findByRole('alert')).toHaveTextContent('Não foi possível carregar as sessões.') + expect(await screen.findByRole('alert')).toHaveTextContent('Não foi possível carregar o dashboard.') fireEvent.click(screen.getByRole('button', { name: 'Tentar novamente' })) - await waitFor(() => expect(screen.getByText('Nenhuma sessão encontrada.')).toBeInTheDocument()) + await waitFor(() => expect(screen.getByText('Nenhuma recarga em andamento.')).toBeInTheDocument()) }) }) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c91f26b..1e5a94f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,7 @@ import { AuthProvider } from './auth/AuthProvider' import { useAuth } from './auth/context' import { ProtectedRoute } from './auth/ProtectedRoute' import { LoginPage } from './pages/LoginPage' -import { WorkspacePage } from './pages/WorkspacePage' +import { UserDashboardPage } from './pages/UserDashboardPage' import { AppShell } from './layouts/AppShell' import { AdminDashboardPage } from './pages/AdminDashboardPage' @@ -17,7 +17,7 @@ export function App() { } /> } /> }>} /> - }>} /> + }>} />

Acesso não permitido

Seu perfil não tem acesso a esta área.

Voltar
} /> } />
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7b6831c..38da442 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -7,6 +7,8 @@ export interface TokenResponse { access_token: string; token_type: 'bearer' } export interface ChargingSession { id: string; user_id: string; vehicle_id: string; charger_id: string; status: 'CREATED' | 'CHARGING' | 'PAUSED' | 'COMPLETED' | 'CANCELLED'; started_at: string | null; ended_at: string | null; requested_power_kw: number; allocated_power_kw: number; energy_consumed_kwh: number; solar_energy_kwh: number; grid_energy_kwh: number; tariff_per_kwh: string; total_cost: string; created_at: string; updated_at: string } export interface EnergyReading { id: string; session_id: string; timestamp: string; requested_power_kw: number; allocated_power_kw: number; solar_power_kw: number; grid_power_kw: number; interval_energy_kwh: number; solar_energy_kwh: number; grid_energy_kwh: number } export interface Invoice { id: string; session_id: string; user_id: string; energy_kwh: string; tariff_per_kwh: string; subtotal: string; total: string; status: 'OPEN' | 'CLOSED' | 'CANCELLED'; created_at: string; closed_at: string | null } +export interface UserSessionSummary { id: string; status: ChargingSession['status']; vehicle_name: string; charger_name: string; started_at: string | null; ended_at: string | null; duration_seconds: number; allocated_power_kw: number; energy_consumed_kwh: number; solar_percentage: number; estimated_cost: string | null; invoice_total: string | null } +export interface UserDashboard { current_session: UserSessionSummary | null; session_history: UserSessionSummary[]; invoices: Invoice[] } export interface Dashboard { station_id: string | null; user_id: string | null; session_count: number; completed_session_count: number; energy_consumed_kwh: number; solar_energy_kwh: number; grid_energy_kwh: number; billed_total: string; currency: string } export interface Sustainability { station_id: string | null; user_id: string | null; energy_consumed_kwh: number; solar_energy_kwh: number; grid_energy_kwh: number; solar_percentage: number; avoided_co2_kg: number; grid_emission_factor_kg_per_kwh: number; estimated_solar_savings: string; currency: string } export interface Filters { station_id?: string; user_id?: string; from?: string; to?: string } @@ -53,6 +55,7 @@ export const api = { login: (email: string, password: string) => request('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }, false), me: () => request('/auth/me'), sessions: () => request('/sessions'), + userDashboard: () => request('/user/dashboard'), session: (id: string) => request(`/sessions/${encodeURIComponent(id)}`), startSession: (vehicle_id: string, charger_id: string) => request('/sessions/start', { method: 'POST', body: JSON.stringify({ vehicle_id, charger_id }) }), stopSession: (id: string) => request(`/sessions/${encodeURIComponent(id)}/stop`, { method: 'POST' }), diff --git a/frontend/src/pages/UserDashboardPage.test.tsx b/frontend/src/pages/UserDashboardPage.test.tsx new file mode 100644 index 0000000..f1b8aee --- /dev/null +++ b/frontend/src/pages/UserDashboardPage.test.tsx @@ -0,0 +1,39 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { App } from '../App' +import { tokenStore } from '../api/client' + +const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }) +const user = { id: 'u1', name: 'Bia', email: 'bia@example.com', role: 'USER', is_active: true, created_at: '', updated_at: '' } +const current = { id: 's1', status: 'CHARGING', vehicle_name: 'Meu EV', charger_name: 'CH-01', started_at: '2026-09-17T12:00:00Z', ended_at: null, duration_seconds: 3660, allocated_power_kw: 11, energy_consumed_kwh: 10, solar_percentage: 40, estimated_cost: '9.20', invoice_total: null } + +function show() { tokenStore.set('token'); render() } +beforeEach(() => localStorage.clear()) +afterEach(() => { cleanup(); vi.restoreAllMocks() }) + +it('shows the active estimate, completed invoice amount and histories', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async input => String(input).endsWith('/auth/me') ? json(user) : json({ current_session: current, session_history: [{ ...current, id: 's0', status: 'COMPLETED', estimated_cost: null, invoice_total: '8.75' }], invoices: [{ id: 'i0', session_id: 's0', status: 'CLOSED', closed_at: '2026-09-17T13:00:00Z', energy_kwh: '10', total: '8.75' }] })) + show() + expect(await screen.findByText('Meu EV · CH-01 · Carregando')).toBeInTheDocument() + expect(screen.getByText('Custo estimado')).toBeInTheDocument() + expect(screen.getByText(/tarifa capturada no início/)).toBeInTheDocument() + expect(screen.getAllByText('R$ 8,75')).toHaveLength(2) + expect(screen.getAllByText('1h 01min')).toHaveLength(2) +}) + +it('shows loading, empty and retry states', async () => { + let calls = 0 + vi.spyOn(globalThis, 'fetch').mockImplementation(async input => { + if (String(input).endsWith('/auth/me')) return json(user) + calls++ + return calls === 1 ? json({}, 500) : json({ current_session: null, session_history: [], invoices: [] }) + }) + show() + expect(await screen.findByText('Carregando dashboard...')).toBeInTheDocument() + expect(await screen.findByRole('alert')).toHaveTextContent('Não foi possível carregar o dashboard.') + fireEvent.click(screen.getByRole('button', { name: 'Tentar novamente' })) + expect(await screen.findByText('Nenhuma recarga em andamento.')).toBeInTheDocument() + expect(screen.getByText('Nenhuma sessão anterior.')).toBeInTheDocument() + expect(screen.getByText('Nenhuma invoice disponível.')).toBeInTheDocument() +}) diff --git a/frontend/src/pages/UserDashboardPage.tsx b/frontend/src/pages/UserDashboardPage.tsx new file mode 100644 index 0000000..6816d50 --- /dev/null +++ b/frontend/src/pages/UserDashboardPage.tsx @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react' +import { api, type UserDashboard, type UserSessionSummary } from '../api/client' +import { useAuth } from '../auth/context' +import { DashboardCard } from '../components/DashboardCard' +import { AppShell } from '../layouts/AppShell' + +const number = (value: number) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(value) +const money = (value: string) => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(value)) +const date = (value: string | null) => value ? new Date(value).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' }) : '—' +const duration = (seconds: number) => `${Math.floor(seconds / 3600)}h ${String(Math.floor(seconds % 3600 / 60)).padStart(2, '0')}min` +const statusLabel: Record = { CREATED: 'Criada', CHARGING: 'Carregando', PAUSED: 'Pausada', COMPLETED: 'Concluída', CANCELLED: 'Cancelada' } + +export function UserDashboardPage() { + const { state, logout } = useAuth() + const [data, setData] = useState(null) + const [error, setError] = useState(false) + const [attempt, setAttempt] = useState(0) + + useEffect(() => { + let live = true + api.userDashboard().then(result => { if (live) setData(result) }).catch(() => { if (live) setError(true) }) + return () => { live = false } + }, [attempt]) + + function reload() { setData(null); setError(false); setAttempt(value => value + 1) } + const current = data?.current_session + return
+

Área do usuário

Minha recarga

Olá, {state.status === 'authenticated' ? state.user.name : ''}

+ {error ?

Não foi possível carregar o dashboard.

: !data ?

Carregando dashboard...

: <> +

Sessão atual

{current ? <>

{current.vehicle_name} · {current.charger_name} · {statusLabel[current.status]}

:

Nenhuma recarga em andamento.

}
+

Histórico de sessões

{data.session_history.length ?
{data.session_history.map(session => )}
InícioVeículoCarregadorStatusDuraçãoEnergiaSolarCusto final
{date(session.started_at)}{session.vehicle_name}{session.charger_name}{statusLabel[session.status]}{duration(session.duration_seconds)}{number(session.energy_consumed_kwh)} kWh{number(session.solar_percentage)}%{session.invoice_total === null ? '—' : money(session.invoice_total)}
:

Nenhuma sessão anterior.

}
+

Invoices

{data.invoices.length ?
{data.invoices.map(invoice => )}
DataSessãoStatusEnergiaValor final
{date(invoice.closed_at ?? invoice.created_at)}{invoice.session_id}{invoice.status === 'CLOSED' ? 'Fechada' : invoice.status === 'OPEN' ? 'Aberta' : 'Cancelada'}{number(Number(invoice.energy_kwh))} kWh{invoice.status === 'CLOSED' ? money(invoice.total) : '—'}
:

Nenhuma invoice disponível.

}
+ } +
+} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index bcb6853..71acb70 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -80,6 +80,7 @@ a { color: inherit; text-decoration: none; } @media (max-width: 760px) { .panel { margin: 2rem 1rem; } } .dashboard { max-width: 1200px; margin: 0 auto; padding: 2rem; } .dashboard-heading, .dashboard-filters { display: flex; align-items: end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +.dashboard-actions { display: flex; gap: .6rem; } .dashboard-heading h1 { font-size: clamp(2rem, 4vw, 3.5rem); margin: .4rem 0; } .dashboard-heading p:last-child { color: #a9c7ba; } .dashboard-filters { justify-content: start; margin: 2rem 0; } From 61b1d4c0e60bf3d84fcdd39ef8cf5e6403248995 Mon Sep 17 00:00:00 2001 From: Matheus Santana <130666046+mathsant-js@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:48:11 -0300 Subject: [PATCH 6/7] test: verify Phase 6 golden path and correct dashboard charts Add an end-to-end test covering power redistribution, solar usage, alerts, billing, and dashboard updates. Aggregate concurrent energy readings by tick in the admin charts, and document the delivered scope and remaining limits. --- README.md | 15 +++ backend/tests/test_phase_6_flow.py | 147 ++++++++++++++++++++++ docs/PHASE_6.md | 51 ++++++++ frontend/src/pages/AdminDashboardPage.tsx | 3 +- frontend/src/pages/energyHistory.test.ts | 17 +++ frontend/src/pages/energyHistory.ts | 15 +++ 6 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_phase_6_flow.py create mode 100644 docs/PHASE_6.md create mode 100644 frontend/src/pages/energyHistory.test.ts create mode 100644 frontend/src/pages/energyHistory.ts diff --git a/README.md b/README.md index a0cd4fc..6c028e0 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ O alvo executa testes, lint e verificação de tipos no backend e no frontend, a - [Fase 4 — Gestão energética (concluída)](docs/PHASE_4.md) - [Fase 5 — Billing e histórico de invoices (concluída)](docs/PHASE_5.md) - [Fase 6 — Contratos da API de analytics](docs/PHASE_6_ANALYTICS.md) +- [Fase 6 — Integração e limites](docs/PHASE_6.md) ## Estado atual @@ -119,3 +120,17 @@ alerta na mesma transação. O histórico de invoices está disponível em com acesso restrito às próprias invoices para usuários comuns. Consulte [a validação da Fase 5](docs/PHASE_5.md) para os critérios de aceite e testes. Listagem e reconhecimento de alertas em `/api/v1/alerts` exigem papel ADMIN. + +A integração da Fase 6 cobre os dashboards administrativo e do usuário, os +gráficos de demanda/solar/rede e faturamento, alertas e os indicadores de +sustentabilidade. Um teste integrado percorre início de sessões, três ticks, +redistribuição de potência, prioridade solar, alerta, encerramento, invoice e +atualização das respostas dos dashboards. Os gráficos do gestor somam leituras +simultâneas para mostrar a demanda total de cada tick. Veja os resultados e +limites em [docs/PHASE_6.md](docs/PHASE_6.md). + +A Fase 6 ainda não está concluída: o KPI obrigatório de risco de pico depende +de uma previsão futura válida, e o fluxo automático de previsão/classificação +da Fase 7 ainda não existe. Sem esses dados, a tela mostra um estado +informativo. O simulador também depende de ticks manuais pela API; não há +seed reproduzível oficial para a demonstração completa. diff --git a/backend/tests/test_phase_6_flow.py b/backend/tests/test_phase_6_flow.py new file mode 100644 index 0000000..e01cffe --- /dev/null +++ b/backend/tests/test_phase_6_flow.py @@ -0,0 +1,147 @@ +"""Golden Path integration through the public API, without ML training.""" + +from decimal import Decimal +from uuid import UUID + +import pytest +from httpx import AsyncClient +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.main import app +from app.models.infrastructure import ChargingStation +from app.models.prediction import SystemConfiguration +from app.simulation.clock import SimulationClock +from app.simulation.control import SimulationController, get_simulation_controller +from tests.test_charging_session_domain import ( + create_user_and_headers, + create_vehicle, + start_session, +) +from tests.test_simulation_api import INSTANT + + +@pytest.fixture +def control() -> SimulationController: + value = SimulationController(SimulationClock(initial_instant=INSTANT)) + + async def override() -> SimulationController: + return value + + app.dependency_overrides[get_simulation_controller] = override + yield value + app.dependency_overrides.pop(get_simulation_controller, None) + + +@pytest.mark.anyio +async def test_phase_6_golden_path( + client: AsyncClient, db_session: Session, control: SimulationController, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GRID_EMISSION_FACTOR_KG_PER_KWH", "0.4") + get_settings.cache_clear() + station = (await client.post( + "/api/v1/stations", + json={"name": "Golden Path", "grid_limit_kw": 60, "station_peak_solar_kw": 0}, + )).json() + db_session.add(SystemConfiguration( + simulation_speed=60, + grid_emission_factor_kg_per_kwh=0.4, + high_demand_threshold=0.85, + medium_peak_threshold=0.7, + high_peak_threshold=0.9, + )) + db_session.commit() + for index in range(4): + suffix = f"golden-{index}" + user, headers = await create_user_and_headers(client, suffix=suffix) + vehicle = await create_vehicle(client, headers, suffix=suffix, max_power_kw=20) + charger = (await client.post("/api/v1/chargers", json={ + "station_id": station["id"], "name": f"CH-0{index + 1}", + "code": f"GOLDEN-0{index + 1}", "max_power_kw": 22, + })).json() + if index == 3: + fourth = (user, headers, vehicle, charger) + break + started = await start_session(client, headers, vehicle, charger) + assert started.status_code == 201 + + assert (await client.post("/api/v1/simulation/start")).status_code == 200 + assert (await client.post("/api/v1/simulation/ticks")).json()["energy_readings_created"] == 3 + first_readings = (await client.get("/api/v1/energy/history", params={ + "station_id": station["id"], + })).json() + assert len(first_readings) == 3 + assert all( + row["allocated_power_kw"] == 20 and row["grid_power_kw"] == 20 + for row in first_readings + ) + alerts = (await client.get("/api/v1/alerts", params={"station_id": station["id"]})).json() + assert len([item for item in alerts if item["type"] == "HIGH_DEMAND"]) == 1 + + user, headers, vehicle, charger = fourth + started = await start_session(client, headers, vehicle, charger) + assert started.status_code == 201 + fourth_id = started.json()["id"] + assert (await client.post("/api/v1/simulation/ticks")).json()["energy_readings_created"] == 4 + redistributed = (await client.get("/api/v1/energy/history", params={ + "station_id": station["id"], + })).json()[3:] + assert len(redistributed) == 4 + assert all(row["allocated_power_kw"] == pytest.approx(15) for row in redistributed) + assert sum(row["grid_power_kw"] for row in redistributed) == pytest.approx(60) + db_session.rollback() + station_model = db_session.get(ChargingStation, UUID(station["id"])) + assert station_model is not None + station_model.station_peak_solar_kw = 20 + db_session.commit() + assert (await client.post("/api/v1/simulation/ticks")).json()["energy_readings_created"] == 4 + readings = (await client.get("/api/v1/energy/history", params={ + "station_id": station["id"], + })).json() + latest = readings[7:] + assert len(latest) == 4 + assert sum(row["allocated_power_kw"] for row in latest) == pytest.approx(80, abs=0.01) + assert sum(row["solar_power_kw"] for row in latest) == pytest.approx(20, abs=0.01) + assert sum(row["grid_power_kw"] for row in latest) == pytest.approx(60, abs=0.01) + assert all(row["allocated_power_kw"] <= 20 for row in latest) + + before = (await client.get("/api/v1/analytics/dashboard", params={ + "station_id": station["id"], + })).json() + assert before["session_count"] == 4 + assert before["completed_session_count"] == 0 + assert before["billed_total"] == "0.00" + assert before["energy_consumed_kwh"] == pytest.approx(200 / 60, abs=0.0001) + assert before["solar_energy_kwh"] == pytest.approx(20 / 60, abs=0.001) + assert before["grid_energy_kwh"] == pytest.approx(180 / 60, abs=0.001) + + user_current = (await client.get("/api/v1/user/dashboard", headers=headers)).json() + assert user_current["current_session"]["id"] == fourth_id + assert user_current["current_session"]["energy_consumed_kwh"] > 0 + stopped = await client.post(f"/api/v1/sessions/{fourth_id}/stop", headers=headers) + assert stopped.status_code == 200 + assert stopped.json()["status"] == "COMPLETED" + assert (await client.get(f"/api/v1/chargers/{charger['id']}")).json()["status"] == "AVAILABLE" + invoices = (await client.get("/api/v1/billing/invoices", headers=headers)).json() + assert len(invoices) == 1 + assert invoices[0]["status"] == "CLOSED" + assert Decimal(invoices[0]["total"]) == Decimal("0.54") + user_after = (await client.get("/api/v1/user/dashboard", headers=headers)).json() + assert user_after["current_session"] is None + assert user_after["session_history"][0]["invoice_total"] == invoices[0]["total"] + assert user_after["invoices"][0]["id"] == invoices[0]["id"] + after = (await client.get("/api/v1/analytics/dashboard", params={ + "station_id": station["id"], + })).json() + assert after["completed_session_count"] == 1 + assert after["billed_total"] == invoices[0]["total"] + sustainability = (await client.get("/api/v1/analytics/sustainability", params={ + "station_id": station["id"], + })).json() + assert sustainability["avoided_co2_kg"] == pytest.approx(20 / 60 * 0.4, abs=0.001) + assert sustainability["solar_percentage"] == pytest.approx(10, abs=0.01) + assert len((await client.get("/api/v1/alerts", params={ + "station_id": station["id"], + })).json()) >= 2 + get_settings.cache_clear() diff --git a/docs/PHASE_6.md b/docs/PHASE_6.md new file mode 100644 index 0000000..fde82f6 --- /dev/null +++ b/docs/PHASE_6.md @@ -0,0 +1,51 @@ +# Fase 6 — Integração e limites + +**Estado: parcial.** Os dashboards, gráficos, alertas e indicadores de +sustentabilidade estão implementados. O requisito obrigatório de risco de pico +do dashboard administrativo ainda depende do pipeline de previsão da Fase 7. +Não há treinamento de ML nesta entrega. + +## Entregue + +- Dashboard administrativo com filtros de estação e período, KPIs de demanda, + rede, solar, sessões, faturamento e CO₂, gráficos, histórico e alertas. + Leituras das sessões no mesmo instante são somadas nos gráficos para mostrar + a demanda e a divisão solar/rede da instalação por tick. +- Dashboard do usuário com sessão atual, estimativa durante a recarga, histórico + e invoices fechadas, restritos ao usuário autenticado. +- APIs de analytics e sustentabilidade descritas em + [PHASE_6_ANALYTICS.md](PHASE_6_ANALYTICS.md). +- Alerta `HIGH_DEMAND` por episódio de importação elevada e + `SESSION_FINISHED` no encerramento; reconhecimento administrativo pela API. + +## Cenário integrado executado + +`backend/tests/test_phase_6_flow.py` usa os endpoints públicos e um relógio +simulado fixo. Três sessões de 20 kW fazem um tick sem solar: 60 kW da rede e +um alerta `HIGH_DEMAND`. A quarta sessão começa e outro tick sem solar +redistribui a potência para 15 kW por sessão. A disponibilidade solar é então +configurada para 20 kW e o terceiro tick aloca 20 kW por sessão: 80 kW ao +todo, cerca de 20 kW solares e 60 kW da rede. A soma de energia nos três ticks +é cerca de 3,333 kWh. Ao encerrar a quarta sessão, o carregador fica +disponível, a invoice `CLOSED` é de R$ 0,54 e os dashboards passam a mostrar +uma sessão concluída, o faturamento e o histórico do usuário. A API de +sustentabilidade retorna a fração solar e o CO₂ evitado com fator de emissão +configurado em 0,4 kg/kWh para o teste. + +## Limites restantes + +- O KPI de risco de pico só aparece quando há uma previsão futura válida para + a estação. O pipeline que produz previsões e classificações é trabalho da + Fase 7; por isso o Golden Path completo ainda não foi demonstrado. +- Os ticks são acionados manualmente por `POST /api/v1/simulation/ticks`. +- O cenário acima é reproduzível como teste automatizado; não há seed oficial + nem roteiro operacional pronto para uma demonstração manual completa. +- Os filtros de período aplicam-se às séries e indicadores da API; as listas de + sessões e alertas da tela administrativa ainda são históricas. + +## Validação + +O teste integrado e os testes de interface cobrem o fluxo e a agregação dos +gráficos. `make check` verifica lint, tipos, testes e build. A geração de +`/openapi.json` é testada. Não houve alteração de schema nesta integração, +portanto não foi criada migration. diff --git a/frontend/src/pages/AdminDashboardPage.tsx b/frontend/src/pages/AdminDashboardPage.tsx index 0a75294..2457e59 100644 --- a/frontend/src/pages/AdminDashboardPage.tsx +++ b/frontend/src/pages/AdminDashboardPage.tsx @@ -4,6 +4,7 @@ import { api, ApiError, type Alert, type Charger, type ChargingSession, type Das import { useAuth } from '../auth/context' import { DashboardCard } from '../components/DashboardCard' import { AppShell } from '../layouts/AppShell' +import { aggregateEnergyHistory } from './energyHistory' type Data = { stations: Station[]; chargers: Charger[]; sessions: ChargingSession[]; invoices: Invoice[]; readings: EnergyReading[]; currentReadings: EnergyReading[]; solar: SolarReading[]; summary: Dashboard; sustainability: Sustainability; alerts: Alert[]; prediction: DemandPrediction | null; predictionError: boolean } const number = (value: number) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(value) @@ -65,7 +66,7 @@ export function AdminDashboardPage() { const solarAvailable = [...currentSolar.values()].reduce((total, reading) => total + reading.available_power_kw, 0) const predictionStation = stationId || (data?.stations.length === 1 ? data.stations[0].id : '') const prediction = data?.prediction && validPrediction(data.prediction, predictionStation) ? data.prediction : null - const history = (data?.readings ?? []).map(reading => ({ label: time(reading.timestamp), demand: reading.allocated_power_kw, solar: reading.solar_power_kw, grid: reading.grid_power_kw })) + const history = aggregateEnergyHistory(data?.readings ?? []).map(point => ({ ...point, label: time(point.timestamp) })) const periodStart = period === '24h' ? Date.now() - 86400000 : period === '7d' ? Date.now() - 7 * 86400000 : 0 const shownSessionIds = new Set(shownSessions.map(session => session.id)) const invoiceHistory = (data?.invoices ?? []).filter(invoice => shownSessionIds.has(invoice.session_id) && invoice.closed_at && Date.parse(invoice.closed_at) >= periodStart).map(invoice => ({ label: invoice.closed_at ? time(invoice.closed_at) : '—', total: Number(invoice.total) })) diff --git a/frontend/src/pages/energyHistory.test.ts b/frontend/src/pages/energyHistory.test.ts new file mode 100644 index 0000000..2314cc4 --- /dev/null +++ b/frontend/src/pages/energyHistory.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { aggregateEnergyHistory } from './energyHistory' +import type { EnergyReading } from '../api/client' + +describe('aggregateEnergyHistory', () => { + it('sums concurrent sessions into one station demand point per tick', () => { + const readings = [ + { id: 'a', timestamp: '2026-09-16T12:01:00Z', allocated_power_kw: 20, solar_power_kw: 5, grid_power_kw: 15 }, + { id: 'b', timestamp: '2026-09-16T12:00:00Z', allocated_power_kw: 20, solar_power_kw: 0, grid_power_kw: 20 }, + { id: 'c', timestamp: '2026-09-16T12:01:00Z', allocated_power_kw: 20, solar_power_kw: 5, grid_power_kw: 15 }, + ] as EnergyReading[] + expect(aggregateEnergyHistory(readings)).toEqual([ + { timestamp: '2026-09-16T12:00:00Z', demand: 20, solar: 0, grid: 20 }, + { timestamp: '2026-09-16T12:01:00Z', demand: 40, solar: 10, grid: 30 }, + ]) + }) +}) diff --git a/frontend/src/pages/energyHistory.ts b/frontend/src/pages/energyHistory.ts new file mode 100644 index 0000000..cb553f4 --- /dev/null +++ b/frontend/src/pages/energyHistory.ts @@ -0,0 +1,15 @@ +import type { EnergyReading } from '../api/client' + +export function aggregateEnergyHistory(readings: EnergyReading[]) { + const byTimestamp = new Map() + for (const reading of readings) { + const point = byTimestamp.get(reading.timestamp) ?? { + timestamp: reading.timestamp, demand: 0, solar: 0, grid: 0, + } + point.demand += reading.allocated_power_kw + point.solar += reading.solar_power_kw + point.grid += reading.grid_power_kw + byTimestamp.set(reading.timestamp, point) + } + return [...byTimestamp.values()].sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)) +} From aa74710526ddb38dd4d7425b3250d54e096818e6 Mon Sep 17 00:00:00 2001 From: Matheus Santana <130666046+mathsant-js@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:59:07 -0300 Subject: [PATCH 7/7] test(frontend): make dashboard loading state test deterministic --- frontend/src/pages/UserDashboardPage.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/UserDashboardPage.test.tsx b/frontend/src/pages/UserDashboardPage.test.tsx index f1b8aee..02af465 100644 --- a/frontend/src/pages/UserDashboardPage.test.tsx +++ b/frontend/src/pages/UserDashboardPage.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { App } from '../App' @@ -24,13 +24,16 @@ it('shows the active estimate, completed invoice amount and histories', async () it('shows loading, empty and retry states', async () => { let calls = 0 + let resolveFirstDashboard!: (response: Response) => void + const firstDashboard = new Promise(resolve => { resolveFirstDashboard = resolve }) vi.spyOn(globalThis, 'fetch').mockImplementation(async input => { if (String(input).endsWith('/auth/me')) return json(user) calls++ - return calls === 1 ? json({}, 500) : json({ current_session: null, session_history: [], invoices: [] }) + return calls === 1 ? firstDashboard : json({ current_session: null, session_history: [], invoices: [] }) }) show() expect(await screen.findByText('Carregando dashboard...')).toBeInTheDocument() + await act(async () => { resolveFirstDashboard(json({}, 500)) }) expect(await screen.findByRole('alert')).toHaveTextContent('Não foi possível carregar o dashboard.') fireEvent.click(screen.getByRole('button', { name: 'Tentar novamente' })) expect(await screen.findByText('Nenhuma recarga em andamento.')).toBeInTheDocument()