stranka configuration
Some checks failed
test / smoke-test (push) Has been cancelled
deploy / deploy (push) Has been cancelled

This commit is contained in:
Dusan Vojacek
2026-04-12 16:56:44 +02:00
parent b50041cfc7
commit 4e81a36371
6 changed files with 796 additions and 0 deletions

View File

@@ -8,6 +8,7 @@ import Economics from './pages/Economics'
import EnergyFlows from './pages/EnergyFlows'
import { Logs } from './pages/Logs'
import Planning from './pages/Planning'
import SiteConfiguration from './pages/SiteConfiguration'
import { Settings } from './pages/Settings'
function SiteCombo() {
@@ -75,6 +76,9 @@ function AppLayout() {
<NavLink to="/energy-flows" className={tabClass}>
Toky
</NavLink>
<NavLink to="/site-config" className={tabClass}>
Konfigurace
</NavLink>
<NavLink to="/settings" className={tabClass}>
Nastavení
</NavLink>
@@ -109,6 +113,7 @@ export default function App() {
<Route path="planning" element={<Planning />} />
<Route path="economics" element={<Economics />} />
<Route path="energy-flows" element={<EnergyFlows />} />
<Route path="site-config" element={<SiteConfiguration />} />
<Route path="settings" element={<Settings />} />
</Route>
<Route path="logs" element={<Logs />} />

View File

@@ -1,6 +1,7 @@
import axios, { type AxiosInstance } from 'axios'
import type { FullStatusResponse } from '../types/fullStatus'
import type { SiteConfigurationResponse } from '../types/siteConfiguration'
import type { Notification } from '../types/dashboard'
import type { CurrentPlanResponse, RunPlanResponse } from '../types/plan'
@@ -53,6 +54,11 @@ export async function getSiteStatusFull(siteId: number): Promise<FullStatusRespo
return data
}
export async function getSiteConfiguration(siteId: number): Promise<SiteConfigurationResponse> {
const { data } = await client.get<SiteConfigurationResponse>(`/sites/${siteId}/configuration`)
return data
}
export type SiteNotificationsResponse = {
notifications: Notification[]
}

View File

@@ -0,0 +1,485 @@
import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { NavLink } from 'react-router-dom'
import { getSiteConfiguration } from '../api/backend'
import { useSiteSelection } from '../context/SiteSelectionContext'
import type { SiteConfigurationResponse } from '../types/siteConfiguration'
export function osmMapUrl(lat: number, lon: number): string {
return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`
}
export function googleMapUrl(lat: number, lon: number): string {
return `https://www.google.com/maps?q=${lat},${lon}`
}
/** OSM embed: bbox = minLon, minLat, maxLon, maxLat */
export function osmEmbedUrl(lat: number, lon: number, delta = 0.012): string {
const left = lon - delta
const right = lon + delta
const bottom = lat - delta
const top = lat + delta
const bbox = `${left},${bottom},${right},${top}`
return `https://www.openstreetmap.org/export/embed.html?bbox=${encodeURIComponent(bbox)}&layer=mapnik&marker=${encodeURIComponent(`${lat},${lon}`)}`
}
function fmtVal(v: unknown): string {
if (v == null) return '—'
if (typeof v === 'boolean') return v ? 'ano' : 'ne'
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : '—'
if (typeof v === 'object') return JSON.stringify(v)
return String(v)
}
function DefRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-1 gap-0.5 border-b border-slate-800/60 py-2 last:border-b-0 sm:grid-cols-[minmax(10rem,14rem)_1fr] sm:gap-4">
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</dt>
<dd className="break-words text-sm text-slate-200">{children}</dd>
</div>
)
}
function Section({
kicker,
title,
children,
}: {
kicker: string
title: string
children: React.ReactNode
}) {
return (
<section className="rounded-xl border border-slate-800 bg-slate-900/25 p-5">
<p className="text-xs font-semibold uppercase tracking-widest text-slate-500">{kicker}</p>
<h2 className="mb-1 text-lg font-semibold text-slate-100">{title}</h2>
{children}
</section>
)
}
function ObjectRows({
obj,
labels,
skip = [],
}: {
obj: Record<string, unknown>
labels?: Record<string, string>
skip?: string[]
}) {
const keys = Object.keys(obj)
.filter((k) => !skip.includes(k))
.filter((k) => obj[k] !== undefined)
.sort()
if (keys.length === 0) {
return <p className="text-sm text-slate-500">Žádná data</p>
}
return (
<dl>
{keys.map((k) => (
<DefRow key={k} label={labels?.[k] ?? k.replace(/_/g, ' ')}>
{fmtVal(obj[k])}
</DefRow>
))}
</dl>
)
}
const GRID_LABELS: Record<string, string> = {
max_import_power_w: 'Max. import (W)',
max_export_power_w: 'Max. export (W)',
no_export: 'Zákaz exportu',
reserved_capacity_w: 'Rezervovaný výkon (W)',
notes: 'Poznámky',
}
const MARKET_LABELS: Record<string, string> = {
purchase_pricing_mode: 'Režim nákupu',
sale_pricing_mode: 'Režim prodeje',
buy_margin_fixed_czk: 'Marže nákup fix (Kč/kWh)',
buy_margin_percent: 'Marže nákup %',
sell_margin_fixed_czk: 'Marže prodej fix (Kč/kWh)',
sell_margin_percent: 'Marže prodej %',
currency: 'Měna',
valid_from: 'Platnost od',
valid_to: 'Platnost do',
notes: 'Poznámky',
}
export default function SiteConfiguration() {
const { selectedSiteId, ready: selectionReady, error: selectionError } = useSiteSelection()
const [data, setData] = useState<SiteConfigurationResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [deyeOpen, setDeyeOpen] = useState<Record<number, boolean>>({})
const load = useCallback(async () => {
if (selectedSiteId == null) {
setData(null)
setError(null)
return
}
setLoading(true)
setError(null)
try {
const res = await getSiteConfiguration(selectedSiteId)
setData(res)
} catch {
setData(null)
setError('Konfiguraci se nepodařilo načíst')
} finally {
setLoading(false)
}
}, [selectedSiteId])
useEffect(() => {
void load()
}, [load])
const toggleDeye = (id: number) => {
setDeyeOpen((prev) => ({ ...prev, [id]: !prev[id] }))
}
if (!selectionReady) {
return (
<div className="mx-auto max-w-7xl px-4 py-8 md:px-8">
<p className="text-sm text-slate-500">Načítám lokality</p>
</div>
)
}
if (selectionError != null || selectedSiteId == null) {
return (
<div className="mx-auto max-w-7xl px-4 py-8 md:px-8">
<p className="text-sm text-amber-600/90">{selectionError ?? 'Vyberte lokalitu v horní liště.'}</p>
</div>
)
}
const site = data?.site
const lat = site?.latitude
const lon = site?.longitude
const hasCoords = lat != null && lon != null && Number.isFinite(lat) && Number.isFinite(lon)
return (
<div className="mx-auto max-w-7xl px-4 py-8 md:px-8">
<header className="mb-8 border-b border-slate-800/80 pb-6">
<h1 className="text-2xl font-bold tracking-tight text-white">Konfigurace lokality</h1>
<p className="mt-1 text-sm text-slate-400">
Souhrn statického nastavení z databáze pro vybranou lokalitu (pouze čtení).
</p>
{site ? (
<p className="mt-2 text-sm text-slate-500">
<span className="text-slate-300">{site.name}</span> ({site.code}) · ID {site.id}
</p>
) : null}
<nav className="mt-4 flex flex-wrap gap-2 text-sm">
<NavLink
to="/"
className="rounded-lg border border-slate-700 px-3 py-1.5 text-slate-300 hover:bg-slate-900"
>
Přehled
</NavLink>
<NavLink
to="/planning"
className="rounded-lg border border-slate-700 px-3 py-1.5 text-slate-300 hover:bg-slate-900"
>
Plánování
</NavLink>
<NavLink
to="/economics"
className="rounded-lg border border-slate-700 px-3 py-1.5 text-slate-300 hover:bg-slate-900"
>
Ekonomika
</NavLink>
<NavLink
to="/settings"
className="rounded-lg border border-slate-700 px-3 py-1.5 text-slate-300 hover:bg-slate-900"
>
Nastavení (režim, EV)
</NavLink>
</nav>
</header>
{loading ? <p className="text-sm text-slate-500">Načítám konfiguraci</p> : null}
{error ? <p className="mb-4 text-sm text-amber-600/90">{error}</p> : null}
{data ? (
<div className="space-y-8">
<Section kicker="Identita" title="Lokalita a poloha">
<dl>
<DefRow label="Časová zóna">{site?.timezone}</DefRow>
<DefRow label="Aktivní">{fmtVal(site?.active)}</DefRow>
<DefRow label="Poznámky">{site?.notes?.trim() ? site.notes : '—'}</DefRow>
<DefRow label="Vytvořeno">{site?.created_at ?? '—'}</DefRow>
<DefRow label="Zeměpisná šířka">{lat != null ? String(lat) : '—'}</DefRow>
<DefRow label="Zeměpisná délka">{lon != null ? String(lon) : '—'}</DefRow>
</dl>
{hasCoords ? (
<div className="mt-4 space-y-3">
<div className="flex flex-wrap gap-3">
<a
href={osmMapUrl(lat!, lon!)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-sky-400 hover:text-sky-300"
>
OpenStreetMap <ExternalLink className="h-3.5 w-3.5" aria-hidden />
</a>
<a
href={googleMapUrl(lat!, lon!)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-sky-400 hover:text-sky-300"
>
Google Maps <ExternalLink className="h-3.5 w-3.5" aria-hidden />
</a>
</div>
<div className="overflow-hidden rounded-lg border border-slate-800">
<iframe
title="Náhled mapy (OpenStreetMap)"
className="h-52 w-full bg-slate-900"
src={osmEmbedUrl(lat!, lon!)}
loading="lazy"
/>
</div>
<p className="text-xs text-slate-500">
Map data ©{' '}
<a href="https://www.openstreetmap.org/copyright" className="text-slate-400 underline">
OpenStreetMap přispěvatelé
</a>
</p>
</div>
) : (
<p className="mt-3 text-sm text-slate-500">Souřadnice nejsou vyplněné mapa se nezobrazí.</p>
)}
</Section>
<Section kicker="Provoz" title="Stručný stav">
<dl>
<DefRow label="Heartbeat (UTC)">{data.operational.heartbeat_last_seen ?? '—'}</DefRow>
<DefRow label="Stav heartbeat">{data.operational.heartbeat_status ?? '—'}</DefRow>
<DefRow label="Aktivní plán">{fmtVal(data.operational.has_active_plan)}</DefRow>
<DefRow label="Plán vytvořen (UTC)">
{data.operational.active_plan_created_at ?? '—'}
</DefRow>
</dl>
</Section>
<Section kicker="Síť" title="Připojení k distribuční síti">
{data.grid_connection ? (
<ObjectRows obj={data.grid_connection} labels={GRID_LABELS} skip={['id', 'site_id']} />
) : (
<p className="text-sm text-slate-500">Záznam site_grid_connection chybí.</p>
)}
</Section>
<Section kicker="Trh" title="Obchodní konfigurace (marže)">
<p className="mb-3 text-xs text-slate-500">{data.market_config_note}</p>
{data.market_config ? (
<ObjectRows obj={data.market_config} labels={MARKET_LABELS} skip={['id', 'site_id']} />
) : (
<p className="text-sm text-slate-500">Aktuální platný záznam site_market_config nenalezen.</p>
)}
</Section>
<Section kicker="Integrace" title="Endpointy">
{data.endpoints.length === 0 ? (
<p className="text-sm text-slate-500">Žádné endpointy.</p>
) : (
<ul className="space-y-4">
{data.endpoints.map((ep) => (
<li
key={ep.id}
className="rounded-lg border border-slate-800/80 bg-slate-950/40 p-4 text-sm text-slate-200"
>
<p className="font-medium text-slate-100">
{ep.endpoint_type}
{ep.enabled ? '' : ' (vypnuto)'}
</p>
<p className="mt-1 text-slate-400">
{ep.host}
{ep.port != null ? `:${ep.port}` : ''} {ep.protocol ? `· ${ep.protocol}` : ''}
</p>
{ep.unit_id != null ? <p className="text-slate-500">Unit ID: {ep.unit_id}</p> : null}
{ep.auth_reference ? (
<p className="text-slate-500">Auth reference: {ep.auth_reference}</p>
) : null}
{ep.notes ? <p className="mt-2 text-slate-500">{ep.notes}</p> : null}
</li>
))}
</ul>
)}
</Section>
<Section kicker="EMS" title="Provozní režim">
{data.operating_mode ? (
<ObjectRows
obj={data.operating_mode}
labels={{
mode_code: 'Kód režimu',
mode_name: 'Název',
mode_description: 'Popis definice',
activated_at: 'Aktivován',
activated_by: 'Aktivoval',
valid_until: 'Platí do',
previous_mode: 'Předchozí režim',
notes: 'Poznámky',
loxone_mode_value: 'Hodnota pro Loxone',
ev_enabled: 'EV v definici',
heat_pump_enabled: 'TČ v definici',
battery_mode: 'Režim baterie (def.)',
grid_mode: 'Režim sítě (def.)',
is_autonomous: 'Autonomní fallback',
}}
/>
) : (
<p className="text-sm text-slate-500">Chybí záznam site_operating_mode.</p>
)}
</Section>
<Section kicker="Zásahy" title="Aktivní přepisy (site_override)">
{data.active_overrides.length === 0 ? (
<p className="text-sm text-slate-500">Žádné aktivní přepisy.</p>
) : (
<ul className="space-y-3">
{data.active_overrides.map((o, i) => (
<li key={i} className="rounded-lg border border-amber-900/40 bg-amber-950/20 p-3 text-sm">
<p className="font-medium text-amber-100/90">{fmtVal(o.override_type)}</p>
<p className="text-slate-400">
{fmtVal(o.valid_from)} {o.valid_to != null ? fmtVal(o.valid_to) : 'bez konce'}
</p>
{o.reason ? <p className="text-slate-500">{String(o.reason)}</p> : null}
{o.value_json != null ? (
<pre className="mt-2 max-h-32 overflow-auto rounded bg-slate-950 p-2 text-xs text-slate-400">
{typeof o.value_json === 'string' ? o.value_json : JSON.stringify(o.value_json, null, 2)}
</pre>
) : null}
</li>
))}
</ul>
)}
</Section>
<Section kicker="Měnič" title="Střídače">
{data.inverters.length === 0 ? (
<p className="text-sm text-slate-500">Žádné střídače.</p>
) : (
<ul className="space-y-6">
{data.inverters.map((inv) => {
const row = inv as Record<string, unknown>
const { deye_meta: dmRaw, ...rest } = row
const dm = dmRaw as Record<string, unknown> | null | undefined
const id = Number(rest.id)
const hasDeye = dm != null && Object.keys(dm).length > 0
const open = deyeOpen[id] ?? false
return (
<li key={id} className="rounded-lg border border-slate-800 bg-slate-950/40 p-4">
<p className="font-medium text-slate-100">{fmtVal(rest.code)}</p>
<ObjectRows obj={rest} />
{hasDeye ? (
<div className="mt-3 border-t border-slate-800 pt-3">
<button
type="button"
onClick={() => toggleDeye(id)}
className="flex items-center gap-2 text-xs font-medium text-slate-400 hover:text-slate-200"
>
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
Deye / Modbus meta
</button>
{open ? (
<div className="mt-2">
<ObjectRows obj={dm} />
</div>
) : null}
</div>
) : null}
</li>
)
})}
</ul>
)}
</Section>
<Section kicker="Akumulace" title="Baterie">
{data.batteries.length === 0 ? (
<p className="text-sm text-slate-500">Žádné baterie.</p>
) : (
<ul className="space-y-4">
{data.batteries.map((b, i) => (
<li key={i} className="rounded-lg border border-slate-800 bg-slate-950/40 p-4">
<ObjectRows obj={b} skip={[]} />
</li>
))}
</ul>
)}
</Section>
<Section kicker="FVE" title="Fotovoltaická pole">
{data.pv_arrays.length === 0 ? (
<p className="text-sm text-slate-500">Žádná pole.</p>
) : (
<ul className="space-y-4">
{data.pv_arrays.map((p, i) => (
<li key={i} className="rounded-lg border border-slate-800 bg-slate-950/40 p-4">
<ObjectRows
obj={p}
labels={{
green_bonus_czk_kwh: 'Zelený bonus (Kč/kWh)',
green_bonus_valid_from: 'Bonus platí od',
green_bonus_valid_to: 'Bonus platí do',
green_bonus_meter_code: 'EAN / elektroměr',
}}
/>
</li>
))}
</ul>
)}
</Section>
<Section kicker="Mobilita" title="Nabíječky a vozidla">
<h3 className="mb-2 text-sm font-medium text-slate-300">Nabíječky</h3>
{data.ev_chargers.length === 0 ? (
<p className="mb-4 text-sm text-slate-500">Žádné nabíječky.</p>
) : (
<ul className="mb-6 space-y-3">
{data.ev_chargers.map((c, i) => (
<li key={i} className="rounded-lg border border-slate-800 bg-slate-950/40 p-4">
<ObjectRows obj={c} />
</li>
))}
</ul>
)}
<h3 className="mb-2 text-sm font-medium text-slate-300">Vozidla</h3>
{data.vehicles.length === 0 ? (
<p className="text-sm text-slate-500">Žádná vozidla.</p>
) : (
<ul className="space-y-3">
{data.vehicles.map((v, i) => (
<li key={i} className="rounded-lg border border-slate-800 bg-slate-950/40 p-4">
<ObjectRows obj={v} />
</li>
))}
</ul>
)}
</Section>
<Section kicker="TČ" title="Tepelná čerpadla">
{data.heat_pumps.length === 0 ? (
<p className="text-sm text-slate-500">Žádná tepelná čerpadla.</p>
) : (
<ul className="space-y-4">
{data.heat_pumps.map((h, i) => (
<li key={i} className="rounded-lg border border-slate-800 bg-slate-950/40 p-4">
<ObjectRows obj={h} />
</li>
))}
</ul>
)}
</Section>
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,50 @@
/** Odpověď GET /api/v1/sites/{id}/configuration */
export type SiteConfigurationSite = {
id: number
code: string
name: string
timezone: string
latitude: number | null
longitude: number | null
active: boolean
notes: string | null
created_at: string
}
export type SiteConfigurationEndpoint = {
id: number
site_id: number
endpoint_type: string
host: string
port: number | null
protocol: string | null
unit_id: number | null
auth_reference: string | null
enabled: boolean
notes: string | null
}
export type SiteConfigurationOperational = {
heartbeat_last_seen: string | null
heartbeat_status: string | null
has_active_plan: boolean
active_plan_created_at: string | null
}
export type SiteConfigurationResponse = {
site: SiteConfigurationSite
grid_connection: Record<string, unknown> | null
market_config: Record<string, unknown> | null
market_config_note: string
endpoints: SiteConfigurationEndpoint[]
inverters: Record<string, unknown>[]
batteries: Record<string, unknown>[]
pv_arrays: Record<string, unknown>[]
ev_chargers: Record<string, unknown>[]
vehicles: Record<string, unknown>[]
heat_pumps: Record<string, unknown>[]
operating_mode: Record<string, unknown> | null
active_overrides: Record<string, unknown>[]
operational: SiteConfigurationOperational
}