This commit is contained in:
Dusan Vojacek
2026-03-20 14:30:03 +01:00
parent 2cc5ccfda7
commit 897b95f728
48 changed files with 4034 additions and 842 deletions

View File

@@ -1,10 +1,31 @@
import { Battery, Sun, Zap } from 'lucide-react'
import { PowerFlowCard } from '../components/PowerFlowCard'
import { SocGauge } from '../components/SocGauge'
import { TelemetryChart } from '../components/TelemetryChart'
import { useState } from 'react'
import { Sun, Battery, Zap, Home, ChevronDown, ChevronUp } from 'lucide-react'
import {
Area,
Bar,
CartesianGrid,
Cell,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { useAuditDailyToday } from '../hooks/useAuditDailyToday'
import { useCurrentPlan } from '../hooks/useCurrentPlan'
import { useFullStatus } from '../hooks/useFullStatus'
import { useSiteStatus } from '../hooks/useSiteStatus'
import { useTelemetryToday } from '../hooks/useTelemetryToday'
import { useTelemetryToday, type TelemetryChartPoint } from '../hooks/useTelemetryToday'
import type { PlanningIntervalDto } from '../types/plan'
const BAT_PLAN_W = 80
function fmtKw2(w: number | null | undefined): string {
if (w == null || Number.isNaN(w)) return '—'
return `${(w / 1000).toFixed(2)} kW`
}
function fmtEnergy(v: string | number | null | undefined): string {
const n = typeof v === 'number' ? v : v == null ? NaN : Number(v)
@@ -18,6 +39,13 @@ function fmtMoney(v: string | number | null | undefined): string {
return `${n.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} Kč`
}
function parseNum(v: string | number | null | undefined): number | null {
if (v == null) return null
if (typeof v === 'number' && !Number.isNaN(v)) return v
const n = Number(v)
return Number.isFinite(n) ? n : null
}
function modeBadgeClass(code: string | null): string {
const c = (code ?? '').toUpperCase()
if (c.includes('AUTO')) return 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/35'
@@ -27,146 +55,571 @@ function modeBadgeClass(code: string | null): string {
return 'bg-slate-700/60 text-slate-200 ring-1 ring-slate-600/50'
}
function batteryStyles(powerW: number | null | undefined): { border: string; icon: string } {
if (powerW == null || Number.isNaN(powerW)) {
return { border: 'border-l-slate-600', icon: 'text-slate-400' }
}
if (powerW >= 0) {
return { border: 'border-l-emerald-500', icon: 'text-emerald-400' }
}
return { border: 'border-l-orange-500', icon: 'text-orange-400' }
function formatTelemetryAgo(iso: string | null | undefined): string {
if (iso == null) return '—'
const diffMin = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000)
if (diffMin <= 0) return 'právě teď'
if (diffMin === 1) return 'před 1 minutou'
if (diffMin >= 2 && diffMin <= 4) return `před ${diffMin} minutami`
return `před ${diffMin} minutami`
}
function gridStyles(powerW: number | null | undefined): { border: string; icon: string } {
if (powerW == null || Number.isNaN(powerW)) {
return { border: 'border-l-slate-600', icon: 'text-slate-400' }
}
if (powerW >= 0) {
return { border: 'border-l-red-500', icon: 'text-red-400' }
}
return { border: 'border-l-emerald-500', icon: 'text-emerald-400' }
function floorToSlotUtc(ms: number): number {
const slot = 15 * 60 * 1000
return Math.floor(ms / slot) * slot
}
function SectionTitle({ kicker, title }: { kicker: string; title: string }) {
function nextPlanSlots(intervals: PlanningIntervalDto[], count: number): PlanningIntervalDto[] {
if (!intervals.length) return []
const sorted = [...intervals].sort(
(a, b) => new Date(a.interval_start).getTime() - new Date(b.interval_start).getTime(),
)
const boundary = floorToSlotUtc(Date.now())
const upcoming = sorted.filter((iv) => new Date(iv.interval_start).getTime() >= boundary - 1)
return upcoming.slice(0, count)
}
function meanBuyPrice(slots: PlanningIntervalDto[]): number | null {
const vals = slots
.map((s) => s.effective_buy_price)
.filter((x): x is number => x != null && Number.isFinite(x))
if (!vals.length) return null
return vals.reduce((a, b) => a + b, 0) / vals.length
}
function slotBgClass(slot: PlanningIntervalDto, avgBuy: number | null): string {
const b = slot.battery_setpoint_w ?? 0
if (b > BAT_PLAN_W) return 'bg-emerald-500'
if (b < -BAT_PLAN_W) return 'bg-orange-500'
const buy = slot.effective_buy_price
if (buy != null && avgBuy != null && avgBuy > 0) {
if (buy > avgBuy * 1.15) return 'bg-red-500'
if (buy < avgBuy * 0.85) return 'bg-amber-400'
}
return 'bg-slate-600'
}
function formatSlotLabel(iso: string): string {
return new Date(iso).toLocaleTimeString('cs-CZ', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Prague',
})
}
type ChartTipPayload = { name?: string; value?: number; dataKey?: string | number }
function ChartTooltip({
active,
payload,
label,
}: {
active?: boolean
payload?: ChartTipPayload[]
label?: string
}) {
if (!active || !payload?.length) return null
return (
<div className="mb-4">
<p className="text-xs font-semibold uppercase tracking-widest text-slate-500">{kicker}</p>
<h2 className="text-lg font-semibold text-slate-100">{title}</h2>
<div className="rounded-lg border border-slate-600 bg-slate-900/95 px-3 py-2 text-xs text-slate-100 shadow-xl">
<p className="mb-1 font-medium text-slate-200">{label}</p>
<ul className="space-y-0.5 tabular-nums">
{payload.map((p) => (
<li key={String(p.dataKey)} className="flex justify-between gap-6">
<span className="text-slate-400">{p.name}</span>
<span>{typeof p.value === 'number' ? `${p.value.toFixed(2)} kW` : '—'}</span>
</li>
))}
</ul>
</div>
)
}
function CardSkeleton() {
return <div className="h-[88px] animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
function SemicircleSocGauge({ socPercent }: { socPercent: string | number | null | undefined }) {
const raw = parseNum(socPercent)
const pct = raw == null ? null : Math.max(0, Math.min(100, raw))
const r = 88
const halfLen = Math.PI * r
const stroke =
pct == null ? 'text-slate-600' : pct < 20 ? 'text-red-500' : pct > 80 ? 'text-blue-500' : 'text-emerald-500'
function StatBlock({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</p>
<p className="mt-1 text-lg font-semibold tabular-nums text-slate-100">{value}</p>
<div className="flex flex-col items-center pt-2">
<div className="relative h-[120px] w-[220px]">
<svg viewBox="0 0 200 110" className="h-full w-full" aria-hidden>
<path
d="M 12 100 A 88 88 0 0 1 188 100"
fill="none"
stroke="currentColor"
strokeWidth="14"
className="text-slate-800"
/>
{pct != null && (
<path
d="M 12 100 A 88 88 0 0 1 188 100"
fill="none"
stroke="currentColor"
strokeWidth="14"
strokeLinecap="round"
strokeDasharray={halfLen}
strokeDashoffset={halfLen * (1 - pct / 100)}
className={stroke}
style={{ transition: 'stroke-dashoffset 0.5s ease' }}
/>
)}
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-end pb-1">
<span className="text-3xl font-bold tabular-nums text-slate-50">
{pct == null ? '—' : `${pct.toFixed(0)}`}
</span>
<span className="text-xs text-slate-500">% SoC</span>
</div>
</div>
</div>
)
}
function StatSkeleton() {
return <div className="h-[76px] animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
function MetricSkeleton() {
return <div className="h-[104px] animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
function BlockSkeleton({ className = '' }: { className?: string }) {
return <div className={`animate-pulse rounded-xl border border-slate-800 bg-slate-900/40 ${className}`} />
}
export function Dashboard() {
const { site, ready: siteReady, hasLiveData } = useSiteStatus()
const { site, ready: siteReady, error: siteError, hasLiveData, reload: reloadSite } = useSiteStatus()
const siteId = site?.site_id ?? null
const { points, ready: telemetryReady, hasChartData } = useTelemetryToday(siteId)
const { daily, ready: auditReady, hasDaily } = useAuditDailyToday(siteId)
const { fullStatus } = useFullStatus(siteId)
const [alertsOpen, setAlertsOpen] = useState(false)
const liveSkeleton = !siteReady || !hasLiveData
const chartSkeleton = !telemetryReady || !hasChartData
const econSkeleton = !auditReady || !hasDaily
const {
points,
ready: chartReady,
error: chartError,
hasChartData,
reload: reloadChart,
} = useTelemetryToday(siteId)
const {
daily,
ready: auditReady,
error: auditError,
hasDaily,
reload: reloadAudit,
} = useAuditDailyToday(siteId)
const { plan, ready: planReady, error: planError, reload: reloadPlan } = useCurrentPlan(siteId)
const hbOk = site?.ems_heartbeat_status === 'ok'
const bat = batteryStyles(site?.battery_power_w ?? null)
const grd = gridStyles(site?.grid_power_w ?? null)
const fetchError = siteError ?? chartError ?? auditError ?? planError
const retryAll = () => {
void reloadSite()
void reloadChart()
void reloadAudit()
void reloadPlan()
}
const metricsLoading = !siteReady
const chartLoading = !chartReady
const summaryLoading = !auditReady
const planLoading = !planReady
const hbOnline = site?.ems_heartbeat_status === 'ok'
const monitoringAlerts = fullStatus?.alerts ?? []
const hasMonitoringAlerts = monitoringAlerts.length > 0
const monitoringHasError = monitoringAlerts.some((a) => a.level === 'error')
const gridW = site?.grid_power_w ?? null
const gridLabel =
gridW == null || Number.isNaN(gridW)
? '—'
: gridW >= 0
? `+${(gridW / 1000).toFixed(2)} kW import`
: `${(gridW / 1000).toFixed(2)} kW export`
const batW = site?.battery_power_w ?? null
const batPct = parseNum(site?.battery_soc_percent)
const batSignedKw =
batW == null || Number.isNaN(batW) ? null : Math.abs(batW / 1000) * (batW >= 0 ? 1 : -1)
const planSlots = nextPlanSlots(plan.intervals, 16)
const avgBuy = meanBuyPrice(planSlots)
const chartData: TelemetryChartPoint[] = points
return (
<div className="min-h-screen bg-slate-950 p-4 md:p-8">
<div className="mx-auto max-w-7xl space-y-10">
<header className="flex flex-col gap-4 border-b border-slate-800/80 pb-6 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-white">EMS Platform</h1>
<p className="mt-1 text-sm text-slate-400">Přehled lokality a auditu</p>
<div className="min-h-screen bg-slate-950 p-4 text-slate-100 md:p-8">
<div className="mx-auto max-w-7xl space-y-8">
{fetchError ? (
<div
className="flex flex-col gap-3 rounded-xl border border-red-500/40 bg-red-950/40 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
role="alert"
>
<p className="text-sm font-medium text-red-200">Chyba načítání dat</p>
<button
type="button"
onClick={() => retryAll()}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-500"
>
Zkusit znovu
</button>
</div>
{!siteReady ? (
<div className="h-10 w-56 animate-pulse rounded-lg bg-slate-800/80" />
) : site ? (
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-slate-400">{site.site_name}</span>
<span
className={`rounded-md px-2.5 py-1 text-xs font-semibold uppercase tracking-wide ${modeBadgeClass(site.active_mode)}`}
title={site.mode_description ?? undefined}
>
{site.active_mode ?? '—'}
{site.mode_name ? ` · ${site.mode_name}` : ''}
</span>
<span className="flex items-center gap-2 text-xs text-slate-500">
<span className="relative flex h-2.5 w-2.5">
<span
className={`inline-flex h-2.5 w-2.5 rounded-full ${hbOk ? 'bg-emerald-500' : 'bg-red-500'}`}
title={site.ems_heartbeat_status ?? 'neznámý'}
/>
</span>
EMS
</span>
</div>
) : null}
) : null}
<header className="border-b border-slate-800/80 pb-6">
<h1 className="text-2xl font-bold tracking-tight text-white">EMS Platform</h1>
<p className="mt-1 text-sm text-slate-400">Přehled lokality, auditu a plánu</p>
</header>
{/* Horní metriky */}
<section>
<SectionTitle kicker="Živě" title="Aktuální stav" />
{liveSkeleton ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<CardSkeleton />
<CardSkeleton />
<div className="flex min-h-[88px] items-center justify-center rounded-xl border border-slate-800 bg-slate-900/40">
<div className="h-36 w-36 animate-pulse rounded-full bg-slate-800/80" />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{metricsLoading ? (
<>
<MetricSkeleton />
<MetricSkeleton />
<MetricSkeleton />
<MetricSkeleton />
</>
) : site == null ? (
<p className="col-span-full text-sm text-slate-500">Žádná lokalita ve vw_site_status.</p>
) : (
<>
<div className="rounded-xl border border-slate-800 border-l-4 border-l-amber-400 bg-slate-900/60 p-4 pl-3">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
<Sun className="h-6 w-6 text-amber-400" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">FVE výroba</p>
<p className="text-xl font-semibold tabular-nums text-amber-300">
{hasLiveData ? fmtKw2(site.pv_power_w) : '—'}{' '}
<span className="text-lg" aria-hidden>
</span>
</p>
</div>
</div>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900/60 p-4">
<div className="flex items-center gap-3">
<div
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80 ${
batW != null && !Number.isNaN(batW)
? batW >= 0
? 'text-emerald-400'
: 'text-orange-400'
: 'text-slate-400'
}`}
>
<Battery className="h-6 w-6" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Baterie</p>
<p className="text-xl font-semibold tabular-nums text-slate-100">
{batPct == null ? '—' : `${batPct.toFixed(0)}%`}
{batSignedKw == null ? '' : ` / ${batSignedKw >= 0 ? '+' : ''}${batSignedKw.toFixed(2)} kW`}
</p>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-slate-800">
<div
className={`h-full rounded-full transition-all ${
batW != null && !Number.isNaN(batW) && batW < 0 ? 'bg-orange-500' : 'bg-emerald-500'
}`}
style={{ width: `${batPct ?? 0}%` }}
/>
</div>
</div>
</div>
</div>
<div
className={`rounded-xl border border-slate-800 bg-slate-900/60 p-4 pl-3 border-l-4 ${
gridW != null && !Number.isNaN(gridW)
? gridW >= 0
? 'border-l-red-500'
: 'border-l-emerald-500'
: 'border-l-slate-600'
}`}
>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
<Zap
className={`h-6 w-6 ${
gridW != null && !Number.isNaN(gridW)
? gridW >= 0
? 'text-red-400'
: 'text-emerald-400'
: 'text-slate-400'
}`}
aria-hidden
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Síť</p>
<p className="text-xl font-semibold tabular-nums text-slate-100">{gridLabel}</p>
<p className="mt-0.5 text-xs text-slate-500">
{gridW != null && !Number.isNaN(gridW)
? gridW >= 0
? 'import'
: 'export'
: ''}
</p>
</div>
</div>
</div>
<div className="rounded-xl border border-slate-800 border-l-4 border-l-blue-500 bg-slate-900/60 p-4 pl-3">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
<Home className="h-6 w-6 text-blue-400" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Spotřeba</p>
<p className="text-xl font-semibold tabular-nums text-blue-300">
{hasLiveData ? fmtKw2(site.load_power_w) : '—'}
</p>
</div>
</div>
</div>
</>
)}
</div>
{/* Status řádek */}
{!metricsLoading && site != null ? (
<div className="mt-4 space-y-3">
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="text-slate-500">Aktivní režim:</span>
<span
className={`rounded-md px-2.5 py-1 text-xs font-semibold uppercase tracking-wide ${modeBadgeClass(site.active_mode)}`}
title={site.mode_description ?? undefined}
>
{site.active_mode ?? '—'}
{site.mode_name ? ` · ${site.mode_name}` : ''}
</span>
<span className="flex items-center gap-2 text-slate-400">
<span className="relative flex h-2.5 w-2.5">
<span
className={`inline-flex h-2.5 w-2.5 rounded-full ${hbOnline ? 'bg-emerald-500' : 'bg-red-500'}`}
/>
</span>
EMS:{' '}
<span className={hbOnline ? 'text-emerald-400' : 'text-red-400'}>
{hbOnline ? 'online' : 'offline'}
</span>
</span>
<span className="text-slate-500">
Poslední telemetrie:{' '}
<span className="text-slate-300">{formatTelemetryAgo(site.telemetry_at)}</span>
</span>
</div>
<CardSkeleton />
{hasMonitoringAlerts ? (
<div className="w-full max-w-2xl">
<button
type="button"
onClick={() => setAlertsOpen((o) => !o)}
className={`flex w-full items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left text-sm font-medium transition hover:opacity-95 ${
monitoringHasError
? 'border-red-500/45 bg-red-950/45 text-red-100'
: 'border-amber-500/40 bg-amber-950/35 text-amber-100'
}`}
aria-expanded={alertsOpen}
>
<span>
{monitoringAlerts.length}{' '}
{monitoringAlerts.length === 1 ? 'alert' : 'alertů'}
{monitoringHasError ? ' · obsahuje chyby' : ''}
</span>
{alertsOpen ? (
<ChevronUp className="h-4 w-4 shrink-0 opacity-80" aria-hidden />
) : (
<ChevronDown className="h-4 w-4 shrink-0 opacity-80" aria-hidden />
)}
</button>
{alertsOpen ? (
<ul
className={`mt-2 space-y-1.5 rounded-lg border px-3 py-2 text-sm ${
monitoringHasError
? 'border-red-500/30 bg-red-950/25 text-red-100'
: 'border-amber-500/25 bg-amber-950/20 text-amber-50'
}`}
role="list"
>
{monitoringAlerts.map((a, i) => (
<li
key={`${a.level}-${i}-${a.message}`}
className={
a.level === 'error'
? 'text-red-200'
: 'text-amber-200'
}
>
<span className="font-semibold uppercase tracking-wide text-[10px] opacity-80">
{a.level === 'error' ? 'Chyba' : 'Varování'}
</span>
<span className="ml-2">{a.message}</span>
</li>
))}
</ul>
) : null}
</div>
) : null}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<PowerFlowCard label="FVE" powerW={site?.pv_power_w} icon={Sun} borderClass="border-l-amber-400" iconClass="text-amber-400" />
<PowerFlowCard
label="Baterie"
powerW={site?.battery_power_w}
icon={Battery}
borderClass={bat.border}
iconClass={bat.icon}
/>
<SocGauge socPercent={site?.battery_soc_percent} loading={false} />
<PowerFlowCard label="Síť" powerW={site?.grid_power_w} icon={Zap} borderClass={grd.border} iconClass={grd.icon} />
</div>
)}
) : metricsLoading ? (
<div className="mt-4 h-5 w-full max-w-md animate-pulse rounded bg-slate-800/80" />
) : null}
</section>
<section>
<SectionTitle kicker="Dnes" title="Průběh výkonů (hodinový průměr)" />
<TelemetryChart points={points} loading={chartSkeleton} />
{/* Graf + denní souhrn */}
<section className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
{chartLoading ? (
<BlockSkeleton className="h-[300px] w-full" />
) : !hasChartData ? (
<div className="flex h-[300px] items-center justify-center rounded-xl border border-slate-800 bg-slate-900/40 text-sm text-slate-500">
Zatím žádná data pro dnešní den
</div>
) : (
<div className="h-[300px] w-full rounded-xl border border-slate-800 bg-slate-900/40 p-2 pr-4 pb-4 pt-4">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="timeLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} />
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} width={40} />
<Tooltip content={<ChartTooltip />} />
<Area
type="monotone"
dataKey="pv_kw"
name="FVE"
stroke="#fbbf24"
fill="#fbbf24"
fillOpacity={0.25}
connectNulls
/>
<Line
type="monotone"
dataKey="load_kw"
name="Spotřeba"
stroke="#60a5fa"
strokeWidth={2}
dot={false}
connectNulls
/>
<Bar dataKey="battery_kw" name="Baterie" barSize={14}>
{chartData.map((e, i) => (
<Cell
key={`c-${i}`}
fill={
e.battery_kw == null || Number.isNaN(e.battery_kw)
? '#475569'
: e.battery_kw >= 0
? '#22c55e'
: '#f97316'
}
/>
))}
</Bar>
<Line
type="monotone"
dataKey="grid_kw"
name="Síť"
stroke="#94a3b8"
strokeWidth={2}
strokeDasharray="6 4"
dot={false}
connectNulls
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
</div>
<div className="lg:col-span-1">
{summaryLoading ? (
<div className="space-y-3">
<BlockSkeleton className="h-10 w-full" />
<BlockSkeleton className="h-10 w-full" />
<BlockSkeleton className="h-10 w-full" />
<BlockSkeleton className="h-10 w-full" />
<BlockSkeleton className="h-40 w-full" />
</div>
) : (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-5">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500">Dnešní souhrn</h2>
<ul className="mt-4 space-y-3 text-sm">
<li className="flex justify-between gap-2">
<span className="text-slate-400">FVE výroba</span>
<span className="tabular-nums text-amber-200">{fmtEnergy(daily?.pv_kwh)}</span>
</li>
<li className="flex justify-between gap-2">
<span className="text-slate-400">Import ze sítě</span>
<span className="tabular-nums text-red-300">{fmtEnergy(daily?.import_kwh)}</span>
</li>
<li className="flex justify-between gap-2">
<span className="text-slate-400">Export do sítě</span>
<span className="tabular-nums text-emerald-300">{fmtEnergy(daily?.export_kwh)}</span>
</li>
<li className="flex justify-between gap-2">
<span className="text-slate-400">Náklady / příjem</span>
{(() => {
const c = parseNum(daily?.actual_cost_czk)
const cls =
c == null
? 'text-slate-200'
: c > 0
? 'text-red-400'
: c < 0
? 'text-emerald-400'
: 'text-slate-200'
return <span className={`tabular-nums font-medium ${cls}`}>{fmtMoney(daily?.actual_cost_czk)}</span>
})()}
</li>
</ul>
{!hasDaily ? (
<p className="mt-3 text-xs text-slate-500">Pro dnešek zatím nejsou uzavřené intervaly auditu.</p>
) : null}
<SemicircleSocGauge socPercent={site?.battery_soc_percent} />
</div>
)}
</div>
</section>
{/* Plán 4 h */}
<section>
<SectionTitle kicker="Dnes" title="Ekonomika auditu" />
{econSkeleton ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatSkeleton />
<StatSkeleton />
<StatSkeleton />
<StatSkeleton />
</div>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Nejbližší plán (4 hodiny)
</h2>
{planLoading ? (
<BlockSkeleton className="h-16 w-full" />
) : planSlots.length === 0 ? (
<p className="text-sm text-slate-500">Plán zatím není k dispozici</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatBlock label="Import" value={fmtEnergy(daily?.import_kwh)} />
<StatBlock label="Export" value={fmtEnergy(daily?.export_kwh)} />
<StatBlock label="FVE výroba" value={fmtEnergy(daily?.pv_kwh)} />
<StatBlock label="Náklady / příjem (audit)" value={fmtMoney(daily?.actual_cost_czk)} />
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<div className="flex gap-1">
{planSlots.map((slot, i) => (
<div key={`${slot.interval_start}-${i}`} className="min-w-0 flex-1 group relative">
<div
className={`h-10 w-full rounded-sm ${slotBgClass(slot, avgBuy)} opacity-90 transition group-hover:opacity-100`}
title=""
/>
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden w-max min-w-[140px] -translate-x-1/2 rounded-md border border-slate-600 bg-slate-900 px-2 py-1.5 text-[10px] text-slate-100 shadow-lg group-hover:block">
<p className="font-medium text-slate-200">{formatSlotLabel(slot.interval_start)}</p>
<p className="tabular-nums text-slate-400">
cena:{' '}
{slot.effective_buy_price == null
? '—'
: `${slot.effective_buy_price.toFixed(3)} Kč/kWh`}
</p>
<p className="tabular-nums text-slate-400">
baterie: {fmtKw2(slot.battery_setpoint_w ?? undefined)}
</p>
<p className="tabular-nums text-slate-400">síť: {fmtKw2(slot.grid_setpoint_w ?? undefined)}</p>
</div>
</div>
))}
</div>
<p className="mt-2 text-center text-[10px] text-slate-600">16× 15 min · najet myší pro detail</p>
</div>
)}
</section>

View 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>: {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)}`
: `${Math.abs(summary.total_expected_cost_czk).toFixed(2)}`}
</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"></th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">Náklady </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>
)
}

View File

@@ -1,5 +1,12 @@
import axios from 'axios'
import { Car } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { patchEvSession, type ActiveEvSessionRow } from '../api/backend'
import { ModeLog } from '../components/ModeLog'
import { ModeSelector } from '../components/ModeSelector'
import { useEVSessions } from '../hooks/useEVSessions'
import { useSiteStatus } from '../hooks/useSiteStatus'
function SectionTitle({ kicker, title }: { kicker: string; title: string }) {
@@ -11,9 +18,182 @@ function SectionTitle({ kicker, title }: { kicker: string; title: string }) {
)
}
function toDatetimeLocalValue(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const h = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${y}-${m}-${day}T${h}:${min}`
}
/** Dnešní HH:00 nebo zítřejší, pokud už je po té hodině (včetně celé hodiny). */
function nextDeadlineAtHour(hour: number): Date {
const now = new Date()
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, 0, 0, 0)
if (d.getTime() <= now.getTime()) {
d.setDate(d.getDate() + 1)
}
return d
}
function isoToDatetimeLocal(iso: string): string {
return toDatetimeLocalValue(new Date(iso))
}
function datetimeLocalToIsoUtc(local: string): string {
const d = new Date(local)
if (Number.isNaN(d.getTime())) {
return new Date().toISOString()
}
return d.toISOString()
}
function vehicleTitle(s: ActiveEvSessionRow): string {
const m = (s.make ?? '').trim()
const mo = (s.model ?? '').trim()
if (!m && !mo) return 'Neznámé vozidlo'
return `${m} ${mo}`.trim()
}
/** Popisek do toastu preferuje model (např. Model Y). */
function toastVehicleLabel(s: ActiveEvSessionRow): string {
const mo = (s.model ?? '').trim()
if (mo) return mo
return vehicleTitle(s)
}
const CHARGER_SLOTS: { code: string; label: string }[] = [
{ code: 'ev-charger-1', label: 'Tesla' },
{ code: 'ev-charger-2', label: 'Zoe' },
]
function EvChargerCard({
siteId,
chargerLabel,
session,
onSaved,
}: {
siteId: number
chargerLabel: string
session: ActiveEvSessionRow | undefined
onSaved: () => void
}) {
const [soc, setSoc] = useState<number | ''>('')
const [deadlineLocal, setDeadlineLocal] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!session) {
setSoc('')
setDeadlineLocal('')
return
}
const defSoc = session.target_soc_pct ?? session.default_target_soc_pct ?? 80
setSoc(Math.round(Number(defSoc)))
if (session.target_deadline) {
setDeadlineLocal(isoToDatetimeLocal(session.target_deadline))
} else {
const h = session.default_deadline_hour ?? 7
setDeadlineLocal(toDatetimeLocalValue(nextDeadlineAtHour(h)))
}
}, [
session?.id,
session?.target_soc_pct,
session?.target_deadline,
session?.default_deadline_hour,
session?.default_target_soc_pct,
])
if (!session) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-slate-800 bg-slate-900/25 p-8 text-center">
<Car className="h-12 w-12 text-slate-600" aria-hidden />
<p className="mt-4 text-sm font-medium text-slate-500">Nepřipojeno</p>
<p className="mt-1 text-xs text-slate-600">{chargerLabel}</p>
</div>
)
}
const kwh = ((session.energy_delivered_wh ?? 0) / 1000).toFixed(1)
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (soc === '' || !deadlineLocal) return
const clamped = Math.min(100, Math.max(10, Math.round(Number(soc))))
setSaving(true)
try {
await patchEvSession(siteId, session.id, {
target_soc_pct: clamped,
target_deadline: datetimeLocalToIsoUtc(deadlineLocal),
})
toast.success(`Deadline nastaven pro ${toastVehicleLabel(session)}`)
onSaved()
} catch (err) {
const msg =
axios.isAxiosError(err) && err.response?.data && typeof err.response.data === 'object'
? JSON.stringify(err.response.data)
: err instanceof Error
? err.message
: 'Neznámá chyba'
toast.error('Uložení se nezdařilo', { description: msg })
} finally {
setSaving(false)
}
}
return (
<div className="rounded-xl border border-slate-700 bg-slate-900/50 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-emerald-500/90">Připojeno</p>
<p className="mt-1 text-sm font-semibold text-slate-100">{vehicleTitle(session)}</p>
<p className="mt-0.5 text-xs text-slate-500">{chargerLabel}</p>
<form onSubmit={(e) => void onSubmit(e)} className="mt-4 space-y-3">
<p className="text-sm text-slate-400">
Energie v session:{' '}
<span className="font-medium text-slate-200">{kwh} kWh</span>
</p>
<div className="flex flex-wrap gap-3">
<label className="flex flex-col text-xs text-slate-500">
Target SoC %
<input
type="number"
min={10}
max={100}
step={1}
value={soc}
onChange={(e) => {
const v = e.target.value
setSoc(v === '' ? '' : Number(v))
}}
className="mt-1 w-28 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-200"
/>
</label>
<label className="flex min-w-[200px] flex-col text-xs text-slate-500">
Deadline
<input
type="datetime-local"
value={deadlineLocal}
onChange={(e) => setDeadlineLocal(e.target.value)}
className="mt-1 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-200"
/>
</label>
</div>
<button
type="submit"
disabled={saving || soc === '' || !deadlineLocal}
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Ukládám…' : 'Uložit'}
</button>
</form>
</div>
)
}
export function Settings() {
const { site, ready, reload } = useSiteStatus()
const siteId = site?.site_id ?? null
const { sessions, ready: evReady, error: evError, reload: reloadEv } = useEVSessions(siteId)
return (
<div className="min-h-screen bg-slate-950 p-4 md:p-8">
@@ -31,7 +211,8 @@ export function Settings() {
<section>
<SectionTitle kicker="Řízení" title="Provozní režim" />
<p className="mb-4 max-w-3xl text-sm text-slate-400">
Přepnutí zapíše stav do databáze a notifikuje Loxone. U dočasného režimu lze nastavit čas návratu; po vypršení systém obnoví předchozí režim.
Přepnutí zapíše stav do databáze a notifikuje Loxone. U dočasného režimu lze nastavit čas návratu; po
vypršení systém obnoví předchozí režim.
</p>
<ModeSelector
siteId={siteId}
@@ -45,52 +226,31 @@ export function Settings() {
</section>
<section>
<SectionTitle kicker="EV" title="Deadline nabíjení (připravuje se)" />
<p className="mb-4 text-sm text-slate-500">
Zatím pouze rozhraní; napojení na API a session přijde v další iteraci.
<SectionTitle kicker="EV" title="Deadline nabíjení" />
<p className="mb-4 max-w-3xl text-sm text-slate-400">
Při připojení vozidla na wallbox se zobrazí aktivní session (dotaz každých 30 s). Cílový SoC a deadline se
ukládají do <span className="text-slate-500">ev_session</span> pro plánovač.
</p>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-slate-800 bg-slate-900/40 p-4">
<p className="text-sm font-medium text-slate-200">Tesla</p>
<div className="mt-3 flex flex-wrap gap-3">
<label className="flex flex-col text-xs text-slate-500">
Cílové SoC %
<input
type="number"
min={0}
max={100}
placeholder="např. 80"
disabled
className="mt-1 w-28 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400"
{siteId === null ? (
<p className="text-sm text-slate-500">Načítám lokalitu</p>
) : !evReady ? (
<p className="text-sm text-slate-500">Načítám EV session</p>
) : (
<>
{evError ? <p className="mb-3 text-sm text-amber-600/90">{evError}</p> : null}
<div className="grid gap-4 md:grid-cols-2">
{CHARGER_SLOTS.map((slot) => (
<EvChargerCard
key={slot.code}
siteId={siteId}
chargerLabel={slot.label}
session={sessions.find((s) => s.charger_code === slot.code)}
onSaved={() => void reloadEv()}
/>
</label>
<label className="flex min-w-[200px] flex-col text-xs text-slate-500">
Deadline
<input type="datetime-local" disabled className="mt-1 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400" />
</label>
))}
</div>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900/40 p-4">
<p className="text-sm font-medium text-slate-200">Zoe</p>
<div className="mt-3 flex flex-wrap gap-3">
<label className="flex flex-col text-xs text-slate-500">
Cílové SoC %
<input
type="number"
min={0}
max={100}
placeholder="např. 80"
disabled
className="mt-1 w-28 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400"
/>
</label>
<label className="flex min-w-[200px] flex-col text-xs text-slate-500">
Deadline
<input type="datetime-local" disabled className="mt-1 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400" />
</label>
</div>
</div>
</div>
</>
)}
</section>
</div>
</div>