Initial commit

Made-with: Cursor
This commit is contained in:
Dusan Vojacek
2026-03-20 13:27:37 +01:00
commit 8b4af663d8
77 changed files with 13337 additions and 0 deletions

49
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { useState } from 'react'
import { Toaster } from 'sonner'
import Planning from './Planning'
import { Dashboard } from './pages/Dashboard'
import { Settings } from './pages/Settings'
type Page = 'dashboard' | 'planning' | 'settings'
export default function App() {
const [page, setPage] = useState<Page>('dashboard')
return (
<div className="min-h-screen bg-slate-950">
<nav className="sticky top-0 z-40 border-b border-slate-800/80 bg-slate-950/95 backdrop-blur">
<div className="mx-auto flex max-w-7xl items-center gap-1 px-4 py-2 md:px-8">
<button
type="button"
onClick={() => setPage('dashboard')}
className={`rounded-lg px-3 py-2 text-sm font-medium transition ${
page === 'dashboard' ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-900 hover:text-slate-200'
}`}
>
Přehled
</button>
<button
type="button"
onClick={() => setPage('planning')}
className={`rounded-lg px-3 py-2 text-sm font-medium transition ${
page === 'planning' ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-900 hover:text-slate-200'
}`}
>
Plán
</button>
<button
type="button"
onClick={() => setPage('settings')}
className={`rounded-lg px-3 py-2 text-sm font-medium transition ${
page === 'settings' ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-900 hover:text-slate-200'
}`}
>
Nastavení
</button>
</div>
</nav>
{page === 'dashboard' ? <Dashboard /> : page === 'planning' ? <Planning /> : <Settings />}
<Toaster richColors position="top-right" theme="dark" />
</div>
)
}

457
frontend/src/Planning.tsx Normal file
View File

@@ -0,0 +1,457 @@
import { Loader2, RefreshCw } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Area,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { getCurrentPlan, postRunPlan } from './api/backend'
import { useSiteStatus } from './hooks/useSiteStatus'
import type { CurrentPlanResponse, PlanningIntervalDto } from './types/plan'
const TZ = 'Europe/Prague'
function formatLocal(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('cs-CZ', {
timeZone: TZ,
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function formatLocalTime(iso: string): string {
return new Date(iso).toLocaleTimeString('cs-CZ', {
timeZone: TZ,
hour: '2-digit',
minute: '2-digit',
})
}
function slotStartUtcMs(iso: string): number {
return new Date(iso).getTime()
}
function negPrice(i: PlanningIntervalDto): boolean {
const b = i.effective_buy_price
const s = i.effective_sell_price
return (b != null && b < 0) || (s != null && s < 0)
}
function rowHighlight(i: PlanningIntervalDto): string {
if (negPrice(i)) return 'bg-red-950/45'
if ((i.pv_a_curtailed_w ?? 0) > 0) return 'bg-amber-950/35'
return ''
}
type ChartRow = {
label: string
ts: number
pv_kw: number
baseline_kw: number
bat_charge_kw: number
bat_discharge_kw: number
price: number
raw: PlanningIntervalDto
}
export default function Planning() {
const { site, ready: siteReady } = useSiteStatus()
const siteId = site?.site_id ?? null
const [data, setData] = useState<CurrentPlanResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [replanning, setReplanning] = useState(false)
const [slotDetail, setSlotDetail] = useState<PlanningIntervalDto | null>(null)
const load = useCallback(async () => {
if (siteId == null) return
setLoading(true)
setError(null)
try {
const res = await getCurrentPlan(siteId)
setData(res)
} catch (e) {
setError(e instanceof Error ? e.message : 'Chyba načtení plánu')
setData(null)
} finally {
setLoading(false)
}
}, [siteId])
useEffect(() => {
if (siteId != null) void load()
}, [siteId, load])
const nowMs = Date.now()
const dayMs = 24 * 60 * 60 * 1000
const intervals24h = useMemo(() => {
if (!data?.intervals?.length) return []
const end = nowMs + dayMs
return data.intervals
.filter((i) => {
const t = slotStartUtcMs(i.interval_start)
return t >= nowMs && t < end
})
.slice(0, 96)
}, [data?.intervals, nowMs, dayMs])
const chartRows: ChartRow[] = useMemo(() => {
return intervals24h.map((i) => {
const bat = i.battery_setpoint_w ?? 0
const pv = i.pv_forecast_total_w ?? 0
const base = i.load_baseline_w ?? 0
const price = i.effective_buy_price ?? 0
return {
label: formatLocalTime(i.interval_start),
ts: slotStartUtcMs(i.interval_start),
pv_kw: pv / 1000,
baseline_kw: base / 1000,
bat_charge_kw: Math.max(0, bat) / 1000,
bat_discharge_kw: Math.max(0, -bat) / 1000,
price,
raw: i,
}
})
}, [intervals24h])
async function onReplan() {
if (siteId == null) return
setReplanning(true)
setError(null)
try {
await postRunPlan(siteId, 'rolling')
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Přepočet selhal')
} finally {
setReplanning(false)
}
}
if (!siteReady) {
return (
<div className="flex min-h-[40vh] items-center justify-center text-slate-400">
Načítám lokalitu
</div>
)
}
if (siteId == null) {
return (
<div className="rounded-lg border border-amber-900/50 bg-amber-950/20 p-4 text-amber-200">
V PostgREST nebyla nalezena lokalita (vw_site_status). Nelze načíst plán.
</div>
)
}
const run = data?.run
const summary = data?.summary
return (
<div className="mx-auto max-w-6xl space-y-8 p-4 md:p-6">
<header className="space-y-1">
<h1 className="text-xl font-semibold tracking-tight text-white">Plánování</h1>
<p className="text-sm text-slate-400">
Aktuální LP plán a přehled dalších 24 hodin ({site?.site_name ?? 'lokalita'})
</p>
</header>
{error && (
<div className="rounded-md border border-red-900/60 bg-red-950/30 px-3 py-2 text-sm text-red-200">
{error}
</div>
)}
{/* Sekce 1 */}
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">
Aktuální plán
</h2>
{loading && !run ? (
<div className="flex items-center gap-2 text-slate-400">
<Loader2 className="h-4 w-4 animate-spin" /> Načítám
</div>
) : !run ? (
<p className="text-slate-400">Žádný aktivní plán v databázi.</p>
) : (
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2 md:gap-x-8">
<div>
<dt className="text-slate-500">Vytvořen</dt>
<dd className="font-mono text-slate-200">{formatLocal(run.created_at)}</dd>
</div>
<div>
<dt className="text-slate-500">Typ</dt>
<dd className="capitalize text-slate-200">{run.run_type}</dd>
</div>
<div>
<dt className="text-slate-500">Korekce FVE</dt>
<dd className="font-mono text-slate-200">
{run.forecast_correction_factor != null
? run.forecast_correction_factor.toFixed(4)
: '—'}
</dd>
</div>
<div>
<dt className="text-slate-500">Čas solveru</dt>
<dd className="font-mono text-slate-200">
{run.solver_duration_ms != null ? `${run.solver_duration_ms} ms` : '—'}
</dd>
</div>
</dl>
<button
type="button"
onClick={() => void onReplan()}
disabled={replanning}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-emerald-500 disabled:opacity-50"
>
{replanning ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
Přeplánovat nyní
</button>
</div>
)}
{summary && run && (
<div className="mt-4 grid grid-cols-2 gap-3 border-t border-slate-800 pt-4 text-xs text-slate-400 md:grid-cols-5">
<div>
<div className="text-slate-500">Očekávané náklady (celkem)</div>
<div className="font-mono text-slate-200">
{summary.total_expected_cost_czk.toFixed(2)}
</div>
</div>
<div>
<div className="text-slate-500">Curtailment A</div>
<div className="font-mono text-slate-200">
{summary.total_pv_curtailed_kwh.toFixed(3)} kWh
</div>
</div>
<div>
<div className="text-slate-500">Sloty nabíjení</div>
<div className="font-mono text-slate-200">{summary.charge_slots}</div>
</div>
<div>
<div className="text-slate-500">Sloty vybíjení</div>
<div className="font-mono text-slate-200">{summary.discharge_slots}</div>
</div>
<div>
<div className="text-slate-500">Sloty exportu</div>
<div className="font-mono text-slate-200">{summary.export_slots}</div>
</div>
</div>
)}
</section>
{/* Sekce 2 */}
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">
Graf (24 h)
</h2>
{!chartRows.length ? (
<p className="text-sm text-slate-500">Žádná data pro graf v horizontu 24 h.</p>
) : (
<div className="h-[380px] w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartRows}
margin={{ top: 8, right: 12, left: 0, bottom: 0 }}
onClick={(state) => {
const p = state?.activePayload?.[0]?.payload as ChartRow | undefined
if (p?.raw) setSlotDetail(p.raw)
}}
>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="label" tick={{ fill: '#94a3b8', fontSize: 11 }} />
<YAxis
yAxisId="left"
tick={{ fill: '#94a3b8', fontSize: 11 }}
label={{ value: 'kW', angle: -90, position: 'insideLeft', fill: '#64748b' }}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fill: '#94a3b8', fontSize: 11 }}
label={{ value: 'Kč/kWh', angle: 90, position: 'insideRight', fill: '#64748b' }}
/>
<Tooltip
contentStyle={{
background: '#0f172a',
border: '1px solid #334155',
borderRadius: 8,
}}
formatter={(value: number, name: string) => {
if (name === 'Cena nákup') return [`${value.toFixed(3)} Kč/kWh`, name]
return [`${value.toFixed(2)} kW`, name]
}}
/>
<Legend />
<Area
yAxisId="left"
type="monotone"
dataKey="pv_kw"
name="FVE předpověď"
stroke="#ca8a04"
fill="#eab308"
fillOpacity={0.35}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="baseline_kw"
name="Spotřeba baseline"
stroke="#3b82f6"
dot={false}
strokeWidth={2}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="bat_charge_kw"
name="Baterie nabíjení"
stroke="#22c55e"
dot={false}
strokeWidth={2}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="bat_discharge_kw"
name="Baterie vybíjení"
stroke="#f97316"
dot={false}
strokeWidth={2}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="price"
name="Cena nákup"
stroke="#94a3b8"
dot={false}
strokeWidth={2}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
{slotDetail && (
<div className="mt-4 rounded-lg border border-slate-700 bg-slate-950/60 p-3 text-sm">
<div className="mb-2 flex items-center justify-between">
<span className="font-medium text-slate-200">
Slot {formatLocal(slotDetail.interval_start)}
</span>
<button
type="button"
className="text-xs text-slate-500 hover:text-slate-300"
onClick={() => setSlotDetail(null)}
>
Zavřít
</button>
</div>
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 font-mono text-xs text-slate-300 md:grid-cols-3">
<dt className="text-slate-500">Nákup / prodej</dt>
<dd className="col-span-1">
{slotDetail.effective_buy_price?.toFixed(4) ?? '—'} /{' '}
{slotDetail.effective_sell_price?.toFixed(4) ?? '—'}
</dd>
<dt className="text-slate-500">FVE (A+B)</dt>
<dd>{slotDetail.pv_forecast_total_w ?? '—'} W</dd>
<dt className="text-slate-500">Baseline</dt>
<dd>{slotDetail.load_baseline_w ?? '—'} W</dd>
<dt className="text-slate-500">Baterie</dt>
<dd>{slotDetail.battery_setpoint_w ?? '—'} W</dd>
<dt className="text-slate-500">SoC cíl</dt>
<dd>
{slotDetail.battery_soc_target_pct != null
? `${slotDetail.battery_soc_target_pct}%`
: '—'}
</dd>
<dt className="text-slate-500">Síť</dt>
<dd>{slotDetail.grid_setpoint_w ?? '—'} W</dd>
<dt className="text-slate-500">EV1 / EV2</dt>
<dd>
{slotDetail.ev1_setpoint_w ?? '—'} / {slotDetail.ev2_setpoint_w ?? '—'} W
</dd>
<dt className="text-slate-500"></dt>
<dd>{slotDetail.heat_pump_enabled ? 'Zapnuto' : 'Vypnuto'}</dd>
<dt className="text-slate-500">Curtailment A</dt>
<dd>{slotDetail.pv_a_curtailed_w ?? 0} W</dd>
<dt className="text-slate-500">Náklady slotu</dt>
<dd>{slotDetail.expected_cost_czk?.toFixed(4) ?? '—'} </dd>
</dl>
</div>
)}
</section>
{/* Sekce 3 */}
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">
Tabulka (96 slotů / 24 h)
</h2>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left text-xs">
<thead>
<tr className="border-b border-slate-700 text-slate-500">
<th className="py-2 pr-2 font-medium">Čas</th>
<th className="py-2 pr-2 font-medium">Nákup</th>
<th className="py-2 pr-2 font-medium">Prodej</th>
<th className="py-2 pr-2 font-medium">FVE</th>
<th className="py-2 pr-2 font-medium">Bat</th>
<th className="py-2 pr-2 font-medium">Síť</th>
<th className="py-2 pr-2 font-medium">EV1</th>
<th className="py-2 pr-2 font-medium">EV2</th>
<th className="py-2 pr-2 font-medium"></th>
<th className="py-2 font-medium">Náklady</th>
</tr>
</thead>
<tbody>
{intervals24h.map((i) => (
<tr key={i.interval_start} className={`border-b border-slate-800/80 ${rowHighlight(i)}`}>
<td className="whitespace-nowrap py-1.5 pr-2 font-mono text-slate-300">
{formatLocalTime(i.interval_start)}
</td>
<td className="pr-2 font-mono text-slate-300">
{i.effective_buy_price?.toFixed(2) ?? '—'}
</td>
<td className="pr-2 font-mono text-slate-300">
{i.effective_sell_price?.toFixed(2) ?? '—'}
</td>
<td className="pr-2 font-mono text-slate-300">
{i.pv_forecast_total_w != null ? Math.round(i.pv_forecast_total_w) : '—'}
</td>
<td className="pr-2 font-mono text-slate-300">
{i.battery_setpoint_w ?? '—'}
</td>
<td className="pr-2 font-mono text-slate-300">{i.grid_setpoint_w ?? '—'}</td>
<td className="pr-2 font-mono text-slate-300">{i.ev1_setpoint_w ?? '—'}</td>
<td className="pr-2 font-mono text-slate-300">{i.ev2_setpoint_w ?? '—'}</td>
<td className="pr-2 text-slate-300">{i.heat_pump_enabled ? 'Ano' : 'Ne'}</td>
<td className="font-mono text-slate-300">
{i.expected_cost_czk?.toFixed(2) ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{!intervals24h.length && !loading && (
<p className="mt-2 text-sm text-slate-500">Žádné řádky v 24h okně.</p>
)}
</section>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import axios, { type AxiosInstance } from 'axios'
import type { CurrentPlanResponse, RunPlanResponse } from '../types/plan'
const client: AxiosInstance = axios.create({
baseURL: '/api/v1',
headers: { Accept: 'application/json' },
timeout: 30_000,
})
/** Příklad: health / readiness až budou v FastAPI exponované. */
export async function getBackendHealth(): Promise<unknown> {
const { data } = await client.get('/health')
return data
}
export type SetSiteModePayload = {
mode: string
notes: string | null
valid_until: string | null
}
export type SetSiteModeResponse = {
success: boolean
mode: string
activated_at: string
}
export async function postSiteMode(
siteId: number,
payload: SetSiteModePayload,
): Promise<SetSiteModeResponse> {
const { data } = await client.post<SetSiteModeResponse>(`/sites/${siteId}/mode`, payload)
return data
}
export async function getCurrentPlan(siteId: number): Promise<CurrentPlanResponse> {
const { data } = await client.get<CurrentPlanResponse>(`/sites/${siteId}/plan/current`, {
timeout: 60_000,
})
return data
}
export async function postRunPlan(
siteId: number,
planType: 'daily' | 'rolling',
): Promise<RunPlanResponse> {
const { data } = await client.post<RunPlanResponse>(
`/sites/${siteId}/plan/run`,
null,
{ params: { type: planType }, timeout: 120_000 },
)
return data
}
export { client as backendClient }

View File

@@ -0,0 +1,14 @@
import axios, { type AxiosInstance } from 'axios'
const client: AxiosInstance = axios.create({
baseURL: '/rest',
headers: { Accept: 'application/json' },
timeout: 15_000,
})
export async function getJson<T>(path: string, params?: Record<string, string>): Promise<T> {
const { data } = await client.get<T>(path, { params })
return data
}
export { client as postgrestClient }

View File

@@ -0,0 +1,132 @@
import { useCallback, useEffect, useState } from 'react'
import { getJson } from '../api/postgrest'
import type { ModeLogRecentRow } from '../types/ems'
function modeBadgeClass(code: string): string {
const c = code.toUpperCase()
if (c === 'AUTO') return 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/35'
if (c === 'SELF_SUSTAIN') return 'bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-500/35'
if (c === 'CHARGE_CHEAP') return 'bg-violet-500/15 text-violet-200 ring-1 ring-violet-500/35'
if (c === 'PRESERVE') return 'bg-amber-500/15 text-amber-200 ring-1 ring-amber-500/35'
if (c === 'MANUAL') return 'bg-slate-600/50 text-slate-200 ring-1 ring-slate-500/40'
return 'bg-slate-700/60 text-slate-200 ring-1 ring-slate-600/50'
}
function num(v: string | number | null | undefined): number {
if (v == null) return NaN
const n = typeof v === 'number' ? v : Number(v)
return n
}
function formatDuration(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return '—'
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
const s = Math.floor(sec % 60)
if (h > 0) return `${h} h ${m} min`
if (m > 0) return `${m} min ${s > 0 ? `${s} s` : ''}`.trim()
return `${s} s`
}
function fmtTime(iso: string): string {
try {
const d = new Date(iso)
return d.toLocaleString('cs-CZ', { dateStyle: 'short', timeStyle: 'medium' })
} catch {
return iso
}
}
type Props = {
siteId: number | null
}
export function ModeLog({ siteId }: Props) {
const [rows, setRows] = useState<ModeLogRecentRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
if (siteId == null) {
setRows([])
setLoading(false)
return
}
setLoading(true)
setError(null)
try {
const data = await getJson<ModeLogRecentRow[]>('/vw_mode_log_recent', {
site_id: `eq.${siteId}`,
order: 'activated_at.desc',
limit: '20',
})
setRows(Array.isArray(data) ? data : [])
} catch (e) {
setError(String(e))
setRows([])
} finally {
setLoading(false)
}
}, [siteId])
useEffect(() => {
void load()
}, [load])
if (siteId == null) {
return <p className="text-sm text-slate-500">Vyberte nebo načtěte lokalitu.</p>
}
if (loading) {
return <div className="h-40 animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
if (error) {
return <p className="text-sm text-red-400">Nelze načíst log: {error}</p>
}
return (
<div className="overflow-x-auto rounded-xl border border-slate-800">
<table className="w-full min-w-[640px] border-collapse text-left text-sm">
<thead>
<tr className="border-b border-slate-800 bg-slate-900/80 text-xs font-semibold uppercase tracking-wide text-slate-500">
<th className="px-4 py-3">Čas</th>
<th className="px-4 py-3">Režim</th>
<th className="px-4 py-3">Trvání</th>
<th className="px-4 py-3">Kdo</th>
<th className="px-4 py-3">Poznámka</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
Žádné záznamy za posledních 7 dní.
</td>
</tr>
) : (
rows.map((r) => (
<tr key={r.id} className="border-b border-slate-800/80 hover:bg-slate-900/40">
<td className="whitespace-nowrap px-4 py-3 tabular-nums text-slate-300">{fmtTime(r.activated_at)}</td>
<td className="px-4 py-3">
<span
className={`inline-flex rounded-md px-2 py-0.5 text-xs font-semibold uppercase tracking-wide ${modeBadgeClass(r.mode_code)}`}
>
{r.mode_code}
</span>
</td>
<td className="whitespace-nowrap px-4 py-3 tabular-nums text-slate-400">{formatDuration(num(r.duration_sec))}</td>
<td className="max-w-[140px] truncate px-4 py-3 text-slate-400" title={r.activated_by ?? ''}>
{r.activated_by ?? '—'}
</td>
<td className="max-w-[280px] truncate px-4 py-3 text-slate-400" title={r.notes ?? ''}>
{r.notes?.trim() ? r.notes : '—'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,269 @@
import {
BatteryCharging,
Bot,
Car,
Check,
Home,
Shield,
Thermometer,
Wrench,
X,
} from 'lucide-react'
import axios from 'axios'
import { useCallback, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { postSiteMode } from '../api/backend'
export type OperatingModeCode = 'AUTO' | 'SELF_SUSTAIN' | 'CHARGE_CHEAP' | 'PRESERVE' | 'MANUAL'
type ModeDef = {
code: OperatingModeCode
title: string
description: string
ev: boolean
hp: boolean
Icon: typeof Bot
}
const MODES: ModeDef[] = [
{
code: 'AUTO',
title: 'AUTO',
description: 'EMS řídí FVE, baterii, EV a TČ podle plánu a cen.',
ev: true,
hp: true,
Icon: Bot,
},
{
code: 'SELF_SUSTAIN',
title: 'SELF_SUSTAIN',
description: 'Autonomní domácí režim bez exportu; EV a TČ zastaveny.',
ev: false,
hp: false,
Icon: Home,
},
{
code: 'CHARGE_CHEAP',
title: 'CHARGE_CHEAP',
description: 'Max. nabíjení baterie; EV a TČ vypnuty.',
ev: false,
hp: false,
Icon: BatteryCharging,
},
{
code: 'PRESERVE',
title: 'PRESERVE',
description: 'Držení SoC; EV a TČ zastaveny (dovolená / servis).',
ev: false,
hp: false,
Icon: Shield,
},
{
code: 'MANUAL',
title: 'MANUAL',
description: 'Servisní režim; žádné řízení z EMS.',
ev: false,
hp: false,
Icon: Wrench,
},
]
function modeBadgeRing(code: string): string {
const c = code.toUpperCase()
if (c === 'AUTO') return 'ring-emerald-500/50'
if (c === 'SELF_SUSTAIN') return 'ring-cyan-500/50'
if (c === 'CHARGE_CHEAP') return 'ring-violet-500/50'
if (c === 'PRESERVE') return 'ring-amber-500/50'
if (c === 'MANUAL') return 'ring-slate-500/50'
return 'ring-slate-600'
}
type Props = {
siteId: number | null
currentMode: string | null | undefined
onModeApplied?: () => void
}
export function ModeSelector({ siteId, currentMode, onModeApplied }: Props) {
const [pending, setPending] = useState<OperatingModeCode | null>(null)
const [notes, setNotes] = useState('')
const [validUntilLocal, setValidUntilLocal] = useState('')
const [optimisticMode, setOptimisticMode] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const displayMode = optimisticMode ?? currentMode ?? null
const normalizedCurrent = (displayMode ?? '').toUpperCase()
const closeModal = useCallback(() => {
setPending(null)
setNotes('')
setValidUntilLocal('')
}, [])
const confirmSwitch = useCallback(async () => {
if (siteId == null || pending == null) return
const modeCode = pending
const notePayload = notes.trim() === '' ? null : notes.trim()
const valid_until =
validUntilLocal.trim() === '' ? null : new Date(validUntilLocal).toISOString()
setSubmitting(true)
setOptimisticMode(modeCode)
closeModal()
try {
await postSiteMode(siteId, {
mode: modeCode,
notes: notePayload,
valid_until,
})
setOptimisticMode(null)
onModeApplied?.()
toast.success(`Režim ${modeCode} byl aktivován.`)
} catch (e: unknown) {
setOptimisticMode(null)
let msg = String(e)
if (axios.isAxiosError(e)) {
const d = e.response?.data as { detail?: unknown } | undefined
if (d?.detail != null) {
msg = Array.isArray(d.detail) ? d.detail.map((x) => JSON.stringify(x)).join('; ') : String(d.detail)
} else if (e.message) {
msg = e.message
}
}
toast.error('Přepnutí režimu se nezdařilo', { description: msg })
} finally {
setSubmitting(false)
}
}, [siteId, pending, notes, validUntilLocal, closeModal, onModeApplied])
const openConfirm = useCallback(
(code: OperatingModeCode) => {
if (siteId == null) {
toast.error('Chybí lokalita (site_id).')
return
}
if (code === normalizedCurrent) return
setPending(code)
setNotes('')
setValidUntilLocal('')
},
[siteId, normalizedCurrent],
)
const modalTitle = useMemo(() => {
if (!pending) return ''
const m = MODES.find((x) => x.code === pending)
return m?.title ?? pending
}, [pending])
return (
<div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{MODES.map(({ code, title, description, ev, hp, Icon }) => {
const active = normalizedCurrent === code
return (
<button
key={code}
type="button"
disabled={siteId == null || submitting}
onClick={() => openConfirm(code)}
className={[
'flex flex-col rounded-xl border p-4 text-left transition',
active
? 'border-emerald-500/70 bg-emerald-950/35 ring-2 ring-emerald-500/40'
: 'border-slate-800 bg-slate-900/40 hover:border-slate-600 hover:bg-slate-900/70',
submitting ? 'opacity-60' : '',
].join(' ')}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span
className={`flex h-9 w-9 items-center justify-center rounded-lg bg-slate-800/80 ring-1 ${modeBadgeRing(code)}`}
>
<Icon className="h-5 w-5 text-slate-200" aria-hidden />
</span>
<span className="text-sm font-semibold tracking-wide text-slate-100">{title}</span>
</div>
</div>
<p className="mt-2 line-clamp-2 text-xs leading-snug text-slate-400">{description}</p>
<div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-500">
<span className="flex items-center gap-1">
<Car className="h-3.5 w-3.5" aria-hidden />
EV
{ev ? (
<Check className="h-3.5 w-3.5 text-emerald-400" aria-label="povoleno" />
) : (
<X className="h-3.5 w-3.5 text-red-400" aria-label="zakázáno" />
)}
</span>
<span className="flex items-center gap-1">
<Thermometer className="h-3.5 w-3.5" aria-hidden />
{hp ? (
<Check className="h-3.5 w-3.5 text-emerald-400" aria-label="povoleno" />
) : (
<X className="h-3.5 w-3.5 text-red-400" aria-label="zakázáno" />
)}
</span>
</div>
</button>
)
})}
</div>
{pending ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="mode-confirm-title"
onClick={(ev) => {
if (ev.target === ev.currentTarget) closeModal()
}}
>
<div className="w-full max-w-md rounded-xl border border-slate-700 bg-slate-950 p-6 shadow-xl">
<h3 id="mode-confirm-title" className="text-lg font-semibold text-white">
Přepnout na {modalTitle}?
</h3>
<p className="mt-1 text-sm text-slate-400">Změna se zapíše do DB a odešle se signál do Loxone (je-li endpoint).</p>
<label className="mt-4 block text-xs font-medium uppercase tracking-wide text-slate-500">
Poznámka (volitelné)
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600"
placeholder="např. odjezd na víkend"
/>
</label>
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-slate-500">
Platí do (volitelné, lokální čas prohlížeče)
<input
type="datetime-local"
value={validUntilLocal}
onChange={(e) => setValidUntilLocal(e.target.value)}
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
/>
</label>
<div className="mt-6 flex justify-end gap-2">
<button
type="button"
onClick={closeModal}
className="rounded-lg border border-slate-600 px-4 py-2 text-sm font-medium text-slate-200 hover:bg-slate-800"
>
Zrušit
</button>
<button
type="button"
disabled={submitting}
onClick={() => void confirmSwitch()}
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500 disabled:opacity-50"
>
Potvrdit přepnutí
</button>
</div>
</div>
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,33 @@
import type { LucideIcon } from 'lucide-react'
function formatKw(powerW: number | null | undefined): string {
if (powerW == null || Number.isNaN(powerW)) return '—'
const kw = powerW / 1000
return `${kw.toFixed(2)} kW`
}
type Props = {
label: string
powerW: number | null | undefined
icon: LucideIcon
/** např. border-l-amber-400 */
borderClass: string
/** např. text-amber-400 */
iconClass: string
}
export function PowerFlowCard({ label, powerW, icon: Icon, borderClass, iconClass }: Props) {
return (
<div
className={`flex items-center gap-4 rounded-xl border border-slate-800 bg-slate-900/60 p-4 pl-3 shadow-sm backdrop-blur-sm border-l-4 ${borderClass}`}
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
<Icon className={`h-6 w-6 ${iconClass}`} aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</p>
<p className="truncate text-xl font-semibold tabular-nums text-slate-100">{formatKw(powerW)}</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,111 @@
import { useCallback, useEffect, useState } from 'react'
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { getJson } from '../api/postgrest'
import { instantPragueDay, pragueCalendarDay } from '../lib/pragueDate'
import type { SiteEffectivePriceRow } from '../types/ems'
function parseNum(v: string | number | null | undefined): number | null {
if (v == null) return null
if (typeof v === 'number' && !Number.isNaN(v)) return v
const n = Number(v)
return Number.isFinite(n) ? n : null
}
export type PricePoint = {
label: string
buy: number | null
sell: number | null
}
type Props = {
siteId: number | null
pollMs?: number
}
/** Efektivní nákup / prodej (Kč/kWh) pro dnešní den v Europe/Prague. */
export function PriceChart({ siteId, pollMs = 120_000 }: Props) {
const [points, setPoints] = useState<PricePoint[]>([])
const [ready, setReady] = useState(false)
const load = useCallback(async () => {
if (siteId == null) {
setPoints([])
setReady(true)
return
}
try {
const rows = await getJson<SiteEffectivePriceRow[]>('/vw_site_effective_price', {
site_id: `eq.${siteId}`,
order: 'interval_start.desc',
limit: '200',
})
const today = pragueCalendarDay()
const todayRows = Array.isArray(rows)
? rows.filter((r) => instantPragueDay(r.interval_start) === today)
: []
todayRows.sort((a, b) => new Date(a.interval_start).getTime() - new Date(b.interval_start).getTime())
const mapped: PricePoint[] = todayRows.map((r) => {
const t = new Date(r.interval_start)
return {
label: t.toLocaleTimeString('cs-CZ', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Prague',
}),
buy: parseNum(r.effective_buy_price_czk_kwh),
sell: parseNum(r.effective_sell_price_czk_kwh),
}
})
setPoints(mapped)
} catch {
setPoints([])
} finally {
setReady(true)
}
}, [siteId])
useEffect(() => {
void load()
const id = window.setInterval(() => void load(), pollMs)
return () => window.clearInterval(id)
}, [load, pollMs])
if (!ready || points.length === 0) {
return <div className="h-[280px] w-full animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
return (
<div className="h-[280px] w-full rounded-xl border border-slate-800 bg-slate-900/40 p-2 pt-4">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={points} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.6} />
<XAxis dataKey="label" tick={{ fill: '#94a3b8', fontSize: 10 }} interval="preserveStartEnd" />
<YAxis
tick={{ fill: '#94a3b8', fontSize: 11 }}
label={{ value: 'Kč/kWh', angle: -90, position: 'insideLeft', fill: '#64748b', fontSize: 11 }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '8px',
}}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line type="stepAfter" dataKey="buy" name="Nákup" stroke="#f97316" strokeWidth={2} dot={false} connectNulls />
<Line type="stepAfter" dataKey="sell" name="Prodej" stroke="#38bdf8" strokeWidth={2} dot={false} connectNulls />
</LineChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -0,0 +1,77 @@
function clampPct(n: number): number {
return Math.max(0, Math.min(100, n))
}
function parseSoc(v: string | number | null | undefined): number | null {
if (v == null) return null
if (typeof v === 'number' && !Number.isNaN(v)) return v
const x = Number(v)
return Number.isFinite(x) ? x : null
}
type Props = {
socPercent: string | number | null | undefined
loading?: boolean
}
const R = 52
const C = 2 * Math.PI * R
const STROKE = 8
export function SocGauge({ socPercent, loading }: Props) {
if (loading) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-slate-800 bg-slate-900/60 p-6">
<div className="h-36 w-36 animate-pulse rounded-full bg-slate-800/80" />
<div className="mt-4 h-4 w-24 animate-pulse rounded bg-slate-800/80" />
</div>
)
}
const raw = parseSoc(socPercent)
if (raw == null) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-slate-800 bg-slate-900/60 p-6">
<div className="h-36 w-36 animate-pulse rounded-full bg-slate-800/80" />
<div className="mt-4 h-3 w-20 animate-pulse rounded bg-slate-800/80" />
</div>
)
}
const pct = clampPct(raw)
const offset = C - (pct / 100) * C
return (
<div className="flex flex-col items-center rounded-xl border border-slate-800 bg-slate-900/60 p-6">
<div className="relative">
<svg width="140" height="140" viewBox="0 0 120 120" className="-rotate-90" aria-hidden>
<circle
cx="60"
cy="60"
r={R}
fill="none"
stroke="currentColor"
strokeWidth={STROKE}
className="text-slate-800"
/>
<circle
cx="60"
cy="60"
r={R}
fill="none"
stroke="currentColor"
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={C}
strokeDashoffset={offset}
className="text-emerald-500 transition-[stroke-dashoffset] duration-500"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold tabular-nums text-slate-50">{pct.toFixed(0)}</span>
<span className="text-xs text-slate-500">% SoC</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import type { TelemetryChartPoint } from '../hooks/useTelemetryToday'
type Props = {
points: TelemetryChartPoint[]
loading?: boolean
}
function ChartSkeleton() {
return <div className="h-[320px] w-full animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
export function TelemetryChart({ points, loading }: Props) {
if (loading || points.length === 0) {
return <ChartSkeleton />
}
return (
<div className="h-[320px] w-full rounded-xl border border-slate-800 bg-slate-900/40 p-2 pt-4">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={points} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.6} />
<XAxis dataKey="timeLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} />
<YAxis
tick={{ fill: '#94a3b8', fontSize: 11 }}
label={{ value: 'kW', angle: -90, position: 'insideLeft', fill: '#64748b', fontSize: 11 }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '8px',
}}
labelStyle={{ color: '#e2e8f0' }}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line type="monotone" dataKey="pv_kw" name="FVE" stroke="#facc15" strokeWidth={2} dot={false} connectNulls />
<Line type="monotone" dataKey="load_kw" name="Spotřeba" stroke="#3b82f6" strokeWidth={2} dot={false} connectNulls />
<Line type="monotone" dataKey="battery_kw" name="Baterie" stroke="#22c55e" strokeWidth={2} dot={false} connectNulls />
<Line type="monotone" dataKey="grid_kw" name="Síť" stroke="#94a3b8" strokeWidth={2} dot={false} connectNulls />
</LineChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { useCallback, useEffect, useState } from 'react'
import { getJson } from '../api/postgrest'
import { instantPragueDay, pragueCalendarDay } from '../lib/pragueDate'
import type { AuditDailyRow } from '../types/ems'
const POLL_MS = 30_000
export function useAuditDailyToday(siteId: number | null) {
const [row, setRow] = useState<AuditDailyRow | null>(null)
const [ready, setReady] = useState(false)
const load = useCallback(async () => {
if (siteId == null) {
setRow(null)
setReady(true)
return
}
try {
const rows = await getJson<AuditDailyRow[]>('/vw_audit_daily', {
site_id: `eq.${siteId}`,
order: 'day_local.desc',
limit: '45',
})
const today = pragueCalendarDay()
const hit = Array.isArray(rows) ? rows.find((r) => instantPragueDay(r.day_local) === today) : undefined
setRow(hit ?? null)
} catch {
setRow(null)
} finally {
setReady(true)
}
}, [siteId])
useEffect(() => {
void load()
const id = window.setInterval(() => void load(), POLL_MS)
return () => window.clearInterval(id)
}, [load])
return {
daily: row,
ready,
hasDaily: row != null && (row.interval_count ?? 0) > 0,
}
}

View File

@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState } from 'react'
import { getJson } from '../api/postgrest'
import type { SiteStatusRow } from '../types/ems'
const POLL_MS = 5_000
export function useSiteStatus() {
const [row, setRow] = useState<SiteStatusRow | null>(null)
const [ready, setReady] = useState(false)
const load = useCallback(async () => {
try {
const rows = await getJson<SiteStatusRow[]>('/vw_site_status')
setRow(Array.isArray(rows) && rows.length > 0 ? rows[0]! : null)
} catch {
setRow(null)
} finally {
setReady(true)
}
}, [])
useEffect(() => {
void load()
const id = window.setInterval(() => void load(), POLL_MS)
return () => window.clearInterval(id)
}, [load])
const hasTelemetry =
row != null &&
(row.pv_power_w != null ||
row.battery_power_w != null ||
row.grid_power_w != null ||
row.battery_soc_percent != null)
return {
site: row,
ready,
/** Máme řádek lokality a alespoň jednu telemetrickou hodnotu (jinak skeleton). */
hasLiveData: row != null && hasTelemetry,
reload: load,
}
}

View File

@@ -0,0 +1,72 @@
import { useCallback, useEffect, useState } from 'react'
import { getJson } from '../api/postgrest'
import type { AuditTodayHourlyRow } from '../types/ems'
const POLL_MS = 30_000
function parseNum(v: string | number | null | undefined): number | null {
if (v == null) return null
if (typeof v === 'number' && !Number.isNaN(v)) return v
const n = Number(v)
return Number.isFinite(n) ? n : null
}
export type TelemetryChartPoint = {
timeLabel: string
ts: number
pv_kw: number | null
load_kw: number | null
battery_kw: number | null
grid_kw: number | null
}
export function useTelemetryToday(siteId: number | null) {
const [points, setPoints] = useState<TelemetryChartPoint[]>([])
const [ready, setReady] = useState(false)
const load = useCallback(async () => {
if (siteId == null) {
setPoints([])
setReady(true)
return
}
try {
const rows = await getJson<AuditTodayHourlyRow[]>('/vw_audit_today_hourly', {
site_id: `eq.${siteId}`,
order: 'hour_local.asc',
})
if (!Array.isArray(rows) || rows.length === 0) {
setPoints([])
return
}
const mapped: TelemetryChartPoint[] = rows.map((r) => {
const d = new Date(r.hour_local)
return {
ts: d.getTime(),
timeLabel: d.toLocaleTimeString('cs-CZ', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Prague',
}),
pv_kw: parseNum(r.avg_pv_kw),
load_kw: parseNum(r.avg_load_kw),
battery_kw: parseNum(r.avg_battery_kw),
grid_kw: parseNum(r.avg_grid_kw),
}
})
setPoints(mapped)
} catch {
setPoints([])
} finally {
setReady(true)
}
}, [siteId])
useEffect(() => {
void load()
const id = window.setInterval(() => void load(), POLL_MS)
return () => window.clearInterval(id)
}, [load])
return { points, ready, hasChartData: points.length > 0 }
}

8
frontend/src/index.css Normal file
View File

@@ -0,0 +1,8 @@
@import 'tailwindcss';
@config "../tailwind.config.ts";
@layer base {
body {
@apply min-h-screen bg-slate-950 text-slate-100 antialiased;
}
}

View File

@@ -0,0 +1,18 @@
/** Kalendářní den YYYY-MM-DD v časové zóně Europe/Prague. */
export function pragueCalendarDay(d = new Date()): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Prague',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d)
}
export function instantPragueDay(iso: string): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Prague',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date(iso))
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,176 @@
import { Battery, Sun, Zap } from 'lucide-react'
import { PowerFlowCard } from '../components/PowerFlowCard'
import { SocGauge } from '../components/SocGauge'
import { TelemetryChart } from '../components/TelemetryChart'
import { useAuditDailyToday } from '../hooks/useAuditDailyToday'
import { useSiteStatus } from '../hooks/useSiteStatus'
import { useTelemetryToday } from '../hooks/useTelemetryToday'
function fmtEnergy(v: string | number | null | undefined): string {
const n = typeof v === 'number' ? v : v == null ? NaN : Number(v)
if (!Number.isFinite(n)) return '—'
return `${n.toLocaleString('cs-CZ', { maximumFractionDigits: 3 })} kWh`
}
function fmtMoney(v: string | number | null | undefined): string {
const n = typeof v === 'number' ? v : v == null ? NaN : Number(v)
if (!Number.isFinite(n)) return '—'
return `${n.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} Kč`
}
function modeBadgeClass(code: string | null): string {
const c = (code ?? '').toUpperCase()
if (c.includes('AUTO')) return 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/35'
if (c.includes('SELF')) return 'bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-500/35'
if (c.includes('MANUAL') || c.includes('FORCE')) return 'bg-amber-500/15 text-amber-200 ring-1 ring-amber-500/35'
if (c.includes('OFF') || c.includes('IDLE')) return 'bg-slate-600/40 text-slate-300 ring-1 ring-slate-500/30'
return 'bg-slate-700/60 text-slate-200 ring-1 ring-slate-600/50'
}
function batteryStyles(powerW: number | null | undefined): { border: string; icon: string } {
if (powerW == null || Number.isNaN(powerW)) {
return { border: 'border-l-slate-600', icon: 'text-slate-400' }
}
if (powerW >= 0) {
return { border: 'border-l-emerald-500', icon: 'text-emerald-400' }
}
return { border: 'border-l-orange-500', icon: 'text-orange-400' }
}
function gridStyles(powerW: number | null | undefined): { border: string; icon: string } {
if (powerW == null || Number.isNaN(powerW)) {
return { border: 'border-l-slate-600', icon: 'text-slate-400' }
}
if (powerW >= 0) {
return { border: 'border-l-red-500', icon: 'text-red-400' }
}
return { border: 'border-l-emerald-500', icon: 'text-emerald-400' }
}
function SectionTitle({ kicker, title }: { kicker: string; title: string }) {
return (
<div className="mb-4">
<p className="text-xs font-semibold uppercase tracking-widest text-slate-500">{kicker}</p>
<h2 className="text-lg font-semibold text-slate-100">{title}</h2>
</div>
)
}
function CardSkeleton() {
return <div className="h-[88px] animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
function StatBlock({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</p>
<p className="mt-1 text-lg font-semibold tabular-nums text-slate-100">{value}</p>
</div>
)
}
function StatSkeleton() {
return <div className="h-[76px] animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
export function Dashboard() {
const { site, ready: siteReady, hasLiveData } = useSiteStatus()
const siteId = site?.site_id ?? null
const { points, ready: telemetryReady, hasChartData } = useTelemetryToday(siteId)
const { daily, ready: auditReady, hasDaily } = useAuditDailyToday(siteId)
const liveSkeleton = !siteReady || !hasLiveData
const chartSkeleton = !telemetryReady || !hasChartData
const econSkeleton = !auditReady || !hasDaily
const hbOk = site?.ems_heartbeat_status === 'ok'
const bat = batteryStyles(site?.battery_power_w ?? null)
const grd = gridStyles(site?.grid_power_w ?? null)
return (
<div className="min-h-screen bg-slate-950 p-4 md:p-8">
<div className="mx-auto max-w-7xl space-y-10">
<header className="flex flex-col gap-4 border-b border-slate-800/80 pb-6 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-white">EMS Platform</h1>
<p className="mt-1 text-sm text-slate-400">Přehled lokality a auditu</p>
</div>
{!siteReady ? (
<div className="h-10 w-56 animate-pulse rounded-lg bg-slate-800/80" />
) : site ? (
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-slate-400">{site.site_name}</span>
<span
className={`rounded-md px-2.5 py-1 text-xs font-semibold uppercase tracking-wide ${modeBadgeClass(site.active_mode)}`}
title={site.mode_description ?? undefined}
>
{site.active_mode ?? '—'}
{site.mode_name ? ` · ${site.mode_name}` : ''}
</span>
<span className="flex items-center gap-2 text-xs text-slate-500">
<span className="relative flex h-2.5 w-2.5">
<span
className={`inline-flex h-2.5 w-2.5 rounded-full ${hbOk ? 'bg-emerald-500' : 'bg-red-500'}`}
title={site.ems_heartbeat_status ?? 'neznámý'}
/>
</span>
EMS
</span>
</div>
) : null}
</header>
<section>
<SectionTitle kicker="Živě" title="Aktuální stav" />
{liveSkeleton ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<CardSkeleton />
<CardSkeleton />
<div className="flex min-h-[88px] items-center justify-center rounded-xl border border-slate-800 bg-slate-900/40">
<div className="h-36 w-36 animate-pulse rounded-full bg-slate-800/80" />
</div>
<CardSkeleton />
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<PowerFlowCard label="FVE" powerW={site?.pv_power_w} icon={Sun} borderClass="border-l-amber-400" iconClass="text-amber-400" />
<PowerFlowCard
label="Baterie"
powerW={site?.battery_power_w}
icon={Battery}
borderClass={bat.border}
iconClass={bat.icon}
/>
<SocGauge socPercent={site?.battery_soc_percent} loading={false} />
<PowerFlowCard label="Síť" powerW={site?.grid_power_w} icon={Zap} borderClass={grd.border} iconClass={grd.icon} />
</div>
)}
</section>
<section>
<SectionTitle kicker="Dnes" title="Průběh výkonů (hodinový průměr)" />
<TelemetryChart points={points} loading={chartSkeleton} />
</section>
<section>
<SectionTitle kicker="Dnes" title="Ekonomika auditu" />
{econSkeleton ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatSkeleton />
<StatSkeleton />
<StatSkeleton />
<StatSkeleton />
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatBlock label="Import" value={fmtEnergy(daily?.import_kwh)} />
<StatBlock label="Export" value={fmtEnergy(daily?.export_kwh)} />
<StatBlock label="FVE výroba" value={fmtEnergy(daily?.pv_kwh)} />
<StatBlock label="Náklady / příjem (audit)" value={fmtMoney(daily?.actual_cost_czk)} />
</div>
)}
</section>
</div>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { ModeLog } from '../components/ModeLog'
import { ModeSelector } from '../components/ModeSelector'
import { useSiteStatus } from '../hooks/useSiteStatus'
function SectionTitle({ kicker, title }: { kicker: string; title: string }) {
return (
<div className="mb-4">
<p className="text-xs font-semibold uppercase tracking-widest text-slate-500">{kicker}</p>
<h2 className="text-lg font-semibold text-slate-100">{title}</h2>
</div>
)
}
export function Settings() {
const { site, ready, reload } = useSiteStatus()
const siteId = site?.site_id ?? null
return (
<div className="min-h-screen bg-slate-950 p-4 md:p-8">
<div className="mx-auto max-w-7xl space-y-12">
<header className="border-b border-slate-800/80 pb-6">
<h1 className="text-2xl font-bold tracking-tight text-white">Nastavení</h1>
<p className="mt-1 text-sm text-slate-400">Provozní režim a plánování flexibilní zátěže</p>
{ready && site ? (
<p className="mt-2 text-sm text-slate-500">
Lokalita: <span className="text-slate-300">{site.site_name}</span> ({site.site_code})
</p>
) : null}
</header>
<section>
<SectionTitle kicker="Řízení" title="Provozní režim" />
<p className="mb-4 max-w-3xl text-sm text-slate-400">
Přepnutí zapíše stav do databáze a notifikuje Loxone. U dočasného režimu lze nastavit čas návratu; po vypršení systém obnoví předchozí režim.
</p>
<ModeSelector
siteId={siteId}
currentMode={site?.active_mode}
onModeApplied={() => void reload()}
/>
<div className="mt-8">
<h3 className="mb-3 text-sm font-medium text-slate-300">Poslední přepnutí</h3>
<ModeLog siteId={siteId} />
</div>
</section>
<section>
<SectionTitle kicker="EV" title="Deadline nabíjení (připravuje se)" />
<p className="mb-4 text-sm text-slate-500">
Zatím pouze rozhraní; napojení na API a session přijde v další iteraci.
</p>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-slate-800 bg-slate-900/40 p-4">
<p className="text-sm font-medium text-slate-200">Tesla</p>
<div className="mt-3 flex flex-wrap gap-3">
<label className="flex flex-col text-xs text-slate-500">
Cílové SoC %
<input
type="number"
min={0}
max={100}
placeholder="např. 80"
disabled
className="mt-1 w-28 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400"
/>
</label>
<label className="flex min-w-[200px] flex-col text-xs text-slate-500">
Deadline
<input type="datetime-local" disabled className="mt-1 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400" />
</label>
</div>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900/40 p-4">
<p className="text-sm font-medium text-slate-200">Zoe</p>
<div className="mt-3 flex flex-wrap gap-3">
<label className="flex flex-col text-xs text-slate-500">
Cílové SoC %
<input
type="number"
min={0}
max={100}
placeholder="např. 80"
disabled
className="mt-1 w-28 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400"
/>
</label>
<label className="flex min-w-[200px] flex-col text-xs text-slate-500">
Deadline
<input type="datetime-local" disabled className="mt-1 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400" />
</label>
</div>
</div>
</div>
</section>
</div>
</div>
)
}

76
frontend/src/types/ems.ts Normal file
View File

@@ -0,0 +1,76 @@
/** ems.vw_site_status (PostgREST) */
export type SiteStatusRow = {
site_id: number
site_code: string
site_name: string
active_mode: string | null
mode_name: string | null
mode_description: string | null
is_autonomous: boolean | null
activated_at: string | null
activated_by: string | null
valid_until: string | null
previous_mode: string | null
mode_notes: string | null
ems_last_seen: string | null
ems_status: string | null
ems_age_seconds: number | null
ems_heartbeat_status: 'ok' | 'delayed' | 'stale' | 'never_seen' | null
pv_power_w: number | null
battery_soc_percent: string | number | null
battery_power_w: number | null
grid_power_w: number | null
load_power_w: number | null
telemetry_at: string | null
}
/** ems.vw_audit_today_hourly */
export type AuditTodayHourlyRow = {
site_id: number
hour_local: string
avg_pv_kw: string | number | null
avg_battery_kw: string | number | null
avg_grid_kw: string | number | null
avg_load_kw: string | number | null
avg_soc_pct: string | number | null
cost_czk: string | number | null
}
/** ems.vw_audit_daily */
export type AuditDailyRow = {
site_id: number
day_local: string
interval_count: number
import_kwh: string | number | null
export_kwh: string | number | null
pv_kwh: string | number | null
load_kwh: string | number | null
ev_kwh: string | number | null
hp_kwh: string | number | null
actual_cost_czk: string | number | null
total_deviation_czk: string | number | null
high_deviation_count: number | null
}
/** ems.vw_mode_log_recent (PostgREST) */
export type ModeLogRecentRow = {
id: number
site_id: number
site_code: string
mode_code: string
mode_name: string
activated_at: string
deactivated_at: string | null
duration_sec: number
activated_by: string | null
notes: string | null
}
/** ems.vw_site_effective_price */
export type SiteEffectivePriceRow = {
site_id: number
interval_start: string
interval_end: string
effective_buy_price_czk_kwh: string | number | null
effective_sell_price_czk_kwh: string | number | null
}

View File

@@ -0,0 +1,46 @@
/** Odpověď GET /api/v1/sites/{id}/plan/current */
export type PlanningRunDto = {
id: number
created_at: string
run_type: string
horizon_start: string
horizon_end: string
forecast_correction_factor: number | null
solver_duration_ms: number | null
}
export type PlanningIntervalDto = {
interval_start: string
battery_setpoint_w: number | null
battery_soc_target_pct: number | null
grid_setpoint_w: number | null
ev1_setpoint_w: number | null
ev2_setpoint_w: number | null
heat_pump_enabled: boolean | null
pv_a_curtailed_w: number | null
expected_cost_czk: number | null
effective_buy_price: number | null
effective_sell_price: number | null
pv_forecast_total_w: number | null
load_baseline_w: number | null
}
export type PlanningSummaryDto = {
total_expected_cost_czk: number
total_pv_curtailed_kwh: number
charge_slots: number
discharge_slots: number
export_slots: number
}
export type CurrentPlanResponse = {
run: PlanningRunDto | null
intervals: PlanningIntervalDto[]
summary: PlanningSummaryDto | null
}
export type RunPlanResponse = {
run_id: number
solver_duration_ms: number
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />