second version
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
} from 'recharts'
|
||||
|
||||
import { getCurrentPlan, postImportSitePrices, postRunForecast, postRunPlan } from '../api/backend'
|
||||
import { floorSlotUtcMs, SLOT_MS } from '../components/charts/chartConstants'
|
||||
import { useSiteStatus } from '../hooks/useSiteStatus'
|
||||
import type { CurrentPlanResponse, PlanningIntervalDto } from '../types/plan'
|
||||
|
||||
@@ -48,10 +49,115 @@ function formatLocalTime(iso: string): string {
|
||||
})
|
||||
}
|
||||
|
||||
function pragueYmd(d: Date): string {
|
||||
return new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: TZ,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(d)
|
||||
}
|
||||
|
||||
function slotStartUtcMs(iso: string): number {
|
||||
return new Date(iso).getTime()
|
||||
}
|
||||
|
||||
const PREDICTED_LEAD_MS = 36 * 60 * 60 * 1000
|
||||
const MAX_FUTURE_SLOTS = 384
|
||||
|
||||
function pragueDayKey(iso: string): string {
|
||||
return new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: TZ,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date(iso))
|
||||
}
|
||||
|
||||
function formatPragueDateLabel(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('cs-CZ', {
|
||||
timeZone: TZ,
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function isPredictedPriceSlot(i: PlanningIntervalDto, nowMs: number): boolean {
|
||||
if (i.is_predicted_price === true) return true
|
||||
if (i.is_predicted_price === false) return false
|
||||
return slotStartUtcMs(i.interval_start) > nowMs + PREDICTED_LEAD_MS
|
||||
}
|
||||
|
||||
function groupByDay(slots: PlanningIntervalDto[]): Record<string, PlanningIntervalDto[]> {
|
||||
return slots.reduce(
|
||||
(acc, slot) => {
|
||||
const day = pragueDayKey(slot.interval_start)
|
||||
if (!acc[day]) acc[day] = []
|
||||
acc[day].push(slot)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, PlanningIntervalDto[]>,
|
||||
)
|
||||
}
|
||||
|
||||
function dayStats(slots: PlanningIntervalDto[]): {
|
||||
fveKwh: number
|
||||
exportKwh: number
|
||||
avgBuy: number | null
|
||||
} {
|
||||
const slotHours = SLOT_MS / 3_600_000
|
||||
let fveWh = 0
|
||||
let expWh = 0
|
||||
const buys: number[] = []
|
||||
for (const s of slots) {
|
||||
fveWh += (s.pv_forecast_total_w ?? 0) * slotHours
|
||||
const gw = s.grid_setpoint_w ?? 0
|
||||
if (gw < 0) expWh += -gw * slotHours
|
||||
if (s.effective_buy_price != null) buys.push(s.effective_buy_price)
|
||||
}
|
||||
const avgBuy = buys.length ? buys.reduce((a, b) => a + b, 0) / buys.length : null
|
||||
return { fveKwh: fveWh / 1000, exportKwh: expWh / 1000, avgBuy }
|
||||
}
|
||||
|
||||
type HorizonHours = 24 | 48 | 96
|
||||
|
||||
type PlanTableRow =
|
||||
| {
|
||||
kind: 'summary'
|
||||
dayKey: string
|
||||
dateLabel: string
|
||||
fveKwh: number
|
||||
exportKwh: number
|
||||
avgBuy: number | null
|
||||
}
|
||||
| { kind: 'slot'; i: PlanningIntervalDto }
|
||||
|
||||
function buildPlanTableRows(visibleSlots: PlanningIntervalDto[]): PlanTableRow[] {
|
||||
const groups = groupByDay(visibleSlots)
|
||||
const dayKeys = [...new Set(visibleSlots.map((s) => pragueDayKey(s.interval_start)))].sort()
|
||||
const rows: PlanTableRow[] = []
|
||||
for (const dk of dayKeys) {
|
||||
const sl = groups[dk]
|
||||
if (!sl?.length) continue
|
||||
rows.push({
|
||||
kind: 'summary',
|
||||
dayKey: dk,
|
||||
dateLabel: formatPragueDateLabel(sl[0]!.interval_start),
|
||||
...dayStats(sl),
|
||||
})
|
||||
for (const i of sl) rows.push({ kind: 'slot', i })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function horizonToggleClass(active: boolean): string {
|
||||
return active
|
||||
? 'border-cyan-600 bg-cyan-950/50 text-cyan-100'
|
||||
: 'border-slate-600 bg-slate-800/80 text-slate-300 hover:bg-slate-800'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
@@ -67,6 +173,53 @@ function pvAProxyW(i: PlanningIntervalDto): number {
|
||||
return Math.max(0, Math.min(15000, w))
|
||||
}
|
||||
|
||||
/** Budoucí slot (od začátku ještě nenastal): předpověď; proběhlý / probíhající: telemetrie z auditu. */
|
||||
function slotFveDisplayW(i: PlanningIntervalDto, nowMs: number): number | null {
|
||||
const start = slotStartUtcMs(i.interval_start)
|
||||
const future = start >= nowMs
|
||||
if (future) {
|
||||
const f = i.pv_forecast_total_w
|
||||
if (f != null) return Number(f)
|
||||
return null
|
||||
}
|
||||
const a = i.pv_power_w
|
||||
if (a != null) return Number(a)
|
||||
const f = i.pv_forecast_total_w
|
||||
return f != null ? Number(f) : null
|
||||
}
|
||||
|
||||
/** Stejná idea jako výkonové buňky: velké hodnoty v kW, jinak W (bez suffixu u malých čísel jako Bat. W). */
|
||||
function formatPlanPowerW(w: number | null): string {
|
||||
if (w == null || Number.isNaN(w)) return '—'
|
||||
const v = Math.round(Number(w))
|
||||
if (Math.abs(v) >= 1000) {
|
||||
const k = v / 1000
|
||||
const s = k.toFixed(1).replace(/\.0$/, '')
|
||||
return `${s} kW`
|
||||
}
|
||||
return String(v)
|
||||
}
|
||||
|
||||
function FveWCell({ i, nowMs }: { i: PlanningIntervalDto; nowMs: number }) {
|
||||
const w = slotFveDisplayW(i, nowMs)
|
||||
const color =
|
||||
w == null || Number.isNaN(w) ? 'text-slate-500' : w > 0 ? 'text-emerald-400' : 'text-slate-500'
|
||||
return (
|
||||
<td className={`pr-2 font-mono tabular-nums ${color}`}>{formatPlanPowerW(w)}</td>
|
||||
)
|
||||
}
|
||||
|
||||
function VynosKcCell({ v }: { v: number | null | undefined }) {
|
||||
if (v == null || Number.isNaN(Number(v))) {
|
||||
return <td className="pr-2 font-mono tabular-nums text-slate-500">—</td>
|
||||
}
|
||||
const n = Number(v)
|
||||
const color = n < 0 ? 'text-emerald-400' : n > 0 ? 'text-red-400' : 'text-slate-500'
|
||||
return (
|
||||
<td className={`pr-2 font-mono tabular-nums ${color}`}>{n.toFixed(4)}</td>
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
@@ -90,6 +243,31 @@ function axiosDetail(e: unknown): string {
|
||||
return e instanceof Error ? e.message : 'Neznámá chyba'
|
||||
}
|
||||
|
||||
/** Zrcadlí logiku TOU řádků z `write_inverter_setpoints` (PASSIVE/SELL/CHARGE) pro jeden plánovací interval. */
|
||||
function deyeSetpointLabel(i: PlanningIntervalDto): string {
|
||||
const battery_w = i.battery_setpoint_w ?? 0
|
||||
const grid_w = i.grid_setpoint_w ?? 0
|
||||
const is_exporting = battery_w < -500 || grid_w < -500
|
||||
const is_charging = battery_w > 500
|
||||
const tgt = i.battery_soc_target_pct
|
||||
const targetSoc = tgt != null ? Math.min(95, Math.round(Number(tgt))) : 80
|
||||
|
||||
const fmtKw = (w: number) => {
|
||||
const k = Math.abs(w) / 1000
|
||||
const s = k.toFixed(1).replace(/\.0$/, '')
|
||||
return `${s}kW`
|
||||
}
|
||||
|
||||
if (is_exporting) {
|
||||
const tpPowerW = Math.abs(battery_w)
|
||||
return `⬇ ${fmtKw(tpPowerW)} | reg178 bit4–5=10 (grid PS off)`
|
||||
}
|
||||
if (is_charging) {
|
||||
return `⬆ ${fmtKw(battery_w)} | grid=yes | SOC→${targetSoc}%`
|
||||
}
|
||||
return '~ 2kW | hold'
|
||||
}
|
||||
|
||||
function tableRowClass(
|
||||
i: PlanningIntervalDto,
|
||||
selected: boolean,
|
||||
@@ -117,6 +295,8 @@ type ChartRow = {
|
||||
type PlanPrepActionsProps = {
|
||||
prepAction: null | 'import' | 'forecast' | 'init'
|
||||
replanning: boolean
|
||||
importDate: 'today' | 'tomorrow'
|
||||
onImportDateChange: (v: 'today' | 'tomorrow') => void
|
||||
onImport: () => void
|
||||
onForecast: () => void
|
||||
onInit: () => void
|
||||
@@ -126,6 +306,8 @@ type PlanPrepActionsProps = {
|
||||
function PlanPrepActions({
|
||||
prepAction,
|
||||
replanning,
|
||||
importDate,
|
||||
onImportDateChange,
|
||||
onImport,
|
||||
onForecast,
|
||||
onInit,
|
||||
@@ -148,6 +330,18 @@ function PlanPrepActions({
|
||||
)}
|
||||
Importovat ceny
|
||||
</button>
|
||||
<label className="inline-flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-900/60 px-3 py-2 text-xs text-slate-300">
|
||||
Den OTE
|
||||
<select
|
||||
value={importDate}
|
||||
onChange={(e) => onImportDateChange(e.target.value === 'today' ? 'today' : 'tomorrow')}
|
||||
disabled={dis}
|
||||
className="rounded border border-slate-600 bg-slate-800 px-2 py-1 text-xs text-slate-100"
|
||||
>
|
||||
<option value="today">dnes</option>
|
||||
<option value="tomorrow">zítra</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onForecast}
|
||||
@@ -178,15 +372,27 @@ function PlanPrepActions({
|
||||
)
|
||||
}
|
||||
|
||||
function PlanTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: ChartRow }> }) {
|
||||
function PlanTooltip({
|
||||
active,
|
||||
payload,
|
||||
nowMs,
|
||||
}: {
|
||||
active?: boolean
|
||||
payload?: Array<{ payload: ChartRow }>
|
||||
nowMs: number
|
||||
}) {
|
||||
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
|
||||
const pred = isPredictedPriceSlot(i, nowMs)
|
||||
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>
|
||||
{pred && (
|
||||
<div className="mb-1 text-[10px] uppercase tracking-wide text-slate-500">Cena: odhad (predikce)</div>
|
||||
)}
|
||||
<div className="space-y-0.5 font-mono tabular-nums">
|
||||
<div>
|
||||
Cena nákup: {buy != null ? `${buy.toFixed(3)} Kč/kWh` : '—'} · prodej:{' '}
|
||||
@@ -203,6 +409,59 @@ function PlanTooltip({ active, payload }: { active?: boolean; payload?: Array<{
|
||||
)
|
||||
}
|
||||
|
||||
function CenaCell({ i, nowMs }: { i: PlanningIntervalDto; nowMs: number }) {
|
||||
const pred = isPredictedPriceSlot(i, nowMs)
|
||||
return (
|
||||
<td className={`max-w-[200px] pr-2 font-mono text-xs tabular-nums ${pred ? 'text-slate-500' : 'text-slate-300'}`}>
|
||||
<span className="inline-flex flex-wrap items-center gap-x-1.5 align-middle">
|
||||
{pred && (
|
||||
<span
|
||||
className="shrink-0 rounded bg-slate-700/70 px-1 py-0.5 text-[10px] font-sans font-semibold uppercase tracking-wide text-slate-400"
|
||||
title="Predikovaná cena (mimo přesné OTE)"
|
||||
>
|
||||
odhad
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{i.effective_buy_price != null ? i.effective_buy_price.toFixed(3) : '—'}
|
||||
<span className="text-slate-600"> / </span>
|
||||
{i.effective_sell_price != null ? i.effective_sell_price.toFixed(3) : '—'}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
function HorizonToggle({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: HorizonHours
|
||||
onChange: (h: HorizonHours) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const opts: HorizonHours[] = [24, 48, 96]
|
||||
return (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-slate-500">Horizont:</span>
|
||||
<div className="flex gap-1">
|
||||
{opts.map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(h)}
|
||||
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition disabled:opacity-50 ${horizonToggleClass(value === h)}`}
|
||||
>
|
||||
{h}h
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Planning() {
|
||||
const { site, ready: siteReady } = useSiteStatus()
|
||||
const siteId = site?.site_id ?? null
|
||||
@@ -212,7 +471,10 @@ export default function Planning() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [replanning, setReplanning] = useState(false)
|
||||
const [prepAction, setPrepAction] = useState<null | 'import' | 'forecast' | 'init'>(null)
|
||||
const [importDate, setImportDate] = useState<'today' | 'tomorrow'>('tomorrow')
|
||||
const [selectedStart, setSelectedStart] = useState<string | null>(null)
|
||||
const [tableHorizonH, setTableHorizonH] = useState<HorizonHours>(48)
|
||||
const [chartHorizonH, setChartHorizonH] = useState<HorizonHours>(48)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteId == null) return
|
||||
@@ -239,36 +501,46 @@ export default function Planning() {
|
||||
}, [siteId, load])
|
||||
|
||||
const nowMs = Date.now()
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
const slotFloorMs = floorSlotUtcMs(nowMs)
|
||||
|
||||
const intervals24h = useMemo(() => {
|
||||
const futureSlots = 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])
|
||||
.filter((i) => slotStartUtcMs(i.interval_start) >= slotFloorMs)
|
||||
.sort((a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start))
|
||||
.slice(0, MAX_FUTURE_SLOTS)
|
||||
}, [data?.intervals, slotFloorMs])
|
||||
|
||||
const visibleSlots = useMemo(() => {
|
||||
const endMs = nowMs + tableHorizonH * 60 * 60 * 1000
|
||||
return futureSlots.filter((s) => slotStartUtcMs(s.interval_start) <= endMs)
|
||||
}, [futureSlots, nowMs, tableHorizonH])
|
||||
|
||||
const chartIntervals = useMemo(() => {
|
||||
const endMs = nowMs + chartHorizonH * 60 * 60 * 1000
|
||||
return futureSlots.filter((s) => slotStartUtcMs(s.interval_start) <= endMs)
|
||||
}, [futureSlots, nowMs, chartHorizonH])
|
||||
|
||||
const planTableRows = useMemo(() => buildPlanTableRows(visibleSlots), [visibleSlots])
|
||||
|
||||
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)
|
||||
if (!chartIntervals.length) return undefined
|
||||
const stepH = chartHorizonH <= 24 ? 2 : chartHorizonH <= 48 ? 4 : 6
|
||||
const stepMs = stepH * 60 * 60 * 1000
|
||||
const first = slotStartUtcMs(chartIntervals[0].interval_start)
|
||||
const last = slotStartUtcMs(chartIntervals[chartIntervals.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)
|
||||
const hit = chartIntervals.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])
|
||||
}, [chartIntervals, chartHorizonH])
|
||||
|
||||
const chartRows: ChartRow[] = useMemo(() => {
|
||||
return intervals24h.map((i) => ({
|
||||
return chartIntervals.map((i) => ({
|
||||
label: formatLocalTime(i.interval_start),
|
||||
ts: slotStartUtcMs(i.interval_start),
|
||||
pv_a_w: pvAProxyW(i),
|
||||
@@ -277,7 +549,7 @@ export default function Planning() {
|
||||
effective_buy_price: i.effective_buy_price,
|
||||
raw: i,
|
||||
}))
|
||||
}, [intervals24h])
|
||||
}, [chartIntervals])
|
||||
|
||||
async function onReplan() {
|
||||
if (siteId == null) return
|
||||
@@ -304,7 +576,11 @@ export default function Planning() {
|
||||
setPrepAction('import')
|
||||
setError(null)
|
||||
try {
|
||||
const r = await postImportSitePrices(siteId)
|
||||
const selectedDate = new Date()
|
||||
if (importDate === 'tomorrow') {
|
||||
selectedDate.setDate(selectedDate.getDate() + 1)
|
||||
}
|
||||
const r = await postImportSitePrices(siteId, pragueYmd(selectedDate))
|
||||
toast.success(
|
||||
`Ceny: ${r.slots_imported} slotů (${r.date}), první ${r.first_price_czk_kwh.toFixed(3)} Kč/kWh`,
|
||||
)
|
||||
@@ -336,7 +612,11 @@ export default function Planning() {
|
||||
setPrepAction('init')
|
||||
setError(null)
|
||||
try {
|
||||
const imp = await postImportSitePrices(siteId)
|
||||
const selectedDate = new Date()
|
||||
if (importDate === 'tomorrow') {
|
||||
selectedDate.setDate(selectedDate.getDate() + 1)
|
||||
}
|
||||
const imp = await postImportSitePrices(siteId, pragueYmd(selectedDate))
|
||||
toast.success(
|
||||
`Ceny: ${imp.slots_imported} slotů (${imp.date}), první ${imp.first_price_czk_kwh.toFixed(3)} Kč/kWh`,
|
||||
)
|
||||
@@ -370,9 +650,7 @@ export default function Planning() {
|
||||
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 showPrepActions = !loading
|
||||
const prepBusy = prepAction !== null
|
||||
|
||||
const correctionPct =
|
||||
@@ -384,7 +662,8 @@ export default function Planning() {
|
||||
<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'})
|
||||
Aktuální LP plán až 96 h od aktuálního slotu ({site?.site_name ?? 'lokalita'}) — tabulka a graf lze zúžit
|
||||
horizontem 24 / 48 / 96 h.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -410,6 +689,8 @@ export default function Planning() {
|
||||
<PlanPrepActions
|
||||
prepAction={prepAction}
|
||||
replanning={replanning}
|
||||
importDate={importDate}
|
||||
onImportDateChange={setImportDate}
|
||||
onImport={() => void handleImportPrices()}
|
||||
onForecast={() => void handleRunForecast()}
|
||||
onInit={() => void handleInitializePlan()}
|
||||
@@ -462,6 +743,17 @@ export default function Planning() {
|
||||
{run.solver_duration_ms != null ? `${run.solver_duration_ms} ms` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
{summary?.pv_scarcity_factor != null && (
|
||||
<div className="text-sm">
|
||||
<span className="text-slate-500">PV scarcity factor: </span>
|
||||
<span className="font-mono text-slate-200">
|
||||
{summary.pv_scarcity_factor.toFixed(3)}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-slate-500">
|
||||
(nižší = méně očekávaného slunce, ekonomika víc toleruje precharge ze sítě)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{summary && (
|
||||
<div className="border-t border-slate-800 pt-3 text-sm">
|
||||
<p className="mb-2 text-slate-500">Summary</p>
|
||||
@@ -501,6 +793,8 @@ export default function Planning() {
|
||||
<PlanPrepActions
|
||||
prepAction={prepAction}
|
||||
replanning={replanning}
|
||||
importDate={importDate}
|
||||
onImportDateChange={setImportDate}
|
||||
onImport={() => void handleImportPrices()}
|
||||
onForecast={() => void handleRunForecast()}
|
||||
onInit={() => void handleInitializePlan()}
|
||||
@@ -523,9 +817,12 @@ export default function Planning() {
|
||||
|
||||
{/* 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>
|
||||
<h2 className="mb-1 text-sm font-medium uppercase tracking-wide text-slate-400">Graf plánu</h2>
|
||||
<HorizonToggle value={chartHorizonH} onChange={setChartHorizonH} disabled={futureSlots.length === 0} />
|
||||
{!chartRows.length ? (
|
||||
<p className="text-sm text-slate-500">Žádná data pro graf (24 h od teď, max. 96 slotů).</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
Žádná data pro graf (budoucí sloty aktivního plánu, horizont {chartHorizonH} h).
|
||||
</p>
|
||||
) : (
|
||||
<div className="h-[350px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
@@ -534,11 +831,11 @@ export default function Planning() {
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
ticks={xTicks}
|
||||
tick={{ fill: '#94a3b8', fontSize: 10 }}
|
||||
tick={{ fill: '#94a3b8', fontSize: 9 }}
|
||||
interval={0}
|
||||
angle={-35}
|
||||
textAnchor="end"
|
||||
height={48}
|
||||
height={52}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="power"
|
||||
@@ -568,7 +865,7 @@ export default function Planning() {
|
||||
offset: 10,
|
||||
}}
|
||||
/>
|
||||
<Tooltip content={<PlanTooltip />} />
|
||||
<Tooltip content={<PlanTooltip nowMs={nowMs} />} />
|
||||
<Area
|
||||
yAxisId="power"
|
||||
type="monotone"
|
||||
@@ -616,25 +913,58 @@ 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-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">
|
||||
<h2 className="mb-1 text-sm font-medium uppercase tracking-wide text-slate-400">Tabulka slotů</h2>
|
||||
<HorizonToggle value={tableHorizonH} onChange={setTableHorizonH} disabled={futureSlots.length === 0} />
|
||||
<div className="max-h-[min(70vh,720px)] 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">
|
||||
<span className="block">Cena</span>
|
||||
<span className="block text-[10px] font-normal normal-case text-slate-600">Kč/kWh · kup / prod</span>
|
||||
</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Bat. W</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Deye setpoint</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">SoC %</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">FVE W</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>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Výnos Kč</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{intervals24h.map((i) => {
|
||||
{planTableRows.map((row) => {
|
||||
if (row.kind === 'summary') {
|
||||
return (
|
||||
<tr
|
||||
key={`sum-${row.dayKey}`}
|
||||
className="border-b border-slate-700/90 bg-slate-800/70 text-slate-200"
|
||||
>
|
||||
<td colSpan={11} 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>{' '}
|
||||
<span className="font-mono tabular-nums text-slate-200">
|
||||
{row.fveKwh.toLocaleString('cs-CZ', { maximumFractionDigits: 3 })} kWh
|
||||
</span>
|
||||
<span className="mx-2 text-slate-600">·</span>
|
||||
<span className="text-slate-400">Export celkem</span>{' '}
|
||||
<span className="font-mono tabular-nums text-slate-200">
|
||||
{row.exportKwh.toLocaleString('cs-CZ', { maximumFractionDigits: 3 })} kWh
|
||||
</span>
|
||||
<span className="mx-2 text-slate-600">·</span>
|
||||
<span className="text-slate-400">Prům. cena nákup</span>{' '}
|
||||
<span className="font-mono tabular-nums text-slate-200">
|
||||
{row.avgBuy != null ? `${row.avgBuy.toFixed(3)} Kč/kWh` : '—'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
const i = row.i
|
||||
const sel = selectedStart === i.interval_start
|
||||
return (
|
||||
<tr
|
||||
@@ -653,33 +983,32 @@ export default function Planning() {
|
||||
<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>
|
||||
<CenaCell i={i} nowMs={nowMs} />
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.battery_setpoint_w ?? '—'}</td>
|
||||
<td className="max-w-[200px] whitespace-normal break-words pr-2 text-slate-300">
|
||||
{deyeSetpointLabel(i)}
|
||||
</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>
|
||||
<FveWCell i={i} nowMs={nowMs} />
|
||||
<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>
|
||||
<VynosKcCell v={i.expected_cost_czk} />
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!intervals24h.length && !loading && (
|
||||
<p className="mt-2 text-sm text-slate-500">Žádné řádky v 24h okně.</p>
|
||||
{!visibleSlots.length && !loading && (
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
Žádné budoucí sloty v horizontu {tableHorizonH} h (aktivní plán může být prázdný nebo starý).
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user