x
This commit is contained in:
@@ -17,7 +17,7 @@ server {
|
||||
text/plain;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/;
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
139
frontend/package-lock.json
generated
139
frontend/package-lock.json
generated
@@ -10,7 +10,8 @@
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.15.0"
|
||||
"recharts": "^2.15.0",
|
||||
"sonner": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
@@ -1311,6 +1312,70 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
|
||||
@@ -2705,6 +2770,16 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
||||
"integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -3562,6 +3637,62 @@
|
||||
"@napi-rs/wasm-runtime": "^1.1.1",
|
||||
"@tybys/wasm-util": "^0.10.1",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emnapi/core": {
|
||||
"version": "1.8.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"@emnapi/runtime": {
|
||||
"version": "1.8.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
}
|
||||
},
|
||||
"@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"tslib": {
|
||||
"version": "2.8.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
@@ -4469,6 +4600,12 @@
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true
|
||||
},
|
||||
"sonner": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
||||
"integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==",
|
||||
"requires": {}
|
||||
},
|
||||
"source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"dev": "node scripts/run-dev.mjs",
|
||||
"build": "node scripts/run-build.mjs",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -13,7 +13,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.15.0",
|
||||
"sonner": "^1.7.1"
|
||||
"sonner": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
|
||||
62
frontend/scripts/ensure-native-bindings.mjs
Normal file
62
frontend/scripts/ensure-native-bindings.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* When optional native deps (e.g. @tailwindcss/oxide-*) fail to install (permissions, npm bugs),
|
||||
* fetch the correct platform package via npm pack and copy the .node file into vendor/.
|
||||
*/
|
||||
import { execSync } from 'node:child_process'
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const root = join(__dirname, '..')
|
||||
const vendorDir = join(root, 'vendor')
|
||||
|
||||
function isMusl() {
|
||||
try {
|
||||
return execSync('ldd --version', { encoding: 'utf8' }).includes('musl')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function detectLinuxX64Oxide() {
|
||||
if (process.platform !== 'linux' || process.arch !== 'x64') return null
|
||||
return isMusl()
|
||||
? { pkg: '@tailwindcss/oxide-linux-x64-musl', version: '4.2.2', nodeName: 'tailwindcss-oxide.linux-x64-musl.node' }
|
||||
: { pkg: '@tailwindcss/oxide-linux-x64-gnu', version: '4.2.2', nodeName: 'tailwindcss-oxide.linux-x64-gnu.node' }
|
||||
}
|
||||
|
||||
function tryResolveOxidePackage(spec) {
|
||||
const sub = spec.pkg.replace('@tailwindcss/', '')
|
||||
const direct = join(root, 'node_modules', '@tailwindcss', sub, 'package.json')
|
||||
if (existsSync(direct)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function ensure() {
|
||||
const spec = detectLinuxX64Oxide()
|
||||
if (!spec) return
|
||||
|
||||
if (tryResolveOxidePackage(spec)) return
|
||||
|
||||
const outPath = join(vendorDir, spec.nodeName)
|
||||
if (existsSync(outPath)) return
|
||||
|
||||
mkdirSync(vendorDir, { recursive: true })
|
||||
const tmp = join(__dirname, '.native-tmp')
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
mkdirSync(tmp, { recursive: true })
|
||||
|
||||
execSync(`npm pack ${spec.pkg}@${spec.version}`, { cwd: tmp, stdio: 'inherit' })
|
||||
const tgz = readdirSync(tmp).find((f) => f.endsWith('.tgz'))
|
||||
if (!tgz) throw new Error('ensure-native-bindings: npm pack produced no .tgz')
|
||||
execSync(`tar -xzf "${tgz}"`, { cwd: tmp, stdio: 'inherit' })
|
||||
const nodeSrc = join(tmp, 'package', spec.nodeName)
|
||||
if (!existsSync(nodeSrc)) {
|
||||
throw new Error(`ensure-native-bindings: missing ${nodeSrc}`)
|
||||
}
|
||||
copyFileSync(nodeSrc, outPath)
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
ensure()
|
||||
14
frontend/scripts/run-build-inner.mjs
Normal file
14
frontend/scripts/run-build-inner.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
function run(scriptArgs) {
|
||||
const r = spawnSync(process.execPath, scriptArgs, { stdio: 'inherit', cwd: root })
|
||||
if (r.status !== 0) process.exit(r.status ?? 1)
|
||||
}
|
||||
|
||||
run([join(root, 'scripts', 'ensure-native-bindings.mjs')])
|
||||
run([join(root, 'node_modules/typescript/bin/tsc'), '-b'])
|
||||
run([join(root, 'node_modules/vite/bin/vite.js'), 'build'])
|
||||
21
frontend/scripts/run-build.mjs
Normal file
21
frontend/scripts/run-build.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const major = parseInt(process.versions.node.split('.')[0], 10)
|
||||
|
||||
if (major >= 20) {
|
||||
const r = spawnSync(process.execPath, [join(root, 'scripts', 'run-build-inner.mjs')], {
|
||||
stdio: 'inherit',
|
||||
cwd: root,
|
||||
})
|
||||
process.exit(r.status ?? 1)
|
||||
}
|
||||
|
||||
const r = spawnSync(
|
||||
'npx',
|
||||
['-y', '-p', 'node@20', 'node', join(root, 'scripts', 'run-build-inner.mjs')],
|
||||
{ stdio: 'inherit', cwd: root, shell: false },
|
||||
)
|
||||
process.exit(r.status ?? 1)
|
||||
17
frontend/scripts/run-dev-inner.mjs
Normal file
17
frontend/scripts/run-dev-inner.mjs
Normal file
@@ -0,0 +1,17 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
const ensure = spawnSync(process.execPath, [join(root, 'scripts', 'ensure-native-bindings.mjs')], {
|
||||
stdio: 'inherit',
|
||||
cwd: root,
|
||||
})
|
||||
if (ensure.status !== 0) process.exit(ensure.status ?? 1)
|
||||
|
||||
const vite = spawnSync(process.execPath, [join(root, 'node_modules/vite/bin/vite.js')], {
|
||||
stdio: 'inherit',
|
||||
cwd: root,
|
||||
})
|
||||
process.exit(vite.status ?? 1)
|
||||
13
frontend/scripts/run-dev.mjs
Normal file
13
frontend/scripts/run-dev.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const inner = join(root, 'scripts', 'run-dev-inner.mjs')
|
||||
const major = parseInt(process.versions.node.split('.')[0], 10)
|
||||
|
||||
const cmd = major >= 20 ? process.execPath : 'npx'
|
||||
const args = major >= 20 ? [inner] : ['-y', '-p', 'node@20', 'node', inner]
|
||||
|
||||
const r = spawnSync(cmd, args, { stdio: 'inherit', cwd: root, shell: false })
|
||||
process.exit(r.status ?? 1)
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Toaster } from 'sonner'
|
||||
import Planning from './Planning'
|
||||
import Planning from './pages/Planning'
|
||||
import { Dashboard } from './pages/Dashboard'
|
||||
import { Settings } from './pages/Settings'
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function App() {
|
||||
page === 'planning' ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-900 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
Plán
|
||||
Plánování
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,457 +0,0 @@
|
||||
import { Loader2, RefreshCw } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Area,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Legend,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
|
||||
import { getCurrentPlan, 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 {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('cs-CZ', {
|
||||
timeZone: TZ,
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
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()
|
||||
}
|
||||
|
||||
function negPrice(i: PlanningIntervalDto): boolean {
|
||||
const b = i.effective_buy_price
|
||||
const s = i.effective_sell_price
|
||||
return (b != null && b < 0) || (s != null && s < 0)
|
||||
}
|
||||
|
||||
function rowHighlight(i: PlanningIntervalDto): string {
|
||||
if (negPrice(i)) return 'bg-red-950/45'
|
||||
if ((i.pv_a_curtailed_w ?? 0) > 0) return 'bg-amber-950/35'
|
||||
return ''
|
||||
}
|
||||
|
||||
type ChartRow = {
|
||||
label: string
|
||||
ts: number
|
||||
pv_kw: number
|
||||
baseline_kw: number
|
||||
bat_charge_kw: number
|
||||
bat_discharge_kw: number
|
||||
price: number
|
||||
raw: PlanningIntervalDto
|
||||
}
|
||||
|
||||
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 [slotDetail, setSlotDetail] = useState<PlanningIntervalDto | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteId == null) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await getCurrentPlan(siteId)
|
||||
setData(res)
|
||||
} catch (e) {
|
||||
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, dayMs])
|
||||
|
||||
const chartRows: ChartRow[] = useMemo(() => {
|
||||
return intervals24h.map((i) => {
|
||||
const bat = i.battery_setpoint_w ?? 0
|
||||
const pv = i.pv_forecast_total_w ?? 0
|
||||
const base = i.load_baseline_w ?? 0
|
||||
const price = i.effective_buy_price ?? 0
|
||||
return {
|
||||
label: formatLocalTime(i.interval_start),
|
||||
ts: slotStartUtcMs(i.interval_start),
|
||||
pv_kw: pv / 1000,
|
||||
baseline_kw: base / 1000,
|
||||
bat_charge_kw: Math.max(0, bat) / 1000,
|
||||
bat_discharge_kw: Math.max(0, -bat) / 1000,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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 přehled dalších 24 hodin ({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">
|
||||
Aktuální plán
|
||||
</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 ? (
|
||||
<p className="text-slate-400">Žádný aktivní plán v databázi.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2 md:gap-x-8">
|
||||
<div>
|
||||
<dt className="text-slate-500">Vytvořen</dt>
|
||||
<dd className="font-mono text-slate-200">{formatLocal(run.created_at)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-slate-500">Typ</dt>
|
||||
<dd className="capitalize text-slate-200">{run.run_type}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-slate-500">Korekce FVE</dt>
|
||||
<dd className="font-mono text-slate-200">
|
||||
{run.forecast_correction_factor != null
|
||||
? run.forecast_correction_factor.toFixed(4)
|
||||
: '—'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-slate-500">Čas solveru</dt>
|
||||
<dd className="font-mono text-slate-200">
|
||||
{run.solver_duration_ms != null ? `${run.solver_duration_ms} ms` : '—'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onReplan()}
|
||||
disabled={replanning}
|
||||
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 nyní
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{summary && run && (
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 border-t border-slate-800 pt-4 text-xs text-slate-400 md:grid-cols-5">
|
||||
<div>
|
||||
<div className="text-slate-500">Očekávané náklady (celkem)</div>
|
||||
<div className="font-mono text-slate-200">
|
||||
{summary.total_expected_cost_czk.toFixed(2)} Kč
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500">Curtailment A</div>
|
||||
<div className="font-mono text-slate-200">
|
||||
{summary.total_pv_curtailed_kwh.toFixed(3)} kWh
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500">Sloty nabíjení</div>
|
||||
<div className="font-mono text-slate-200">{summary.charge_slots}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500">Sloty vybíjení</div>
|
||||
<div className="font-mono text-slate-200">{summary.discharge_slots}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500">Sloty exportu</div>
|
||||
<div className="font-mono text-slate-200">{summary.export_slots}</div>
|
||||
</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 (24 h)
|
||||
</h2>
|
||||
{!chartRows.length ? (
|
||||
<p className="text-sm text-slate-500">Žádná data pro graf v horizontu 24 h.</p>
|
||||
) : (
|
||||
<div className="h-[380px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={chartRows}
|
||||
margin={{ top: 8, right: 12, left: 0, bottom: 0 }}
|
||||
onClick={(state) => {
|
||||
const p = state?.activePayload?.[0]?.payload as ChartRow | undefined
|
||||
if (p?.raw) setSlotDetail(p.raw)
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="label" tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
||||
label={{ value: 'kW', angle: -90, position: 'insideLeft', fill: '#64748b' }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
||||
label={{ value: 'Kč/kWh', angle: 90, position: 'insideRight', fill: '#64748b' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: '#0f172a',
|
||||
border: '1px solid #334155',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'Cena nákup') return [`${value.toFixed(3)} Kč/kWh`, name]
|
||||
return [`${value.toFixed(2)} kW`, name]
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Area
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="pv_kw"
|
||||
name="FVE předpověď"
|
||||
stroke="#ca8a04"
|
||||
fill="#eab308"
|
||||
fillOpacity={0.35}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="baseline_kw"
|
||||
name="Spotřeba baseline"
|
||||
stroke="#3b82f6"
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="bat_charge_kw"
|
||||
name="Baterie nabíjení"
|
||||
stroke="#22c55e"
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="bat_discharge_kw"
|
||||
name="Baterie vybíjení"
|
||||
stroke="#f97316"
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="price"
|
||||
name="Cena nákup"
|
||||
stroke="#94a3b8"
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
{slotDetail && (
|
||||
<div className="mt-4 rounded-lg border border-slate-700 bg-slate-950/60 p-3 text-sm">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="font-medium text-slate-200">
|
||||
Slot {formatLocal(slotDetail.interval_start)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-slate-500 hover:text-slate-300"
|
||||
onClick={() => setSlotDetail(null)}
|
||||
>
|
||||
Zavřít
|
||||
</button>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 font-mono text-xs text-slate-300 md:grid-cols-3">
|
||||
<dt className="text-slate-500">Nákup / prodej</dt>
|
||||
<dd className="col-span-1">
|
||||
{slotDetail.effective_buy_price?.toFixed(4) ?? '—'} /{' '}
|
||||
{slotDetail.effective_sell_price?.toFixed(4) ?? '—'}
|
||||
</dd>
|
||||
<dt className="text-slate-500">FVE (A+B)</dt>
|
||||
<dd>{slotDetail.pv_forecast_total_w ?? '—'} W</dd>
|
||||
<dt className="text-slate-500">Baseline</dt>
|
||||
<dd>{slotDetail.load_baseline_w ?? '—'} W</dd>
|
||||
<dt className="text-slate-500">Baterie</dt>
|
||||
<dd>{slotDetail.battery_setpoint_w ?? '—'} W</dd>
|
||||
<dt className="text-slate-500">SoC cíl</dt>
|
||||
<dd>
|
||||
{slotDetail.battery_soc_target_pct != null
|
||||
? `${slotDetail.battery_soc_target_pct}%`
|
||||
: '—'}
|
||||
</dd>
|
||||
<dt className="text-slate-500">Síť</dt>
|
||||
<dd>{slotDetail.grid_setpoint_w ?? '—'} W</dd>
|
||||
<dt className="text-slate-500">EV1 / EV2</dt>
|
||||
<dd>
|
||||
{slotDetail.ev1_setpoint_w ?? '—'} / {slotDetail.ev2_setpoint_w ?? '—'} W
|
||||
</dd>
|
||||
<dt className="text-slate-500">TČ</dt>
|
||||
<dd>{slotDetail.heat_pump_enabled ? 'Zapnuto' : 'Vypnuto'}</dd>
|
||||
<dt className="text-slate-500">Curtailment A</dt>
|
||||
<dd>{slotDetail.pv_a_curtailed_w ?? 0} W</dd>
|
||||
<dt className="text-slate-500">Náklady slotu</dt>
|
||||
<dd>{slotDetail.expected_cost_czk?.toFixed(4) ?? '—'} Kč</dd>
|
||||
</dl>
|
||||
</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 (96 slotů / 24 h)
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-700 text-slate-500">
|
||||
<th className="py-2 pr-2 font-medium">Čas</th>
|
||||
<th className="py-2 pr-2 font-medium">Nákup</th>
|
||||
<th className="py-2 pr-2 font-medium">Prodej</th>
|
||||
<th className="py-2 pr-2 font-medium">FVE</th>
|
||||
<th className="py-2 pr-2 font-medium">Bat</th>
|
||||
<th className="py-2 pr-2 font-medium">Síť</th>
|
||||
<th className="py-2 pr-2 font-medium">EV1</th>
|
||||
<th className="py-2 pr-2 font-medium">EV2</th>
|
||||
<th className="py-2 pr-2 font-medium">TČ</th>
|
||||
<th className="py-2 font-medium">Náklady</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{intervals24h.map((i) => (
|
||||
<tr key={i.interval_start} className={`border-b border-slate-800/80 ${rowHighlight(i)}`}>
|
||||
<td className="whitespace-nowrap py-1.5 pr-2 font-mono text-slate-300">
|
||||
{formatLocalTime(i.interval_start)}
|
||||
</td>
|
||||
<td className="pr-2 font-mono text-slate-300">
|
||||
{i.effective_buy_price?.toFixed(2) ?? '—'}
|
||||
</td>
|
||||
<td className="pr-2 font-mono text-slate-300">
|
||||
{i.effective_sell_price?.toFixed(2) ?? '—'}
|
||||
</td>
|
||||
<td className="pr-2 font-mono text-slate-300">
|
||||
{i.pv_forecast_total_w != null ? Math.round(i.pv_forecast_total_w) : '—'}
|
||||
</td>
|
||||
<td className="pr-2 font-mono text-slate-300">
|
||||
{i.battery_setpoint_w ?? '—'}
|
||||
</td>
|
||||
<td className="pr-2 font-mono text-slate-300">{i.grid_setpoint_w ?? '—'}</td>
|
||||
<td className="pr-2 font-mono text-slate-300">{i.ev1_setpoint_w ?? '—'}</td>
|
||||
<td className="pr-2 font-mono text-slate-300">{i.ev2_setpoint_w ?? '—'}</td>
|
||||
<td className="pr-2 text-slate-300">{i.heat_pump_enabled ? 'Ano' : 'Ne'}</td>
|
||||
<td className="font-mono text-slate-300">
|
||||
{i.expected_cost_czk?.toFixed(2) ?? '—'}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
|
||||
import type { FullStatusResponse } from '../types/fullStatus'
|
||||
import type { CurrentPlanResponse, RunPlanResponse } from '../types/plan'
|
||||
|
||||
const client: AxiosInstance = axios.create({
|
||||
@@ -14,6 +15,25 @@ export async function getBackendHealth(): Promise<unknown> {
|
||||
return data
|
||||
}
|
||||
|
||||
export type HealthDetailedResponse = {
|
||||
db: 'ok' | 'error'
|
||||
scheduler: 'running' | 'stopped'
|
||||
telemetry_loop: 'running' | 'stopped'
|
||||
last_telemetry_age_sec: number
|
||||
last_plan_age_sec: number
|
||||
active_jobs: { id: string; next_run_time: string | null }[]
|
||||
}
|
||||
|
||||
export async function getBackendHealthDetailed(): Promise<HealthDetailedResponse> {
|
||||
const { data } = await client.get<HealthDetailedResponse>('/health/detailed')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getSiteStatusFull(siteId: number): Promise<FullStatusResponse> {
|
||||
const { data } = await client.get<FullStatusResponse>(`/sites/${siteId}/status/full`)
|
||||
return data
|
||||
}
|
||||
|
||||
export type SetSiteModePayload = {
|
||||
mode: string
|
||||
notes: string | null
|
||||
@@ -53,4 +73,86 @@ export async function postRunPlan(
|
||||
return data
|
||||
}
|
||||
|
||||
export type PricesImportResponse = {
|
||||
slots_imported: number
|
||||
date: string
|
||||
first_price_czk_kwh: number
|
||||
}
|
||||
|
||||
export async function postImportSitePrices(
|
||||
siteId: number,
|
||||
date?: string,
|
||||
): Promise<PricesImportResponse> {
|
||||
const { data } = await client.post<PricesImportResponse>(
|
||||
`/sites/${siteId}/prices/import`,
|
||||
null,
|
||||
{
|
||||
params: date ? { date } : undefined,
|
||||
timeout: 60_000,
|
||||
},
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export type ForecastRunResponse = {
|
||||
intervals_saved: number
|
||||
pv_arrays: number
|
||||
}
|
||||
|
||||
export async function postRunForecast(siteId: number): Promise<ForecastRunResponse> {
|
||||
const { data } = await client.post<ForecastRunResponse>(
|
||||
`/sites/${siteId}/forecast/run`,
|
||||
null,
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/** Aktivní EV session (GET .../ev/sessions/active) – join vozidlo + nabíječka */
|
||||
export type ActiveEvSessionRow = {
|
||||
id: number
|
||||
charger_id: number
|
||||
vehicle_id: number | null
|
||||
session_start: string
|
||||
energy_delivered_wh: number
|
||||
target_soc_pct: number | null
|
||||
target_deadline: string | null
|
||||
make: string | null
|
||||
model: string | null
|
||||
battery_capacity_kwh: number | null
|
||||
default_target_soc_pct: number | null
|
||||
default_deadline_hour: number | null
|
||||
charger_code: string
|
||||
charger_name: string | null
|
||||
}
|
||||
|
||||
export async function getActiveEvSessions(siteId: number): Promise<ActiveEvSessionRow[]> {
|
||||
const { data } = await client.get<ActiveEvSessionRow[]>(
|
||||
`/sites/${siteId}/ev/sessions/active`,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export type PatchEvSessionPayload = {
|
||||
target_soc_pct: number | null
|
||||
target_deadline: string | null
|
||||
}
|
||||
|
||||
export type PatchEvSessionResponse = {
|
||||
success: boolean
|
||||
session_id: number
|
||||
}
|
||||
|
||||
export async function patchEvSession(
|
||||
siteId: number,
|
||||
sessionId: number,
|
||||
payload: PatchEvSessionPayload,
|
||||
): Promise<PatchEvSessionResponse> {
|
||||
const { data } = await client.patch<PatchEvSessionResponse>(
|
||||
`/sites/${siteId}/ev/sessions/${sessionId}`,
|
||||
payload,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export { client as backendClient }
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Thermometer,
|
||||
Wrench,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
@@ -22,7 +23,7 @@ type ModeDef = {
|
||||
description: string
|
||||
ev: boolean
|
||||
hp: boolean
|
||||
Icon: typeof Bot
|
||||
Icon: LucideIcon
|
||||
}
|
||||
|
||||
const MODES: ModeDef[] = [
|
||||
|
||||
@@ -8,24 +8,40 @@ const POLL_MS = 30_000
|
||||
export function useAuditDailyToday(siteId: number | null) {
|
||||
const [row, setRow] = useState<AuditDailyRow | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteId == null) {
|
||||
setRow(null)
|
||||
setError(null)
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const rows = await getJson<AuditDailyRow[]>('/vw_audit_daily', {
|
||||
site_id: `eq.${siteId}`,
|
||||
order: 'day_local.desc',
|
||||
limit: '45',
|
||||
})
|
||||
const today = pragueCalendarDay()
|
||||
const hit = Array.isArray(rows) ? rows.find((r) => instantPragueDay(r.day_local) === today) : undefined
|
||||
setRow(hit ?? null)
|
||||
let primary = await getJson<AuditDailyRow[]>('/vw_audit_daily', {
|
||||
site_id: `eq.${siteId}`,
|
||||
day_local: `eq.${today}`,
|
||||
})
|
||||
let chosen: AuditDailyRow | null = null
|
||||
if (Array.isArray(primary) && primary.length > 0) {
|
||||
chosen = primary.find((r) => instantPragueDay(r.day_local) === today) ?? null
|
||||
}
|
||||
if (chosen == null || instantPragueDay(chosen.day_local) !== today) {
|
||||
const recent = await getJson<AuditDailyRow[]>('/vw_audit_daily', {
|
||||
site_id: `eq.${siteId}`,
|
||||
order: 'day_local.desc',
|
||||
limit: '45',
|
||||
})
|
||||
chosen = Array.isArray(recent)
|
||||
? recent.find((r) => instantPragueDay(r.day_local) === today) ?? null
|
||||
: null
|
||||
}
|
||||
setRow(chosen)
|
||||
setError(null)
|
||||
} catch {
|
||||
setRow(null)
|
||||
setError('Denní souhrn auditu se nepodařil načíst')
|
||||
} finally {
|
||||
setReady(true)
|
||||
}
|
||||
@@ -40,6 +56,8 @@ export function useAuditDailyToday(siteId: number | null) {
|
||||
return {
|
||||
daily: row,
|
||||
ready,
|
||||
error,
|
||||
hasDaily: row != null && (row.interval_count ?? 0) > 0,
|
||||
reload: load,
|
||||
}
|
||||
}
|
||||
|
||||
47
frontend/src/hooks/useCurrentPlan.ts
Normal file
47
frontend/src/hooks/useCurrentPlan.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
import { getCurrentPlan } from '../api/backend'
|
||||
import type { CurrentPlanResponse } from '../types/plan'
|
||||
|
||||
const POLL_MS = 30_000
|
||||
|
||||
const EMPTY: CurrentPlanResponse = { run: null, intervals: [], summary: null }
|
||||
|
||||
export function useCurrentPlan(siteId: number | null) {
|
||||
const [data, setData] = useState<CurrentPlanResponse>(EMPTY)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteId == null) {
|
||||
setData(EMPTY)
|
||||
setError(null)
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getCurrentPlan(siteId)
|
||||
setData(res)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 404) {
|
||||
setData(EMPTY)
|
||||
setError(null)
|
||||
} else {
|
||||
setData(EMPTY)
|
||||
setError(e instanceof Error ? e.message : 'Nepodařilo se načíst plán')
|
||||
}
|
||||
} finally {
|
||||
setReady(true)
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
const id = window.setInterval(() => void load(), POLL_MS)
|
||||
return () => window.clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
return { plan: data, ready, error, reload: load }
|
||||
}
|
||||
38
frontend/src/hooks/useEVSessions.ts
Normal file
38
frontend/src/hooks/useEVSessions.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { getActiveEvSessions, type ActiveEvSessionRow } from '../api/backend'
|
||||
|
||||
const POLL_MS = 30_000
|
||||
|
||||
export function useEVSessions(siteId: number | null) {
|
||||
const [sessions, setSessions] = useState<ActiveEvSessionRow[]>([])
|
||||
const [ready, setReady] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteId == null) {
|
||||
setSessions([])
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const rows = await getActiveEvSessions(siteId)
|
||||
setSessions(rows)
|
||||
setError(null)
|
||||
} catch {
|
||||
setSessions([])
|
||||
setError('EV session se nepodařilo načíst')
|
||||
} finally {
|
||||
setReady(true)
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
if (siteId == null) return
|
||||
const id = window.setInterval(() => void load(), POLL_MS)
|
||||
return () => window.clearInterval(id)
|
||||
}, [load, siteId])
|
||||
|
||||
return { sessions, ready, error, reload: load }
|
||||
}
|
||||
39
frontend/src/hooks/useFullStatus.ts
Normal file
39
frontend/src/hooks/useFullStatus.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { getSiteStatusFull } from '../api/backend'
|
||||
import type { FullStatusResponse } from '../types/fullStatus'
|
||||
|
||||
const POLL_MS = 30_000
|
||||
|
||||
export function useFullStatus(siteId: number | null) {
|
||||
const [data, setData] = useState<FullStatusResponse | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteId == null) {
|
||||
setData(null)
|
||||
setError(null)
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getSiteStatusFull(siteId)
|
||||
setData(res)
|
||||
setError(null)
|
||||
} catch {
|
||||
setData(null)
|
||||
setError('Monitoring stav se nepodařilo načíst')
|
||||
} finally {
|
||||
setReady(true)
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
const id = window.setInterval(() => void load(), POLL_MS)
|
||||
return () => window.clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
return { fullStatus: data, ready, error, reload: load }
|
||||
}
|
||||
@@ -7,13 +7,16 @@ const POLL_MS = 5_000
|
||||
export function useSiteStatus() {
|
||||
const [row, setRow] = useState<SiteStatusRow | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const rows = await getJson<SiteStatusRow[]>('/vw_site_status')
|
||||
setRow(Array.isArray(rows) && rows.length > 0 ? rows[0]! : null)
|
||||
setError(null)
|
||||
} catch {
|
||||
setRow(null)
|
||||
setError('Stav lokality se nepodařilo načíst')
|
||||
} finally {
|
||||
setReady(true)
|
||||
}
|
||||
@@ -35,6 +38,7 @@ export function useSiteStatus() {
|
||||
return {
|
||||
site: row,
|
||||
ready,
|
||||
error,
|
||||
/** Máme řádek lokality a alespoň jednu telemetrickou hodnotu (jinak skeleton). */
|
||||
hasLiveData: row != null && hasTelemetry,
|
||||
reload: load,
|
||||
|
||||
@@ -23,10 +23,12 @@ export type TelemetryChartPoint = {
|
||||
export function useTelemetryToday(siteId: number | null) {
|
||||
const [points, setPoints] = useState<TelemetryChartPoint[]>([])
|
||||
const [ready, setReady] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteId == null) {
|
||||
setPoints([])
|
||||
setError(null)
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
@@ -37,6 +39,7 @@ export function useTelemetryToday(siteId: number | null) {
|
||||
})
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
setPoints([])
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
const mapped: TelemetryChartPoint[] = rows.map((r) => {
|
||||
@@ -55,8 +58,10 @@ export function useTelemetryToday(siteId: number | null) {
|
||||
}
|
||||
})
|
||||
setPoints(mapped)
|
||||
setError(null)
|
||||
} catch {
|
||||
setPoints([])
|
||||
setError('Hodinová data auditu se nepodařila načíst')
|
||||
} finally {
|
||||
setReady(true)
|
||||
}
|
||||
@@ -68,5 +73,5 @@ export function useTelemetryToday(siteId: number | null) {
|
||||
return () => window.clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
return { points, ready, hasChartData: points.length > 0 }
|
||||
return { points, ready, error, hasChartData: points.length > 0, reload: load }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
687
frontend/src/pages/Planning.tsx
Normal file
687
frontend/src/pages/Planning.tsx
Normal file
@@ -0,0 +1,687 @@
|
||||
import axios from 'axios'
|
||||
import {
|
||||
ArrowDownRight,
|
||||
ArrowUpRight,
|
||||
CloudSun,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Area,
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
|
||||
import { getCurrentPlan, postImportSitePrices, postRunForecast, postRunPlan } from '../api/backend'
|
||||
import { useSiteStatus } from '../hooks/useSiteStatus'
|
||||
import type { CurrentPlanResponse, PlanningIntervalDto } from '../types/plan'
|
||||
|
||||
const TZ = 'Europe/Prague'
|
||||
|
||||
function formatLocal(iso: string): string {
|
||||
return new Date(iso).toLocaleString('cs-CZ', {
|
||||
timeZone: TZ,
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function formatLocalTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString('cs-CZ', {
|
||||
timeZone: TZ,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function slotStartUtcMs(iso: string): number {
|
||||
return new Date(iso).getTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* Vizuál FVE: API posílá součet A+B (`pv_forecast_total_w`).
|
||||
* Pokud je hodnota null (data chybí), použijeme jednoduchou proxy z ceny nákupu (W).
|
||||
* Čistá nula = platná předpověď „bez výroby“ (např. noc).
|
||||
*/
|
||||
function pvAProxyW(i: PlanningIntervalDto): number {
|
||||
const pv = i.pv_forecast_total_w
|
||||
if (pv != null && pv > 0) return pv
|
||||
if (pv === 0) return 0
|
||||
const buy = i.effective_buy_price
|
||||
if (buy == null) return 0
|
||||
const w = 6000 - buy * 3500
|
||||
return Math.max(0, Math.min(15000, w))
|
||||
}
|
||||
|
||||
function runTypeBadgeClass(t: string): string {
|
||||
const u = t.toLowerCase()
|
||||
if (u === 'daily') return 'bg-sky-500/15 text-sky-300 ring-1 ring-sky-500/35'
|
||||
if (u === 'rolling') return 'bg-violet-500/15 text-violet-300 ring-1 ring-violet-500/35'
|
||||
if (u === 'manual') return 'bg-amber-500/15 text-amber-200 ring-1 ring-amber-500/35'
|
||||
return 'bg-slate-600/40 text-slate-300 ring-1 ring-slate-500/30'
|
||||
}
|
||||
|
||||
function axiosDetail(e: unknown): string {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const d = e.response?.data as { detail?: unknown } | undefined
|
||||
const detail = d?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
return detail
|
||||
.map((x: { msg?: string }) => (typeof x?.msg === 'string' ? x.msg : ''))
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
}
|
||||
}
|
||||
return e instanceof Error ? e.message : 'Neznámá chyba'
|
||||
}
|
||||
|
||||
function tableRowClass(
|
||||
i: PlanningIntervalDto,
|
||||
selected: boolean,
|
||||
): string {
|
||||
const parts: string[] = []
|
||||
if (selected) parts.push('ring-1 ring-inset ring-cyan-500/50 bg-cyan-950/25')
|
||||
const buy = i.effective_buy_price
|
||||
const sell = i.effective_sell_price
|
||||
if (buy != null && buy < 0) parts.push('bg-green-950/80')
|
||||
else if (sell != null && sell < 0) parts.push('bg-red-950/80')
|
||||
if ((i.pv_a_curtailed_w ?? 0) > 0) parts.push('border-l-4 border-l-yellow-500')
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
type ChartRow = {
|
||||
label: string
|
||||
ts: number
|
||||
pv_a_w: number
|
||||
battery_soc_target_pct: number | null
|
||||
battery_setpoint_w: number
|
||||
effective_buy_price: number | null
|
||||
raw: PlanningIntervalDto
|
||||
}
|
||||
|
||||
type PlanPrepActionsProps = {
|
||||
prepAction: null | 'import' | 'forecast' | 'init'
|
||||
replanning: boolean
|
||||
onImport: () => void
|
||||
onForecast: () => void
|
||||
onInit: () => void
|
||||
wrapClassName?: string
|
||||
}
|
||||
|
||||
function PlanPrepActions({
|
||||
prepAction,
|
||||
replanning,
|
||||
onImport,
|
||||
onForecast,
|
||||
onInit,
|
||||
wrapClassName = 'flex flex-wrap gap-2',
|
||||
}: PlanPrepActionsProps) {
|
||||
const prepBusy = prepAction !== null
|
||||
const dis = prepBusy || replanning
|
||||
return (
|
||||
<div className={wrapClassName}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImport}
|
||||
disabled={dis}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-600 bg-slate-800/90 px-3 py-2 text-sm font-medium text-slate-100 transition hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{prepAction === 'import' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
Importovat ceny
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onForecast}
|
||||
disabled={dis}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-600 bg-slate-800/90 px-3 py-2 text-sm font-medium text-slate-100 transition hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{prepAction === 'forecast' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CloudSun className="h-4 w-4" />
|
||||
)}
|
||||
Spustit forecast
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInit}
|
||||
disabled={dis}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-emerald-700/60 bg-emerald-900/40 px-3 py-2 text-sm font-medium text-emerald-100 transition hover:bg-emerald-800/50 disabled:opacity-50"
|
||||
>
|
||||
{prepAction === 'init' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
Inicializovat plán
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlanTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: ChartRow }> }) {
|
||||
if (!active || !payload?.length) return null
|
||||
const p = payload[0].payload
|
||||
const i = p.raw
|
||||
const buy = i.effective_buy_price
|
||||
const sell = i.effective_sell_price
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-600 bg-slate-950 px-3 py-2 text-xs text-slate-200 shadow-xl">
|
||||
<div className="mb-1 font-medium text-slate-100">{formatLocal(i.interval_start)}</div>
|
||||
<div className="space-y-0.5 font-mono tabular-nums">
|
||||
<div>
|
||||
Cena nákup: {buy != null ? `${buy.toFixed(3)} Kč/kWh` : '—'} · prodej:{' '}
|
||||
{sell != null ? `${sell.toFixed(3)} Kč/kWh` : '—'}
|
||||
</div>
|
||||
<div>Baterie: {i.battery_setpoint_w ?? '—'} W</div>
|
||||
<div>Síť: {i.grid_setpoint_w ?? '—'} W</div>
|
||||
<div>TČ: {i.heat_pump_enabled ? 'zapnuto' : 'vypnuto'}</div>
|
||||
<div>
|
||||
EV1: {i.ev1_setpoint_w ?? '—'} W · EV2: {i.ev2_setpoint_w ?? '—'} W
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Planning() {
|
||||
const { site, ready: siteReady } = useSiteStatus()
|
||||
const siteId = site?.site_id ?? null
|
||||
|
||||
const [data, setData] = useState<CurrentPlanResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [replanning, setReplanning] = useState(false)
|
||||
const [prepAction, setPrepAction] = useState<null | 'import' | 'forecast' | 'init'>(null)
|
||||
const [selectedStart, setSelectedStart] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteId == null) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await getCurrentPlan(siteId)
|
||||
setData(res)
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 404) {
|
||||
setData({ run: null, intervals: [], summary: null })
|
||||
setError(null)
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : 'Chyba načtení plánu')
|
||||
setData(null)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
useEffect(() => {
|
||||
if (siteId != null) void load()
|
||||
}, [siteId, load])
|
||||
|
||||
const nowMs = Date.now()
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
|
||||
const intervals24h = useMemo(() => {
|
||||
if (!data?.intervals?.length) return []
|
||||
const end = nowMs + dayMs
|
||||
return data.intervals
|
||||
.filter((i) => {
|
||||
const t = slotStartUtcMs(i.interval_start)
|
||||
return t >= nowMs && t < end
|
||||
})
|
||||
.slice(0, 96)
|
||||
}, [data?.intervals, nowMs])
|
||||
|
||||
const xTicks = useMemo(() => {
|
||||
if (!intervals24h.length) return undefined
|
||||
const stepMs = 2 * 60 * 60 * 1000
|
||||
const first = slotStartUtcMs(intervals24h[0].interval_start)
|
||||
const last = slotStartUtcMs(intervals24h[intervals24h.length - 1].interval_start)
|
||||
const ticks: string[] = []
|
||||
let t = Math.ceil(first / stepMs) * stepMs
|
||||
while (t <= last) {
|
||||
const hit = intervals24h.find((i) => Math.abs(slotStartUtcMs(i.interval_start) - t) < 30 * 60 * 1000)
|
||||
if (hit) ticks.push(hit.interval_start)
|
||||
t += stepMs
|
||||
}
|
||||
return ticks.length ? ticks.map((iso) => formatLocalTime(iso)) : undefined
|
||||
}, [intervals24h])
|
||||
|
||||
const chartRows: ChartRow[] = useMemo(() => {
|
||||
return intervals24h.map((i) => ({
|
||||
label: formatLocalTime(i.interval_start),
|
||||
ts: slotStartUtcMs(i.interval_start),
|
||||
pv_a_w: pvAProxyW(i),
|
||||
battery_soc_target_pct: i.battery_soc_target_pct,
|
||||
battery_setpoint_w: i.battery_setpoint_w ?? 0,
|
||||
effective_buy_price: i.effective_buy_price,
|
||||
raw: i,
|
||||
}))
|
||||
}, [intervals24h])
|
||||
|
||||
async function onReplan() {
|
||||
if (siteId == null) return
|
||||
setReplanning(true)
|
||||
setError(null)
|
||||
try {
|
||||
await postRunPlan(siteId, 'rolling')
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Přepočet selhal')
|
||||
} finally {
|
||||
setReplanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function runRollingReload() {
|
||||
if (siteId == null) return
|
||||
await postRunPlan(siteId, 'rolling')
|
||||
await load()
|
||||
}
|
||||
|
||||
async function handleImportPrices() {
|
||||
if (siteId == null) return
|
||||
setPrepAction('import')
|
||||
setError(null)
|
||||
try {
|
||||
const r = await postImportSitePrices(siteId)
|
||||
toast.success(
|
||||
`Ceny: ${r.slots_imported} slotů (${r.date}), první ${r.first_price_czk_kwh.toFixed(3)} Kč/kWh`,
|
||||
)
|
||||
await runRollingReload()
|
||||
} catch (e) {
|
||||
toast.error('Import cen selhal', { description: axiosDetail(e) })
|
||||
} finally {
|
||||
setPrepAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRunForecast() {
|
||||
if (siteId == null) return
|
||||
setPrepAction('forecast')
|
||||
setError(null)
|
||||
try {
|
||||
const r = await postRunForecast(siteId)
|
||||
toast.success(`Forecast: ${r.intervals_saved} intervalů, ${r.pv_arrays} FVE polí`)
|
||||
await runRollingReload()
|
||||
} catch (e) {
|
||||
toast.error('Forecast selhal', { description: axiosDetail(e) })
|
||||
} finally {
|
||||
setPrepAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInitializePlan() {
|
||||
if (siteId == null) return
|
||||
setPrepAction('init')
|
||||
setError(null)
|
||||
try {
|
||||
const imp = await postImportSitePrices(siteId)
|
||||
toast.success(
|
||||
`Ceny: ${imp.slots_imported} slotů (${imp.date}), první ${imp.first_price_czk_kwh.toFixed(3)} Kč/kWh`,
|
||||
)
|
||||
const fc = await postRunForecast(siteId)
|
||||
toast.success(`Forecast: ${fc.intervals_saved} intervalů, ${fc.pv_arrays} FVE polí`)
|
||||
await runRollingReload()
|
||||
toast.success('Plán přepočítán (rolling).')
|
||||
} catch (e) {
|
||||
toast.error('Inicializace selhala', { description: axiosDetail(e) })
|
||||
} finally {
|
||||
setPrepAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!siteReady) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center text-slate-400">
|
||||
Načítám lokalitu…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (siteId == null) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-900/50 bg-amber-950/20 p-4 text-amber-200">
|
||||
V PostgREST nebyla nalezena lokalita (vw_site_status). Nelze načíst plán.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const run = data?.run
|
||||
const summary = data?.summary
|
||||
|
||||
const planStale =
|
||||
run != null && Date.now() - new Date(run.created_at).getTime() > 2 * 60 * 60 * 1000
|
||||
const showPrepActions = !loading && (run == null || planStale)
|
||||
const prepBusy = prepAction !== null
|
||||
|
||||
const correctionPct =
|
||||
run?.forecast_correction_factor != null ? run.forecast_correction_factor * 100 : null
|
||||
const correctionUp = (run?.forecast_correction_factor ?? 1) >= 1
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-8 p-4 md:p-6">
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-white">Plánování</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
Aktuální LP plán a dalších 24 h od teď ({site?.site_name ?? 'lokalita'})
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-900/60 bg-red-950/30 px-3 py-2 text-sm text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sekce 1 */}
|
||||
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
||||
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">
|
||||
Status aktivního plánu
|
||||
</h2>
|
||||
{loading && !run ? (
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Načítám…
|
||||
</div>
|
||||
) : !run ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-slate-400">Žádný aktivní plán.</p>
|
||||
{showPrepActions && (
|
||||
<PlanPrepActions
|
||||
prepAction={prepAction}
|
||||
replanning={replanning}
|
||||
onImport={() => void handleImportPrices()}
|
||||
onForecast={() => void handleRunForecast()}
|
||||
onInit={() => void handleInitializePlan()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-slate-200">
|
||||
<span className="text-slate-500">Vytvořeno:</span>
|
||||
<span className="font-mono">{formatLocal(run.created_at)}</span>
|
||||
<span className="text-slate-600">|</span>
|
||||
<span className="text-slate-500">Typ:</span>
|
||||
<span
|
||||
className={`rounded-md px-2 py-0.5 text-xs font-semibold uppercase tracking-wide ${runTypeBadgeClass(run.run_type)}`}
|
||||
>
|
||||
{run.run_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-slate-500">Horizont: </span>
|
||||
<span className="font-mono text-slate-200">
|
||||
{formatLocal(run.horizon_start)} → {formatLocal(run.horizon_end)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-slate-500">Korekce FVE forecastu:</span>
|
||||
<span className="inline-flex items-center gap-1 font-mono text-slate-200">
|
||||
{correctionPct != null ? (
|
||||
<>
|
||||
{correctionUp ? (
|
||||
<ArrowUpRight className="h-4 w-4 text-emerald-400" aria-hidden />
|
||||
) : (
|
||||
<ArrowDownRight className="h-4 w-4 text-amber-400" aria-hidden />
|
||||
)}
|
||||
{Number.isInteger(correctionPct)
|
||||
? correctionPct
|
||||
: correctionPct.toLocaleString('cs-CZ', { maximumFractionDigits: 1 })}{' '}
|
||||
%
|
||||
</>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-slate-500">Čas výpočtu solveru: </span>
|
||||
<span className="font-mono text-slate-200">
|
||||
{run.solver_duration_ms != null ? `${run.solver_duration_ms} ms` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
{summary && (
|
||||
<div className="border-t border-slate-800 pt-3 text-sm">
|
||||
<p className="mb-2 text-slate-500">Summary</p>
|
||||
<dl className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-xs text-slate-500">
|
||||
{summary.total_expected_cost_czk >= 0 ? 'Celkové náklady' : 'Celkový příjem'}
|
||||
</dt>
|
||||
<dd className="font-mono text-slate-100">
|
||||
{summary.total_expected_cost_czk >= 0
|
||||
? `${summary.total_expected_cost_czk.toFixed(2)} Kč`
|
||||
: `${Math.abs(summary.total_expected_cost_czk).toFixed(2)} Kč`}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-slate-500">kWh curtailmentu (A)</dt>
|
||||
<dd className="font-mono text-slate-100">
|
||||
{summary.total_pv_curtailed_kwh.toLocaleString('cs-CZ', {
|
||||
minimumFractionDigits: 3,
|
||||
maximumFractionDigits: 3,
|
||||
})}{' '}
|
||||
kWh
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-slate-500">Sloty nabíjení / vybíjení / export</dt>
|
||||
<dd className="font-mono text-slate-100">
|
||||
{summary.charge_slots} / {summary.discharge_slots} / {summary.export_slots}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-stretch gap-2 sm:items-end">
|
||||
{showPrepActions && (
|
||||
<PlanPrepActions
|
||||
prepAction={prepAction}
|
||||
replanning={replanning}
|
||||
onImport={() => void handleImportPrices()}
|
||||
onForecast={() => void handleRunForecast()}
|
||||
onInit={() => void handleInitializePlan()}
|
||||
wrapClassName="flex flex-wrap justify-end gap-2"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onReplan()}
|
||||
disabled={replanning || prepBusy}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-emerald-500 disabled:opacity-50"
|
||||
>
|
||||
{replanning ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
Přeplánovat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Sekce 2 */}
|
||||
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
||||
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">Graf plánu</h2>
|
||||
{!chartRows.length ? (
|
||||
<p className="text-sm text-slate-500">Žádná data pro graf (24 h od teď, max. 96 slotů).</p>
|
||||
) : (
|
||||
<div className="h-[350px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartRows} margin={{ top: 8, right: 72, left: 8, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
ticks={xTicks}
|
||||
tick={{ fill: '#94a3b8', fontSize: 10 }}
|
||||
interval={0}
|
||||
angle={-35}
|
||||
textAnchor="end"
|
||||
height={48}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="power"
|
||||
tick={{ fill: '#94a3b8', fontSize: 10 }}
|
||||
label={{ value: 'W', angle: -90, position: 'insideLeft', fill: '#64748b', fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="soc"
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
tick={{ fill: '#22c55e', fontSize: 10 }}
|
||||
label={{ value: 'SoC %', angle: 90, position: 'insideRight', fill: '#22c55e', fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="price"
|
||||
orientation="right"
|
||||
width={52}
|
||||
tick={{ fill: '#94a3b8', fontSize: 9 }}
|
||||
axisLine={{ stroke: '#64748b' }}
|
||||
tickLine={{ stroke: '#64748b' }}
|
||||
label={{
|
||||
value: 'Kč/kWh',
|
||||
angle: 90,
|
||||
position: 'insideRight',
|
||||
fill: '#94a3b8',
|
||||
fontSize: 10,
|
||||
offset: 10,
|
||||
}}
|
||||
/>
|
||||
<Tooltip content={<PlanTooltip />} />
|
||||
<Area
|
||||
yAxisId="power"
|
||||
type="monotone"
|
||||
dataKey="pv_a_w"
|
||||
name="FVE (A) / předpověď"
|
||||
stroke="#ca8a04"
|
||||
fill="#eab308"
|
||||
fillOpacity={0.35}
|
||||
/>
|
||||
<Bar yAxisId="power" dataKey="battery_setpoint_w" name="Baterie W" barSize={10} isAnimationActive={false}>
|
||||
{chartRows.map((e) => (
|
||||
<Cell
|
||||
key={e.ts}
|
||||
fill={e.battery_setpoint_w >= 0 ? '#22c55e' : '#f97316'}
|
||||
fillOpacity={0.85}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
<Line
|
||||
yAxisId="soc"
|
||||
type="monotone"
|
||||
dataKey="battery_soc_target_pct"
|
||||
name="SoC %"
|
||||
stroke="#4ade80"
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
yAxisId="price"
|
||||
type="monotone"
|
||||
dataKey="effective_buy_price"
|
||||
name="Cena nákup"
|
||||
stroke="#94a3b8"
|
||||
strokeDasharray="5 4"
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
connectNulls
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Sekce 3 */}
|
||||
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
||||
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">Tabulka slotů</h2>
|
||||
<div className="max-h-[400px] overflow-y-auto overflow-x-auto rounded-lg border border-slate-800/80">
|
||||
<table className="w-full border-collapse text-left text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-slate-900 shadow-[0_1px_0_0_rgb(30_41_59)]">
|
||||
<tr className="text-slate-500">
|
||||
<th className="whitespace-nowrap py-2 pl-2 pr-2 font-medium">Čas</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Cena kup</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Cena prod</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Bat. W</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">SoC %</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Síť W</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">EV1 W</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">EV2 W</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">TČ</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Náklady Kč</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{intervals24h.map((i) => {
|
||||
const sel = selectedStart === i.interval_start
|
||||
return (
|
||||
<tr
|
||||
key={i.interval_start}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedStart((prev) => (prev === i.interval_start ? null : i.interval_start))}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault()
|
||||
setSelectedStart((prev) => (prev === i.interval_start ? null : i.interval_start))
|
||||
}
|
||||
}}
|
||||
className={`cursor-pointer border-b border-slate-800/80 transition hover:bg-slate-800/40 ${tableRowClass(i, sel)}`}
|
||||
>
|
||||
<td className="whitespace-nowrap py-1.5 pl-2 pr-2 font-mono text-slate-300">
|
||||
{formatLocalTime(i.interval_start)}
|
||||
</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
||||
{i.effective_buy_price?.toFixed(3) ?? '—'}
|
||||
</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
||||
{i.effective_sell_price?.toFixed(3) ?? '—'}
|
||||
</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.battery_setpoint_w ?? '—'}</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
||||
{i.battery_soc_target_pct != null
|
||||
? `${i.battery_soc_target_pct.toFixed(1)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.grid_setpoint_w ?? '—'}</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.ev1_setpoint_w ?? '—'}</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.ev2_setpoint_w ?? '—'}</td>
|
||||
<td className="pr-2 text-slate-300">{i.heat_pump_enabled ? 'on' : 'off'}</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
||||
{i.expected_cost_czk?.toFixed(4) ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!intervals24h.length && !loading && (
|
||||
<p className="mt-2 text-sm text-slate-500">Žádné řádky v 24h okně.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
41
frontend/src/types/fullStatus.ts
Normal file
41
frontend/src/types/fullStatus.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export type FullStatusAlert = {
|
||||
level: 'warn' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type FullStatusResponse = {
|
||||
site: { id: number; code: string; name: string }
|
||||
operating_mode: {
|
||||
mode_code: string | null
|
||||
mode_name: string | null
|
||||
activated_at: string | null
|
||||
activated_by: string | null
|
||||
}
|
||||
heartbeat: {
|
||||
last_seen: string | null
|
||||
age_seconds: number | null
|
||||
status: string | null
|
||||
}
|
||||
telemetry: {
|
||||
inverter: {
|
||||
pv_power_w: number | null
|
||||
battery_soc_pct: number | null
|
||||
grid_power_w: number | null
|
||||
measured_at: string | null
|
||||
age_seconds: number | null
|
||||
}
|
||||
ev_chargers: { code: string; status: string | null; power_w: number | null }[]
|
||||
heat_pump: {
|
||||
power_w: number | null
|
||||
tank_temp_c: number | null
|
||||
measured_at: string | null
|
||||
}
|
||||
}
|
||||
planning: {
|
||||
has_active_plan: boolean
|
||||
plan_created_at: string | null
|
||||
next_interval_start: string | null
|
||||
next_battery_setpoint_w: number | null
|
||||
}
|
||||
alerts: FullStatusAlert[]
|
||||
}
|
||||
@@ -43,4 +43,6 @@ export type CurrentPlanResponse = {
|
||||
export type RunPlanResponse = {
|
||||
run_id: number
|
||||
solver_duration_ms: number
|
||||
horizon_start: string
|
||||
horizon_end: string
|
||||
}
|
||||
|
||||
@@ -1,24 +1,42 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/rest': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/rest/, ''),
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const oxideVendored = [
|
||||
join(__dirname, 'vendor', 'tailwindcss-oxide.linux-x64-gnu.node'),
|
||||
join(__dirname, 'vendor', 'tailwindcss-oxide.linux-x64-musl.node'),
|
||||
]
|
||||
for (const p of oxideVendored) {
|
||||
if (existsSync(p)) {
|
||||
process.env.NAPI_RS_NATIVE_LIBRARY_PATH = p
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(async () => {
|
||||
const { default: tailwindcss } = await import('@tailwindcss/vite')
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
chunkSizeWarningLimit: 750,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/rest': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/rest/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user