x
This commit is contained in:
687
frontend/src/pages/Planning.tsx
Normal file
687
frontend/src/pages/Planning.tsx
Normal file
@@ -0,0 +1,687 @@
|
||||
import axios from 'axios'
|
||||
import {
|
||||
ArrowDownRight,
|
||||
ArrowUpRight,
|
||||
CloudSun,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Area,
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
|
||||
import { getCurrentPlan, postImportSitePrices, postRunForecast, 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 {
|
||||
return new Date(iso).toLocaleString('cs-CZ', {
|
||||
timeZone: TZ,
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Vizuál FVE: API posílá součet A+B (`pv_forecast_total_w`).
|
||||
* Pokud je hodnota null (data chybí), použijeme jednoduchou proxy z ceny nákupu (W).
|
||||
* Čistá nula = platná předpověď „bez výroby“ (např. noc).
|
||||
*/
|
||||
function pvAProxyW(i: PlanningIntervalDto): number {
|
||||
const pv = i.pv_forecast_total_w
|
||||
if (pv != null && pv > 0) return pv
|
||||
if (pv === 0) return 0
|
||||
const buy = i.effective_buy_price
|
||||
if (buy == null) return 0
|
||||
const w = 6000 - buy * 3500
|
||||
return Math.max(0, Math.min(15000, w))
|
||||
}
|
||||
|
||||
function runTypeBadgeClass(t: string): string {
|
||||
const u = t.toLowerCase()
|
||||
if (u === 'daily') return 'bg-sky-500/15 text-sky-300 ring-1 ring-sky-500/35'
|
||||
if (u === 'rolling') return 'bg-violet-500/15 text-violet-300 ring-1 ring-violet-500/35'
|
||||
if (u === 'manual') return 'bg-amber-500/15 text-amber-200 ring-1 ring-amber-500/35'
|
||||
return 'bg-slate-600/40 text-slate-300 ring-1 ring-slate-500/30'
|
||||
}
|
||||
|
||||
function axiosDetail(e: unknown): string {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const d = e.response?.data as { detail?: unknown } | undefined
|
||||
const detail = d?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
return detail
|
||||
.map((x: { msg?: string }) => (typeof x?.msg === 'string' ? x.msg : ''))
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
}
|
||||
}
|
||||
return e instanceof Error ? e.message : 'Neznámá chyba'
|
||||
}
|
||||
|
||||
function tableRowClass(
|
||||
i: PlanningIntervalDto,
|
||||
selected: boolean,
|
||||
): string {
|
||||
const parts: string[] = []
|
||||
if (selected) parts.push('ring-1 ring-inset ring-cyan-500/50 bg-cyan-950/25')
|
||||
const buy = i.effective_buy_price
|
||||
const sell = i.effective_sell_price
|
||||
if (buy != null && buy < 0) parts.push('bg-green-950/80')
|
||||
else if (sell != null && sell < 0) parts.push('bg-red-950/80')
|
||||
if ((i.pv_a_curtailed_w ?? 0) > 0) parts.push('border-l-4 border-l-yellow-500')
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
type ChartRow = {
|
||||
label: string
|
||||
ts: number
|
||||
pv_a_w: number
|
||||
battery_soc_target_pct: number | null
|
||||
battery_setpoint_w: number
|
||||
effective_buy_price: number | null
|
||||
raw: PlanningIntervalDto
|
||||
}
|
||||
|
||||
type PlanPrepActionsProps = {
|
||||
prepAction: null | 'import' | 'forecast' | 'init'
|
||||
replanning: boolean
|
||||
onImport: () => void
|
||||
onForecast: () => void
|
||||
onInit: () => void
|
||||
wrapClassName?: string
|
||||
}
|
||||
|
||||
function PlanPrepActions({
|
||||
prepAction,
|
||||
replanning,
|
||||
onImport,
|
||||
onForecast,
|
||||
onInit,
|
||||
wrapClassName = 'flex flex-wrap gap-2',
|
||||
}: PlanPrepActionsProps) {
|
||||
const prepBusy = prepAction !== null
|
||||
const dis = prepBusy || replanning
|
||||
return (
|
||||
<div className={wrapClassName}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImport}
|
||||
disabled={dis}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-600 bg-slate-800/90 px-3 py-2 text-sm font-medium text-slate-100 transition hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{prepAction === 'import' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
Importovat ceny
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onForecast}
|
||||
disabled={dis}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-600 bg-slate-800/90 px-3 py-2 text-sm font-medium text-slate-100 transition hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{prepAction === 'forecast' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CloudSun className="h-4 w-4" />
|
||||
)}
|
||||
Spustit forecast
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInit}
|
||||
disabled={dis}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-emerald-700/60 bg-emerald-900/40 px-3 py-2 text-sm font-medium text-emerald-100 transition hover:bg-emerald-800/50 disabled:opacity-50"
|
||||
>
|
||||
{prepAction === 'init' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
Inicializovat plán
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlanTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: ChartRow }> }) {
|
||||
if (!active || !payload?.length) return null
|
||||
const p = payload[0].payload
|
||||
const i = p.raw
|
||||
const buy = i.effective_buy_price
|
||||
const sell = i.effective_sell_price
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-600 bg-slate-950 px-3 py-2 text-xs text-slate-200 shadow-xl">
|
||||
<div className="mb-1 font-medium text-slate-100">{formatLocal(i.interval_start)}</div>
|
||||
<div className="space-y-0.5 font-mono tabular-nums">
|
||||
<div>
|
||||
Cena nákup: {buy != null ? `${buy.toFixed(3)} Kč/kWh` : '—'} · prodej:{' '}
|
||||
{sell != null ? `${sell.toFixed(3)} Kč/kWh` : '—'}
|
||||
</div>
|
||||
<div>Baterie: {i.battery_setpoint_w ?? '—'} W</div>
|
||||
<div>Síť: {i.grid_setpoint_w ?? '—'} W</div>
|
||||
<div>TČ: {i.heat_pump_enabled ? 'zapnuto' : 'vypnuto'}</div>
|
||||
<div>
|
||||
EV1: {i.ev1_setpoint_w ?? '—'} W · EV2: {i.ev2_setpoint_w ?? '—'} W
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 [prepAction, setPrepAction] = useState<null | 'import' | 'forecast' | 'init'>(null)
|
||||
const [selectedStart, setSelectedStart] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteId == null) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await getCurrentPlan(siteId)
|
||||
setData(res)
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 404) {
|
||||
setData({ run: null, intervals: [], summary: null })
|
||||
setError(null)
|
||||
} else {
|
||||
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])
|
||||
|
||||
const xTicks = useMemo(() => {
|
||||
if (!intervals24h.length) return undefined
|
||||
const stepMs = 2 * 60 * 60 * 1000
|
||||
const first = slotStartUtcMs(intervals24h[0].interval_start)
|
||||
const last = slotStartUtcMs(intervals24h[intervals24h.length - 1].interval_start)
|
||||
const ticks: string[] = []
|
||||
let t = Math.ceil(first / stepMs) * stepMs
|
||||
while (t <= last) {
|
||||
const hit = intervals24h.find((i) => Math.abs(slotStartUtcMs(i.interval_start) - t) < 30 * 60 * 1000)
|
||||
if (hit) ticks.push(hit.interval_start)
|
||||
t += stepMs
|
||||
}
|
||||
return ticks.length ? ticks.map((iso) => formatLocalTime(iso)) : undefined
|
||||
}, [intervals24h])
|
||||
|
||||
const chartRows: ChartRow[] = useMemo(() => {
|
||||
return intervals24h.map((i) => ({
|
||||
label: formatLocalTime(i.interval_start),
|
||||
ts: slotStartUtcMs(i.interval_start),
|
||||
pv_a_w: pvAProxyW(i),
|
||||
battery_soc_target_pct: i.battery_soc_target_pct,
|
||||
battery_setpoint_w: i.battery_setpoint_w ?? 0,
|
||||
effective_buy_price: i.effective_buy_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)
|
||||
}
|
||||
}
|
||||
|
||||
async function runRollingReload() {
|
||||
if (siteId == null) return
|
||||
await postRunPlan(siteId, 'rolling')
|
||||
await load()
|
||||
}
|
||||
|
||||
async function handleImportPrices() {
|
||||
if (siteId == null) return
|
||||
setPrepAction('import')
|
||||
setError(null)
|
||||
try {
|
||||
const r = await postImportSitePrices(siteId)
|
||||
toast.success(
|
||||
`Ceny: ${r.slots_imported} slotů (${r.date}), první ${r.first_price_czk_kwh.toFixed(3)} Kč/kWh`,
|
||||
)
|
||||
await runRollingReload()
|
||||
} catch (e) {
|
||||
toast.error('Import cen selhal', { description: axiosDetail(e) })
|
||||
} finally {
|
||||
setPrepAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRunForecast() {
|
||||
if (siteId == null) return
|
||||
setPrepAction('forecast')
|
||||
setError(null)
|
||||
try {
|
||||
const r = await postRunForecast(siteId)
|
||||
toast.success(`Forecast: ${r.intervals_saved} intervalů, ${r.pv_arrays} FVE polí`)
|
||||
await runRollingReload()
|
||||
} catch (e) {
|
||||
toast.error('Forecast selhal', { description: axiosDetail(e) })
|
||||
} finally {
|
||||
setPrepAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInitializePlan() {
|
||||
if (siteId == null) return
|
||||
setPrepAction('init')
|
||||
setError(null)
|
||||
try {
|
||||
const imp = await postImportSitePrices(siteId)
|
||||
toast.success(
|
||||
`Ceny: ${imp.slots_imported} slotů (${imp.date}), první ${imp.first_price_czk_kwh.toFixed(3)} Kč/kWh`,
|
||||
)
|
||||
const fc = await postRunForecast(siteId)
|
||||
toast.success(`Forecast: ${fc.intervals_saved} intervalů, ${fc.pv_arrays} FVE polí`)
|
||||
await runRollingReload()
|
||||
toast.success('Plán přepočítán (rolling).')
|
||||
} catch (e) {
|
||||
toast.error('Inicializace selhala', { description: axiosDetail(e) })
|
||||
} finally {
|
||||
setPrepAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
const planStale =
|
||||
run != null && Date.now() - new Date(run.created_at).getTime() > 2 * 60 * 60 * 1000
|
||||
const showPrepActions = !loading && (run == null || planStale)
|
||||
const prepBusy = prepAction !== null
|
||||
|
||||
const correctionPct =
|
||||
run?.forecast_correction_factor != null ? run.forecast_correction_factor * 100 : null
|
||||
const correctionUp = (run?.forecast_correction_factor ?? 1) >= 1
|
||||
|
||||
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 dalších 24 h od teď ({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">
|
||||
Status aktivního plánu
|
||||
</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 ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-slate-400">Žádný aktivní plán.</p>
|
||||
{showPrepActions && (
|
||||
<PlanPrepActions
|
||||
prepAction={prepAction}
|
||||
replanning={replanning}
|
||||
onImport={() => void handleImportPrices()}
|
||||
onForecast={() => void handleRunForecast()}
|
||||
onInit={() => void handleInitializePlan()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-slate-200">
|
||||
<span className="text-slate-500">Vytvořeno:</span>
|
||||
<span className="font-mono">{formatLocal(run.created_at)}</span>
|
||||
<span className="text-slate-600">|</span>
|
||||
<span className="text-slate-500">Typ:</span>
|
||||
<span
|
||||
className={`rounded-md px-2 py-0.5 text-xs font-semibold uppercase tracking-wide ${runTypeBadgeClass(run.run_type)}`}
|
||||
>
|
||||
{run.run_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-slate-500">Horizont: </span>
|
||||
<span className="font-mono text-slate-200">
|
||||
{formatLocal(run.horizon_start)} → {formatLocal(run.horizon_end)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-slate-500">Korekce FVE forecastu:</span>
|
||||
<span className="inline-flex items-center gap-1 font-mono text-slate-200">
|
||||
{correctionPct != null ? (
|
||||
<>
|
||||
{correctionUp ? (
|
||||
<ArrowUpRight className="h-4 w-4 text-emerald-400" aria-hidden />
|
||||
) : (
|
||||
<ArrowDownRight className="h-4 w-4 text-amber-400" aria-hidden />
|
||||
)}
|
||||
{Number.isInteger(correctionPct)
|
||||
? correctionPct
|
||||
: correctionPct.toLocaleString('cs-CZ', { maximumFractionDigits: 1 })}{' '}
|
||||
%
|
||||
</>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-slate-500">Čas výpočtu solveru: </span>
|
||||
<span className="font-mono text-slate-200">
|
||||
{run.solver_duration_ms != null ? `${run.solver_duration_ms} ms` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
{summary && (
|
||||
<div className="border-t border-slate-800 pt-3 text-sm">
|
||||
<p className="mb-2 text-slate-500">Summary</p>
|
||||
<dl className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-xs text-slate-500">
|
||||
{summary.total_expected_cost_czk >= 0 ? 'Celkové náklady' : 'Celkový příjem'}
|
||||
</dt>
|
||||
<dd className="font-mono text-slate-100">
|
||||
{summary.total_expected_cost_czk >= 0
|
||||
? `${summary.total_expected_cost_czk.toFixed(2)} Kč`
|
||||
: `${Math.abs(summary.total_expected_cost_czk).toFixed(2)} Kč`}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-slate-500">kWh curtailmentu (A)</dt>
|
||||
<dd className="font-mono text-slate-100">
|
||||
{summary.total_pv_curtailed_kwh.toLocaleString('cs-CZ', {
|
||||
minimumFractionDigits: 3,
|
||||
maximumFractionDigits: 3,
|
||||
})}{' '}
|
||||
kWh
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-slate-500">Sloty nabíjení / vybíjení / export</dt>
|
||||
<dd className="font-mono text-slate-100">
|
||||
{summary.charge_slots} / {summary.discharge_slots} / {summary.export_slots}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-stretch gap-2 sm:items-end">
|
||||
{showPrepActions && (
|
||||
<PlanPrepActions
|
||||
prepAction={prepAction}
|
||||
replanning={replanning}
|
||||
onImport={() => void handleImportPrices()}
|
||||
onForecast={() => void handleRunForecast()}
|
||||
onInit={() => void handleInitializePlan()}
|
||||
wrapClassName="flex flex-wrap justify-end gap-2"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onReplan()}
|
||||
disabled={replanning || prepBusy}
|
||||
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
|
||||
</button>
|
||||
</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 plánu</h2>
|
||||
{!chartRows.length ? (
|
||||
<p className="text-sm text-slate-500">Žádná data pro graf (24 h od teď, max. 96 slotů).</p>
|
||||
) : (
|
||||
<div className="h-[350px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartRows} margin={{ top: 8, right: 72, left: 8, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
ticks={xTicks}
|
||||
tick={{ fill: '#94a3b8', fontSize: 10 }}
|
||||
interval={0}
|
||||
angle={-35}
|
||||
textAnchor="end"
|
||||
height={48}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="power"
|
||||
tick={{ fill: '#94a3b8', fontSize: 10 }}
|
||||
label={{ value: 'W', angle: -90, position: 'insideLeft', fill: '#64748b', fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="soc"
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
tick={{ fill: '#22c55e', fontSize: 10 }}
|
||||
label={{ value: 'SoC %', angle: 90, position: 'insideRight', fill: '#22c55e', fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="price"
|
||||
orientation="right"
|
||||
width={52}
|
||||
tick={{ fill: '#94a3b8', fontSize: 9 }}
|
||||
axisLine={{ stroke: '#64748b' }}
|
||||
tickLine={{ stroke: '#64748b' }}
|
||||
label={{
|
||||
value: 'Kč/kWh',
|
||||
angle: 90,
|
||||
position: 'insideRight',
|
||||
fill: '#94a3b8',
|
||||
fontSize: 10,
|
||||
offset: 10,
|
||||
}}
|
||||
/>
|
||||
<Tooltip content={<PlanTooltip />} />
|
||||
<Area
|
||||
yAxisId="power"
|
||||
type="monotone"
|
||||
dataKey="pv_a_w"
|
||||
name="FVE (A) / předpověď"
|
||||
stroke="#ca8a04"
|
||||
fill="#eab308"
|
||||
fillOpacity={0.35}
|
||||
/>
|
||||
<Bar yAxisId="power" dataKey="battery_setpoint_w" name="Baterie W" barSize={10} isAnimationActive={false}>
|
||||
{chartRows.map((e) => (
|
||||
<Cell
|
||||
key={e.ts}
|
||||
fill={e.battery_setpoint_w >= 0 ? '#22c55e' : '#f97316'}
|
||||
fillOpacity={0.85}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
<Line
|
||||
yAxisId="soc"
|
||||
type="monotone"
|
||||
dataKey="battery_soc_target_pct"
|
||||
name="SoC %"
|
||||
stroke="#4ade80"
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
yAxisId="price"
|
||||
type="monotone"
|
||||
dataKey="effective_buy_price"
|
||||
name="Cena nákup"
|
||||
stroke="#94a3b8"
|
||||
strokeDasharray="5 4"
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
connectNulls
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</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 slotů</h2>
|
||||
<div className="max-h-[400px] overflow-y-auto overflow-x-auto rounded-lg border border-slate-800/80">
|
||||
<table className="w-full border-collapse text-left text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-slate-900 shadow-[0_1px_0_0_rgb(30_41_59)]">
|
||||
<tr className="text-slate-500">
|
||||
<th className="whitespace-nowrap py-2 pl-2 pr-2 font-medium">Čas</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Cena kup</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Cena prod</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Bat. W</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">SoC %</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Síť W</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">EV1 W</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">EV2 W</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">TČ</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Náklady Kč</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{intervals24h.map((i) => {
|
||||
const sel = selectedStart === i.interval_start
|
||||
return (
|
||||
<tr
|
||||
key={i.interval_start}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedStart((prev) => (prev === i.interval_start ? null : i.interval_start))}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault()
|
||||
setSelectedStart((prev) => (prev === i.interval_start ? null : i.interval_start))
|
||||
}
|
||||
}}
|
||||
className={`cursor-pointer border-b border-slate-800/80 transition hover:bg-slate-800/40 ${tableRowClass(i, sel)}`}
|
||||
>
|
||||
<td className="whitespace-nowrap py-1.5 pl-2 pr-2 font-mono text-slate-300">
|
||||
{formatLocalTime(i.interval_start)}
|
||||
</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
||||
{i.effective_buy_price?.toFixed(3) ?? '—'}
|
||||
</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
||||
{i.effective_sell_price?.toFixed(3) ?? '—'}
|
||||
</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.battery_setpoint_w ?? '—'}</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
||||
{i.battery_soc_target_pct != null
|
||||
? `${i.battery_soc_target_pct.toFixed(1)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.grid_setpoint_w ?? '—'}</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.ev1_setpoint_w ?? '—'}</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.ev2_setpoint_w ?? '—'}</td>
|
||||
<td className="pr-2 text-slate-300">{i.heat_pump_enabled ? 'on' : 'off'}</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
||||
{i.expected_cost_czk?.toFixed(4) ?? '—'}
|
||||
</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