Initial commit
Made-with: Cursor
This commit is contained in:
5
frontend/.dockerignore
Normal file
5
frontend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.md
|
||||
.env*
|
||||
16
frontend/Dockerfile
Normal file
16
frontend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
# Stage 1 – build static assets
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2 – serve with nginx
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EMS Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
50
frontend/nginx.conf
Normal file
50
frontend/nginx.conf
Normal file
@@ -0,0 +1,50 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
text/css
|
||||
application/javascript
|
||||
application/json
|
||||
text/javascript
|
||||
application/xml
|
||||
application/xml+rss
|
||||
text/plain;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /rest/ {
|
||||
proxy_pass http://postgrest:3000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ^~ /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
4551
frontend/package-lock.json
generated
Normal file
4551
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "ems-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.15.0",
|
||||
"sonner": "^1.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"tailwindcss": "^4.0.14",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
49
frontend/src/App.tsx
Normal file
49
frontend/src/App.tsx
Normal 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
457
frontend/src/Planning.tsx
Normal 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)} Kč
|
||||
</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">TČ</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) ?? '—'} Kč</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">TČ</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>
|
||||
)
|
||||
}
|
||||
56
frontend/src/api/backend.ts
Normal file
56
frontend/src/api/backend.ts
Normal 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 }
|
||||
14
frontend/src/api/postgrest.ts
Normal file
14
frontend/src/api/postgrest.ts
Normal 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 }
|
||||
132
frontend/src/components/ModeLog.tsx
Normal file
132
frontend/src/components/ModeLog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
269
frontend/src/components/ModeSelector.tsx
Normal file
269
frontend/src/components/ModeSelector.tsx
Normal 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 />
|
||||
TČ
|
||||
{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>
|
||||
)
|
||||
}
|
||||
33
frontend/src/components/PowerFlowCard.tsx
Normal file
33
frontend/src/components/PowerFlowCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
frontend/src/components/PriceChart.tsx
Normal file
111
frontend/src/components/PriceChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
frontend/src/components/SocGauge.tsx
Normal file
77
frontend/src/components/SocGauge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
frontend/src/components/TelemetryChart.tsx
Normal file
54
frontend/src/components/TelemetryChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
frontend/src/hooks/useAuditDailyToday.ts
Normal file
45
frontend/src/hooks/useAuditDailyToday.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
42
frontend/src/hooks/useSiteStatus.ts
Normal file
42
frontend/src/hooks/useSiteStatus.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
72
frontend/src/hooks/useTelemetryToday.ts
Normal file
72
frontend/src/hooks/useTelemetryToday.ts
Normal 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
8
frontend/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
18
frontend/src/lib/pragueDate.ts
Normal file
18
frontend/src/lib/pragueDate.ts
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
176
frontend/src/pages/Dashboard.tsx
Normal file
176
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
frontend/src/pages/Settings.tsx
Normal file
98
frontend/src/pages/Settings.tsx
Normal 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
76
frontend/src/types/ems.ts
Normal 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
|
||||
}
|
||||
46
frontend/src/types/plan.ts
Normal file
46
frontend/src/types/plan.ts
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
10
frontend/tailwind.config.ts
Normal file
10
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config
|
||||
22
frontend/tsconfig.app.json
Normal file
22
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
24
frontend/vite.config.ts
Normal file
24
frontend/vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/rest': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/rest/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user