Initial commit
Made-with: Cursor
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user