Files
ems/frontend/src/pages/SiteConfiguration.tsx
Dusan Vojacek e686bc1d2c
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
fix solar sell pri male zaporne cene
2026-05-01 10:38:40 +02:00

488 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
block_export_on_negative_sell:
'LP: při záporném výkupu zákaz vývozu (site_grid_connection; viz planning.md)',
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>
)
}