488 lines
19 KiB
TypeScript
488 lines
19 KiB
TypeScript
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>
|
||
)
|
||
}
|