uprava UI pro planovani
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-21 13:22:33 +02:00
parent b78597fdda
commit 52bedcf67d
7 changed files with 439 additions and 5 deletions

View File

@@ -17,6 +17,7 @@ import {
Cell,
ComposedChart,
Line,
ReferenceArea,
ResponsiveContainer,
Tooltip,
XAxis,
@@ -32,6 +33,12 @@ import {
} from '../api/backend'
import { floorSlotUtcMs, SLOT_MS } from '../components/charts/chartConstants'
import { useSiteStatus } from '../hooks/useSiteStatus'
import {
maskForInterval,
parsePlanSolverSnapshot,
type PlanMaskSlot,
type PlanSolverSnapshot,
} from '../lib/planSolverSnapshot'
import type {
CurrentPlanResponse,
PlanningCompareResponse,
@@ -379,6 +386,7 @@ function genCutoffBadge(i: PlanningIntervalDto): { show: boolean; label: string;
function tableRowClass(
i: PlanningIntervalDto,
selected: boolean,
mask: PlanMaskSlot | null,
): string {
const parts: string[] = []
if (selected) parts.push('ring-1 ring-inset ring-cyan-500/50 bg-cyan-950/25')
@@ -387,9 +395,229 @@ function tableRowClass(
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')
else if (mask?.allow_charge) parts.push('border-l-4 border-l-emerald-600/70')
else if (mask?.allow_discharge_export) parts.push('border-l-4 border-l-orange-500/70')
return parts.join(' ')
}
function exportModeBadge(i: PlanningIntervalDto): {
label: string
klass: string
title: string
} {
const m = (i.export_mode ?? 'NONE').toString().trim().toUpperCase()
const cap =
i.export_limit_w != null && i.export_limit_w > 0
? ` · limit ${formatPlanPowerW(i.export_limit_w)}`
: ''
if (m === 'BATTERY_SELL') {
return {
label: 'BAT→síť',
klass: 'bg-orange-500/20 text-orange-100 ring-1 ring-orange-500/40',
title: `Export z baterie do sítě (BATTERY_SELL)${cap}`,
}
}
if (m === 'PV_SURPLUS') {
return {
label: 'FVE→síť',
klass: 'bg-amber-500/15 text-amber-100 ring-1 ring-amber-500/35',
title: `Export přebytku FVE (PV_SURPLUS)${cap}`,
}
}
return {
label: '—',
klass: 'text-slate-600',
title: `Bez exportu do sítě (NONE)${cap}`,
}
}
function MaskIconsCell({ mask }: { mask: PlanMaskSlot | null }) {
if (mask == null) {
return <td className="pr-2 text-slate-600"></td>
}
return (
<td className="pr-2">
<span className="inline-flex items-center gap-1.5 font-mono text-[10px]">
<span
className={
mask.allow_charge
? 'rounded bg-emerald-950/80 px-1 py-0.5 text-emerald-300 ring-1 ring-emerald-700/50'
: 'rounded px-1 py-0.5 text-slate-600'
}
title={mask.allow_charge ? 'Solver: povoleno grid nabíjení' : 'Grid nabíjení zakázáno'}
>
</span>
<span
className={
mask.allow_discharge_export
? 'rounded bg-orange-950/80 px-1 py-0.5 text-orange-200 ring-1 ring-orange-700/50'
: 'rounded px-1 py-0.5 text-slate-600'
}
title={
mask.allow_discharge_export
? 'Solver: povolen export baterie do sítě'
: 'Export baterie do sítě zakázán'
}
>
</span>
</span>
</td>
)
}
function PlanSolverInsights({ snap }: { snap: PlanSolverSnapshot }) {
const cutoffLabel = snap.chargeAcquisitionCutoffAt
? formatLocal(snap.chargeAcquisitionCutoffAt)
: '—'
return (
<div className="rounded-lg border border-slate-700/80 bg-slate-950/50 p-3 text-sm">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-slate-500">
Solver masky a arbitráž
</p>
<dl className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-xs text-slate-500">Nákupní cena zásoby (acquisition)</dt>
<dd className="font-mono text-slate-100">
{snap.chargeAcquisitionKwh != null
? `${snap.chargeAcquisitionKwh.toFixed(3)} Kč/kWh`
: '—'}
</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Řez před 1. exportem</dt>
<dd className="font-mono text-xs text-slate-200">{cutoffLabel}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Sloty nabíjení</dt>
<dd className="font-mono text-emerald-300">{snap.chargeMaskCount}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Sloty export bat.</dt>
<dd className="font-mono text-orange-300">{snap.exportMaskCount}</dd>
</div>
</dl>
<p className="mt-2 text-[11px] text-slate-500">
= allow_charge · = allow_discharge_export (z posledního běhu solveru). Zelený/oranžový okraj
řádku = stejná legenda.
</p>
</div>
)
}
function PlanSlotDetail({
i,
mask,
compare,
nowMs,
}: {
i: PlanningIntervalDto
mask: PlanMaskSlot | null
compare: PlanningIntervalDto | undefined
nowMs: number
}) {
const ex = exportModeBadge(i)
const spread =
i.effective_sell_price != null && i.effective_buy_price != null
? i.effective_sell_price - i.effective_buy_price
: null
return (
<div className="mt-3 rounded-lg border border-cyan-800/50 bg-cyan-950/20 p-3 text-sm text-slate-200">
<p className="mb-2 font-medium text-cyan-100">{formatLocal(i.interval_start)}</p>
<div className="flex flex-wrap gap-2">
<span className={`inline-flex rounded-md px-2 py-0.5 text-[10px] font-semibold ${ex.klass}`} title={ex.title}>
{ex.label}
</span>
{(() => {
const b = deyeModeBadge(i)
return (
<span className={`inline-flex rounded-md px-2 py-0.5 text-[10px] font-semibold ${b.klass}`} title={b.title}>
Deye {b.label}
</span>
)
})()}
{mask?.allow_charge ? (
<span className="rounded bg-emerald-950/60 px-2 py-0.5 text-[10px] text-emerald-300"> nabíjení OK</span>
) : null}
{mask?.allow_discharge_export ? (
<span className="rounded bg-orange-950/60 px-2 py-0.5 text-[10px] text-orange-200"> export bat. OK</span>
) : null}
</div>
<dl className="mt-3 grid gap-2 font-mono text-xs sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt className="text-slate-500">Cena kup / prod</dt>
<dd>
{i.effective_buy_price?.toFixed(3) ?? '—'} / {i.effective_sell_price?.toFixed(3) ?? '—'}
{isPredictedPriceSlot(i, nowMs) ? ' (odhad)' : ''}
</dd>
</div>
<div>
<dt className="text-slate-500">Spread prodkup</dt>
<dd className={spread != null && spread > 0 ? 'text-emerald-400' : ''}>
{spread != null ? `${spread.toFixed(3)} Kč/kWh` : '—'}
</dd>
</div>
<div>
<dt className="text-slate-500">Bat. / síť / SoC</dt>
<dd>
{formatPlanPowerW(i.battery_setpoint_w)} / {formatPlanPowerW(i.grid_setpoint_w)} /{' '}
{i.battery_soc_target_pct != null ? `${i.battery_soc_target_pct.toFixed(1)} %` : '—'}
</dd>
</div>
<div>
<dt className="text-slate-500">FVE / dům</dt>
<dd>
{formatPlanPowerW(slotFveDisplayW(i, nowMs))} / {formatPlanPowerW(i.load_baseline_w)}
</dd>
</div>
<div>
<dt className="text-slate-500">Škrcení A</dt>
<dd>{(i.pv_a_curtailed_w ?? 0) > 0 ? `${i.pv_a_curtailed_w} W` : '—'}</dd>
</div>
<div>
<dt className="text-slate-500">Výnos slotu</dt>
<dd>{i.expected_cost_czk != null ? `${i.expected_cost_czk.toFixed(4)}` : '—'}</dd>
</div>
</dl>
<p className="mt-2 text-[11px] text-slate-500">{deyeSetpointLabel(i)}</p>
{compare ? (
<p className="mt-1 text-[11px] text-amber-200/90">
Compare: bat. {compare.battery_setpoint_w ?? '—'} W · síť {compare.grid_setpoint_w ?? '—'} W · export{' '}
{compare.export_mode ?? '—'}
</p>
) : null}
</div>
)
}
type ChartMaskBand = { x1: string; x2: string }
function buildChartMaskBands(
chartRows: ChartRow[],
snap: PlanSolverSnapshot | null,
flag: 'allow_charge' | 'allow_discharge_export',
): ChartMaskBand[] {
if (snap == null || chartRows.length === 0) return []
const bands: ChartMaskBand[] = []
let start: string | null = null
let prev: string | null = null
for (const row of chartRows) {
const m = maskForInterval(snap, row.raw.interval_start)
const on = m != null && m[flag]
if (on) {
if (start == null) start = row.label
prev = row.label
} else if (start != null && prev != null) {
bands.push({ x1: start, x2: prev })
start = null
prev = null
}
}
if (start != null && prev != null) bands.push({ x1: start, x2: prev })
return bands
}
type ChartRow = {
label: string
ts: number
@@ -496,10 +724,12 @@ function PlanTooltip({
active,
payload,
nowMs,
solverSnap,
}: {
active?: boolean
payload?: Array<{ payload: ChartRow }>
nowMs: number
solverSnap?: PlanSolverSnapshot | null
}) {
if (!active || !payload?.length) return null
const p = payload[0].payload
@@ -514,6 +744,7 @@ function PlanTooltip({
const exportLimit = i.export_limit_w
const exportMode = i.export_mode ?? 'NONE'
const compareBattery = p.compare_battery_setpoint_w
const mask = maskForInterval(solverSnap ?? null, i.interval_start)
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>
@@ -537,6 +768,12 @@ function PlanTooltip({
{exportLimit != null ? ` · limit ${formatPlanPowerW(exportLimit)}` : ''}
</div>
) : null}
{mask ? (
<div className="text-slate-400">
Masky: {mask.allow_charge ? '⚡ nabíjení' : '—'} ·{' '}
{mask.allow_discharge_export ? '↓ export bat.' : '—'}
</div>
) : null}
<div>SoC cíl: {soc != null && !Number.isNaN(Number(soc)) ? `${Number(soc).toFixed(1)} %` : '—'}</div>
<div>Dům: {i.load_baseline_w ?? '—'} W</div>
<div>Baterie: {i.battery_setpoint_w ?? '—'} W</div>
@@ -840,6 +1077,35 @@ export default function Planning() {
const run = data?.run
const summary = data?.summary
const solverSnap = useMemo(
() =>
parsePlanSolverSnapshot(
run != null ? (run as unknown as Record<string, unknown>) : undefined,
),
[run],
)
const chartChargeBands = useMemo(
() => buildChartMaskBands(chartRows, solverSnap, 'allow_charge'),
[chartRows, solverSnap],
)
const chartExportBands = useMemo(
() => buildChartMaskBands(chartRows, solverSnap, 'allow_discharge_export'),
[chartRows, solverSnap],
)
const compareIntervalByStart = useMemo(() => {
const list = compareData?.comparison?.intervals ?? []
return new Map(list.map((i) => [i.interval_start, i]))
}, [compareData?.comparison?.intervals])
const selectedSlot = useMemo(
() => visibleSlots.find((s) => s.interval_start === selectedStart) ?? null,
[visibleSlots, selectedStart],
)
const tableColCount = 13 + (solverSnap != null ? 1 : 0) + (showGenCut ? 1 : 0)
const showPrepActions = !loading
const prepBusy = prepAction !== null
@@ -949,6 +1215,7 @@ export default function Planning() {
</span>
</div>
)}
{solverSnap != null && <PlanSolverInsights snap={solverSnap} />}
{summary && (
<div className="border-t border-slate-800 pt-3 text-sm">
<p className="mb-2 text-slate-500">Summary</p>
@@ -1146,6 +1413,12 @@ export default function Planning() {
{/* Sekce 3 */}
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
<h2 className="mb-1 text-sm font-medium uppercase tracking-wide text-slate-400">Graf plánu</h2>
{solverSnap != null && (
<p className="mb-2 text-[11px] text-slate-500">
Pásy: <span className="text-emerald-400/90">zelená</span> = okno grid nabíjení () ·{' '}
<span className="text-orange-400/90">oranžová</span> = okno exportu baterie ().
</p>
)}
<HorizonToggle
value={chartHorizonH}
onChange={setChartHorizonH}
@@ -1198,7 +1471,29 @@ export default function Planning() {
offset: 10,
}}
/>
<Tooltip content={<PlanTooltip nowMs={nowMs} />} />
<Tooltip content={<PlanTooltip nowMs={nowMs} solverSnap={solverSnap} />} />
{chartChargeBands.map((b) => (
<ReferenceArea
key={`chg-${b.x1}-${b.x2}`}
yAxisId="power"
x1={b.x1}
x2={b.x2}
strokeOpacity={0}
fill="#10b981"
fillOpacity={0.08}
/>
))}
{chartExportBands.map((b) => (
<ReferenceArea
key={`exp-${b.x1}-${b.x2}`}
x1={b.x1}
x2={b.x2}
yAxisId="power"
strokeOpacity={0}
fill="#f97316"
fillOpacity={0.1}
/>
))}
<Area
yAxisId="power"
type="monotone"
@@ -1287,6 +1582,20 @@ export default function Planning() {
+ nabíj · vybíj
</span>
</th>
<th
className="whitespace-nowrap py-2 pr-2 font-medium"
title="Záměr exportu do sítě z LP (NONE / PV_SURPLUS / BATTERY_SELL)"
>
Export
</th>
{solverSnap != null ? (
<th
className="whitespace-nowrap py-2 pr-2 font-medium"
title="Masky z posledního solveru: ⚡ allow_charge, ↓ allow_discharge_export"
>
Masky
</th>
) : null}
<th className="whitespace-nowrap py-2 pr-2 font-medium">Deye setpoint</th>
{showGenCut ? (
<th className="whitespace-nowrap py-2 pr-2 font-medium" title="GEN port cut-off (BA81 / mikroinvertory)">
@@ -1322,7 +1631,7 @@ export default function Planning() {
key={`sum-${row.dayKey}`}
className="border-b border-slate-700/90 bg-slate-800/70 text-slate-200"
>
<td colSpan={showGenCut ? 13 : 12} className="px-2 py-2 text-xs font-medium">
<td colSpan={tableColCount} className="px-2 py-2 text-xs font-medium">
<span className="text-slate-100">{row.dateLabel}</span>
<span className="mx-2 text-slate-600">·</span>
<span className="text-slate-400">FVE celkem</span>{' '}
@@ -1345,6 +1654,8 @@ export default function Planning() {
}
const i = row.i
const sel = selectedStart === i.interval_start
const slotMask = maskForInterval(solverSnap, i.interval_start)
const exBadge = exportModeBadge(i)
return (
<tr
key={i.interval_start}
@@ -1357,13 +1668,28 @@ export default function Planning() {
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)}`}
className={`cursor-pointer border-b border-slate-800/80 transition hover:bg-slate-800/40 ${tableRowClass(i, sel, slotMask)}`}
>
<td className="whitespace-nowrap py-1.5 pl-2 pr-2 font-mono text-slate-300">
{formatLocalTime(i.interval_start)}
</td>
<CenaCell i={i} nowMs={nowMs} />
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.battery_setpoint_w ?? '—'}</td>
<td className="pr-2">
{exBadge.label !== '—' ? (
<span
className={`inline-flex rounded-md px-1.5 py-0.5 text-[10px] font-semibold ${exBadge.klass}`}
title={exBadge.title}
>
{exBadge.label}
</span>
) : (
<span className="text-slate-600" title={exBadge.title}>
</span>
)}
</td>
{solverSnap != null ? <MaskIconsCell mask={slotMask} /> : null}
<td className="max-w-[200px] whitespace-normal break-words pr-2 text-slate-300">
<div className="flex flex-wrap items-center gap-2">
{(() => {
@@ -1423,6 +1749,19 @@ export default function Planning() {
Žádné budoucí sloty v horizontu {tableHorizonH} h (aktivní plán může být prázdný nebo starý).
</p>
)}
{selectedSlot != null && (
<PlanSlotDetail
i={selectedSlot}
mask={maskForInterval(solverSnap, selectedSlot.interval_start)}
compare={compareIntervalByStart.get(selectedSlot.interval_start)}
nowMs={nowMs}
/>
)}
{!solverSnap && run != null && (
<p className="mt-2 text-[11px] text-slate-500">
Masky solveru nejsou v tomto běhu spusťte nový rolling/denní plán po nasazení arbitráže.
</p>
)}
</section>
</div>
)