From 58f85ac7c23bb45615a3809966fc438f89f2a139 Mon Sep 17 00:00:00 2001 From: Matheus Santana <130666046+mathsant-js@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:27:16 -0300 Subject: [PATCH 1/3] feat(admin): add MVP operations management screens - add admin interfaces for stations, chargers, tariffs, and system settings - add energy, risk, and simulation controls - handle loading, validation, conflict, and error states - require confirmation for impactful operations - add typed API contracts and reusable accessible forms - protect operations by admin role - add authorization and rendering tests --- frontend/src/App.tsx | 6 +- frontend/src/api/client.ts | 27 ++++++- frontend/src/layouts/AppShell.tsx | 8 +- .../src/pages/AdminOperationsPage.test.tsx | 75 +++++++++++++++++++ frontend/src/pages/AdminOperationsPage.tsx | 65 ++++++++++++++++ frontend/src/styles.css | 16 ++++ 6 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 frontend/src/pages/AdminOperationsPage.test.tsx create mode 100644 frontend/src/pages/AdminOperationsPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1e5a94f..8ea6082 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { LoginPage } from './pages/LoginPage' import { UserDashboardPage } from './pages/UserDashboardPage' import { AppShell } from './layouts/AppShell' import { AdminDashboardPage } from './pages/AdminDashboardPage' +import { AdminOperationsPage } from './pages/AdminOperationsPage' function Home() { const { state } = useAuth() @@ -16,7 +17,10 @@ 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 369f7a6..6436c02 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -14,8 +14,15 @@ export interface UserDashboard { current_session: UserSessionSummary | null; ses 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 Station { id: string; name: string; description: string | null; grid_limit_kw: number; station_peak_solar_kw: number; is_active: boolean } +export interface StationInput { name: string; description: string | null; grid_limit_kw: number; station_peak_solar_kw: number; is_active: boolean } export interface Charger { id: string; station_id: string; name: string; code: string; max_power_kw: number; status: 'AVAILABLE' | 'CHARGING' | 'UNAVAILABLE'; is_active: boolean } +export interface ChargerInput { station_id: string; name: string; code: string; max_power_kw: number; status: Charger['status']; is_active: boolean } +export interface Tariff { id: string; name: string; price_per_kwh: string; currency: string; is_active: boolean; valid_from: string; valid_until: string | null; created_at: string } +export interface TariffInput { name: string; price_per_kwh: number; currency: string; is_active: boolean; valid_from: string; valid_until: string | null } +export interface SystemConfiguration { id: string; simulation_speed: number; grid_emission_factor_kg_per_kwh: number; high_demand_threshold: number; medium_peak_threshold: number; high_peak_threshold: number; created_at: string; updated_at: string } +export type SystemConfigurationInput = Omit +export interface SimulationStatus { state: 'RUNNING' | 'STOPPED'; current_instant: string; tick_duration_seconds: number; simulation_speed: number; last_tick: string | null } 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; recommendation: 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 } @@ -47,7 +54,9 @@ async function request(path: string, options: RequestInit = {}, authenticated if (response.status === 401 && authenticated && token && tokenStore.get() === token) tokenStore.clear() if (!response.ok) { 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 + const rawDetail = typeof body === 'object' && body !== null && 'detail' in body ? body.detail : undefined + const detail = typeof rawDetail === 'string' ? rawDetail : Array.isArray(rawDetail) + ? rawDetail.map(item => typeof item === 'object' && item !== null && 'msg' in item ? String(item.msg) : '').filter(Boolean).join(' ') : undefined throw new ApiError(response.status, response.status === 403 ? 'Acesso não permitido.' : detail ?? `Erro HTTP ${response.status}`) } if (response.status === 204) return undefined as T @@ -73,7 +82,21 @@ export const api = { dashboard: (filters?: Filters) => request(`/analytics/dashboard${query(filters)}`), sustainability: (filters?: Filters) => request(`/analytics/sustainability${query(filters)}`), stations: () => request('/stations'), + createStation: (value: StationInput) => request('/stations', { method: 'POST', body: JSON.stringify(value) }), + updateStation: (id: string, value: Partial) => request(`/stations/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(value) }), chargers: () => request('/chargers'), + createCharger: (value: ChargerInput) => request('/chargers', { method: 'POST', body: JSON.stringify(value) }), + updateCharger: (id: string, value: Partial) => request(`/chargers/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(value) }), + tariffs: () => request('/tariffs'), + createTariff: (value: TariffInput) => request('/tariffs', { method: 'POST', body: JSON.stringify(value) }), + updateTariff: (id: string, value: Partial) => request(`/tariffs/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(value) }), + systemConfiguration: () => request('/system-configuration'), + createSystemConfiguration: (value: SystemConfigurationInput) => request('/system-configuration', { method: 'POST', body: JSON.stringify(value) }), + updateSystemConfiguration: (value: SystemConfigurationInput) => request('/system-configuration', { method: 'PATCH', body: JSON.stringify(value) }), + simulationStatus: () => request('/simulation/status'), + startSimulation: () => request('/simulation/start', { method: 'POST' }), + stopSimulation: () => request('/simulation/stop', { method: 'POST' }), + resetSimulation: () => request('/simulation/reset', { method: 'POST' }), solarHistory: (filters?: Filters) => request(`/solar/history${query(filters)}`), prediction: (station_id: string) => request(`/predictions/demand${query({ station_id })}`), runPrediction: (station_id: string) => request('/predictions/demand/run', { method: 'POST', body: JSON.stringify({ station_id }) }), diff --git a/frontend/src/layouts/AppShell.tsx b/frontend/src/layouts/AppShell.tsx index 5aa708a..adaad76 100644 --- a/frontend/src/layouts/AppShell.tsx +++ b/frontend/src/layouts/AppShell.tsx @@ -1,14 +1,16 @@ import type { PropsWithChildren } from 'react' +import { Link, useLocation } from 'react-router-dom' export function AppShell({ children }: PropsWithChildren) { + const location = useLocation() return (
- + CG ChargeGrid Intelligence - - ChargeGrid Intelligence + + {location.pathname.startsWith('/admin') && }
{children}
diff --git a/frontend/src/pages/AdminOperationsPage.test.tsx b/frontend/src/pages/AdminOperationsPage.test.tsx new file mode 100644 index 0000000..f778d7d --- /dev/null +++ b/frontend/src/pages/AdminOperationsPage.test.tsx @@ -0,0 +1,75 @@ +import { cleanup, fireEvent, render, screen } 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: 'a1', name: 'Admin', email: 'admin@example.com', role: 'ADMIN', is_active: true, created_at: '', updated_at: '' } +const user = { ...admin, id: 'u1', role: 'USER' } +const station = { id: 's1', name: 'Central', description: 'Matriz', grid_limit_kw: 60, station_peak_solar_kw: 20, is_active: true } +const charger = { id: 'c1', station_id: 's1', name: 'Carga 1', code: 'CG-01', max_power_kw: 22, status: 'AVAILABLE', is_active: true } +const tariff = { id: 't1', name: 'Padrão', price_per_kwh: '0.9200', currency: 'BRL', is_active: true, valid_from: '2026-09-01T00:00:00Z', valid_until: null, created_at: '' } +const configuration = { id: 'cfg1', simulation_speed: 60, grid_emission_factor_kg_per_kwh: 0.084, high_demand_threshold: 0.85, medium_peak_threshold: 0.7, high_peak_threshold: 0.9, created_at: '', updated_at: '' } +const simulation = { state: 'STOPPED', current_instant: '2026-09-23T12:00:00Z', tick_duration_seconds: 60, simulation_speed: 60, last_tick: null } + +function mockApi(role: 'ADMIN' | 'USER' = 'ADMIN') { + return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const url = new URL(String(input)); const method = init?.method ?? 'GET' + if (url.pathname.endsWith('/auth/me')) return json(role === 'ADMIN' ? admin : user) + if (url.pathname.endsWith('/stations')) return method === 'POST' ? json(station, 201) : json([station]) + if (url.pathname.endsWith('/chargers')) return json([charger]) + if (url.pathname.endsWith('/tariffs')) return json([tariff]) + if (url.pathname.endsWith('/system-configuration')) return method === 'PATCH' ? json(configuration) : json(configuration) + if (url.pathname.endsWith('/simulation/status')) return json(simulation) + if (url.pathname.endsWith('/simulation/reset')) return json(simulation) + return json({ detail: 'unexpected' }, 500) + }) +} + +function show() { tokenStore.set('token'); render() } +beforeEach(() => localStorage.clear()) +afterEach(() => { cleanup(); vi.restoreAllMocks() }) + +describe('admin operations authorization and rendering', () => { + it('prevents a regular user from opening the administration route', async () => { + mockApi('USER'); show() + expect(await screen.findByRole('heading', { name: 'Acesso não permitido' })).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Operação do MVP' })).not.toBeInTheDocument() + }) + + it('renders every minimum management area with loaded data', async () => { + mockApi(); show() + expect(await screen.findByRole('heading', { name: 'Estações' })).toBeInTheDocument() + for (const name of ['Carregadores', 'Tarifas', 'Energia, risco e simulação']) expect(screen.getByRole('heading', { name })).toBeInTheDocument() + expect(screen.getAllByText('Central').length).toBeGreaterThan(0) + expect(screen.getByText('Carga 1 (CG-01)')).toBeInTheDocument() + expect(screen.getByText('Padrão')).toBeInTheDocument() + expect(screen.getByText(/A classificação permanece calculada pelo backend/)).toBeInTheDocument() + }) + + it('validates risk threshold ordering before calling the API', async () => { + const fetch = mockApi(); show(); await screen.findByRole('heading', { name: 'Estações' }) + fireEvent.change(screen.getByLabelText('Limiar de risco médio'), { target: { value: '0.95' } }) + fireEvent.change(screen.getByLabelText('Limiar de risco alto'), { target: { value: '0.9' } }) + fireEvent.click(screen.getByRole('button', { name: 'Atualizar parâmetros' })) + expect(await screen.findByRole('alert')).toHaveTextContent('O limiar médio deve ser menor que o limiar alto.') + expect(fetch.mock.calls.filter(([url, init]) => String(url).endsWith('/system-configuration') && init?.method === 'PATCH')).toHaveLength(0) + }) + + it('requires confirmation before resetting the simulation clock', async () => { + const fetch = mockApi(); const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); show(); await screen.findByRole('heading', { name: 'Estações' }) + fireEvent.click(screen.getByRole('button', { name: 'Redefinir relógio' })) + expect(confirm).toHaveBeenCalled() + expect(fetch.mock.calls.some(([url]) => String(url).endsWith('/simulation/reset'))).toBe(false) + }) + + it('shows API validation and conflict messages near the form', async () => { + const fetch = mockApi(); const original = fetch.getMockImplementation()! + fetch.mockImplementation(async (input, init) => String(input).endsWith('/stations') && init?.method === 'POST' ? json({ detail: 'Station already exists' }, 409) : original(input, init)) + show(); await screen.findByRole('heading', { name: 'Estações' }) + const names = screen.getAllByLabelText('Nome'); fireEvent.change(names[0], { target: { value: 'Central' } }) + fireEvent.click(screen.getByRole('button', { name: 'Cadastrar estação' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Conflito: Station already exists') + }) +}) diff --git a/frontend/src/pages/AdminOperationsPage.tsx b/frontend/src/pages/AdminOperationsPage.tsx new file mode 100644 index 0000000..967b5db --- /dev/null +++ b/frontend/src/pages/AdminOperationsPage.tsx @@ -0,0 +1,65 @@ +import { type FormEvent, type ReactNode, useCallback, useEffect, useState } from 'react' +import { api, ApiError, type Charger, type ChargerInput, type SimulationStatus, type Station, type StationInput, type SystemConfiguration, type SystemConfigurationInput, type Tariff, type TariffInput } from '../api/client' +import { useAuth } from '../auth/context' +import { AppShell } from '../layouts/AppShell' + +type Notice = { kind: 'success' | 'error'; text: string } | null +const stationBlank: StationInput = { name: '', description: null, grid_limit_kw: 60, station_peak_solar_kw: 0, is_active: true } +const chargerBlank: ChargerInput = { station_id: '', name: '', code: '', max_power_kw: 22, status: 'AVAILABLE', is_active: true } +const tariffBlank = (): TariffInput => ({ name: '', price_per_kwh: 0.92, currency: 'BRL', is_active: true, valid_from: new Date().toISOString().slice(0, 16), valid_until: null }) +const configurationBlank: SystemConfigurationInput = { simulation_speed: 60, grid_emission_factor_kg_per_kwh: 0.084, high_demand_threshold: 0.85, medium_peak_threshold: 0.7, high_peak_threshold: 0.9 } + +function message(error: unknown) { + if (!(error instanceof ApiError)) return 'Não foi possível concluir a operação.' + if (error.status === 409) return `Conflito: ${error.message}` + if (error.status === 422) return `Revise os campos: ${error.message}` + return error.message +} + +function Section({ title, description, children }: { title: string; description: string; children: ReactNode }) { + return

{title}

{description}

{children}
+} + +function Feedback({ notice }: { notice: Notice }) { + if (!notice) return null + return

{notice.text}

+} + +function StationForm({ initial = stationBlank, label, onSave }: { initial?: StationInput; label: string; onSave: (value: StationInput) => Promise }) { + const [value, setValue] = useState(initial); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(null) + async function submit(event: FormEvent) { event.preventDefault(); setBusy(true); setNotice(null); try { const saved = await onSave({ ...value, description: value.description || null }); if (saved !== false) setNotice({ kind: 'success', text: 'Estação salva.' }) } catch (error) { setNotice({ kind: 'error', text: message(error) }) } finally { setBusy(false) } } + return