uprava UI pro planovani
This commit is contained in:
@@ -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 prod−kup</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)} Kč` : '—'}</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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user