Files
ems/frontend/src/Planning.tsx
Dusan Vojacek 8b4af663d8 Initial commit
Made-with: Cursor
2026-03-20 13:27:44 +01:00

458 lines
17 KiB
TypeScript

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>
)
}