stranka configuration
This commit is contained in:
@@ -20,6 +20,7 @@ from app.routers.energy_flows import router as energy_flows_router
|
||||
from app.routers.ev import router as ev_router
|
||||
from app.routers.full_status import router as full_status_router
|
||||
from app.routers.plan import router as plan_router
|
||||
from app.routers.site_configuration import router as site_configuration_router
|
||||
from app.ws_log_handler import WSLogHandler
|
||||
from app.ws_manager import manager
|
||||
from fastapi import (
|
||||
@@ -526,6 +527,7 @@ app = FastAPI(title="EMS Platform", lifespan=lifespan)
|
||||
app.include_router(plan_router, prefix="/api/v1")
|
||||
app.include_router(ev_router, prefix="/api/v1")
|
||||
app.include_router(full_status_router, prefix="/api/v1")
|
||||
app.include_router(site_configuration_router, prefix="/api/v1")
|
||||
app.include_router(economics_router, prefix="/api/v1")
|
||||
app.include_router(energy_flows_router, prefix="/api/v1")
|
||||
|
||||
|
||||
248
backend/app/routers/site_configuration.py
Normal file
248
backend/app/routers/site_configuration.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""GET /sites/{site_id}/configuration – read-only souhrn konfigurace lokality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.db_json import record_to_dict
|
||||
from app.deps import get_pg_pool
|
||||
|
||||
router = APIRouter(prefix="/sites/{site_id}", tags=["sites"])
|
||||
|
||||
_DEYE_KEYS = frozenset(
|
||||
{
|
||||
"deye_last_system_time_sync_at",
|
||||
"deye_last_system_time_sync_minute",
|
||||
"deye_last_tou_inactive_write_prague_date",
|
||||
"deye_tou_inactive_signature",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _mask_secret_reference(raw: str | None) -> str | None:
|
||||
if raw is None:
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None
|
||||
if len(s) <= 4:
|
||||
return "nastaveno"
|
||||
return f"…{s[-2:]}"
|
||||
|
||||
|
||||
def _iso_utc(dt: datetime | None) -> str | None:
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@router.get("/configuration")
|
||||
async def get_site_configuration(
|
||||
site_id: int,
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> dict[str, Any]:
|
||||
async with pool.acquire() as conn:
|
||||
site_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, code, name, timezone, latitude, longitude, active, notes, created_at
|
||||
FROM ems.site
|
||||
WHERE id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if site_row is None:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
grid_row = await conn.fetchrow(
|
||||
"SELECT * FROM ems.site_grid_connection WHERE site_id = $1",
|
||||
site_id,
|
||||
)
|
||||
market_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT *
|
||||
FROM ems.site_market_config
|
||||
WHERE site_id = $1
|
||||
AND valid_from <= now()
|
||||
AND (valid_to IS NULL OR valid_to > now())
|
||||
ORDER BY valid_from DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
endpoint_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, site_id, endpoint_type, host, port, protocol, unit_id,
|
||||
auth_reference, enabled, notes
|
||||
FROM ems.site_endpoint
|
||||
WHERE site_id = $1
|
||||
ORDER BY id
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
endpoints: list[dict[str, Any]] = []
|
||||
for er in endpoint_rows:
|
||||
d = record_to_dict(er)
|
||||
d["auth_reference"] = _mask_secret_reference(er["auth_reference"])
|
||||
endpoints.append(d)
|
||||
|
||||
inv_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT ai.*,
|
||||
(SELECT ep.host || CASE
|
||||
WHEN ep.port IS NOT NULL THEN ':' || ep.port::text
|
||||
ELSE ''
|
||||
END
|
||||
FROM ems.site_endpoint ep
|
||||
WHERE ep.id = ai.endpoint_id) AS endpoint_connection
|
||||
FROM ems.asset_inverter ai
|
||||
WHERE ai.site_id = $1
|
||||
ORDER BY ai.id
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
inverters: list[dict[str, Any]] = []
|
||||
for ir in inv_rows:
|
||||
full = record_to_dict(ir)
|
||||
ep_label = full.pop("endpoint_connection", None)
|
||||
core = {k: v for k, v in full.items() if k not in _DEYE_KEYS}
|
||||
deye_meta = {k: full[k] for k in _DEYE_KEYS if full.get(k) is not None}
|
||||
core["endpoint_connection"] = ep_label
|
||||
core["deye_meta"] = deye_meta if deye_meta else None
|
||||
inverters.append(core)
|
||||
|
||||
bat_rows = await conn.fetch(
|
||||
"SELECT * FROM ems.asset_battery WHERE site_id = $1 ORDER BY id",
|
||||
site_id,
|
||||
)
|
||||
pv_rows = await conn.fetch(
|
||||
"SELECT * FROM ems.asset_pv_array WHERE site_id = $1 ORDER BY id",
|
||||
site_id,
|
||||
)
|
||||
ev_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT ec.*,
|
||||
se.host || CASE
|
||||
WHEN se.port IS NOT NULL THEN ':' || se.port::text
|
||||
ELSE ''
|
||||
END AS endpoint_connection
|
||||
FROM ems.asset_ev_charger ec
|
||||
LEFT JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
|
||||
WHERE ec.site_id = $1
|
||||
ORDER BY ec.id
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
ev_chargers = [record_to_dict(r) for r in ev_rows]
|
||||
|
||||
veh_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, site_id, code, name, make, model, battery_capacity_kwh,
|
||||
max_charge_power_w, default_charger_id, api_type, api_reference,
|
||||
default_target_soc_pct, default_deadline_hour, active
|
||||
FROM ems.asset_vehicle
|
||||
WHERE site_id = $1
|
||||
ORDER BY code
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
vehicles: list[dict[str, Any]] = []
|
||||
for vr in veh_rows:
|
||||
d = record_to_dict(vr)
|
||||
d["api_reference"] = _mask_secret_reference(vr["api_reference"])
|
||||
vehicles.append(d)
|
||||
|
||||
hp_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT hp.*,
|
||||
se.host || CASE
|
||||
WHEN se.port IS NOT NULL THEN ':' || se.port::text
|
||||
ELSE ''
|
||||
END AS endpoint_connection
|
||||
FROM ems.asset_heat_pump hp
|
||||
LEFT JOIN ems.site_endpoint se ON se.id = hp.endpoint_id
|
||||
WHERE hp.site_id = $1
|
||||
ORDER BY hp.id
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
heat_pumps = [record_to_dict(r) for r in hp_rows]
|
||||
|
||||
mode_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT m.mode_code, m.activated_at, m.activated_by, m.valid_until,
|
||||
m.previous_mode, m.notes,
|
||||
d.name AS mode_name, d.description AS mode_description,
|
||||
d.loxone_mode_value, d.ev_enabled, d.heat_pump_enabled,
|
||||
d.battery_mode, d.grid_mode, d.is_autonomous
|
||||
FROM ems.site_operating_mode m
|
||||
JOIN ems.operating_mode_def d ON d.code = m.mode_code
|
||||
WHERE m.site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
override_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, override_type, value_json, valid_from, valid_to, reason, created_by, created_at
|
||||
FROM ems.site_override
|
||||
WHERE site_id = $1
|
||||
AND valid_from <= now()
|
||||
AND (valid_to IS NULL OR valid_to > now())
|
||||
ORDER BY valid_from DESC
|
||||
LIMIT 50
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
hb_row = await conn.fetchrow(
|
||||
"SELECT last_seen, status FROM ems.site_heartbeat WHERE site_id = $1",
|
||||
site_id,
|
||||
)
|
||||
run_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, created_at
|
||||
FROM ems.planning_run
|
||||
WHERE site_id = $1 AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
site = record_to_dict(site_row)
|
||||
lat = site_row["latitude"]
|
||||
lon = site_row["longitude"]
|
||||
site["latitude"] = float(lat) if lat is not None else None
|
||||
site["longitude"] = float(lon) if lon is not None else None
|
||||
|
||||
operating_mode = record_to_dict(mode_row) if mode_row else None
|
||||
|
||||
return {
|
||||
"site": site,
|
||||
"grid_connection": record_to_dict(grid_row) if grid_row else None,
|
||||
"market_config": record_to_dict(market_row) if market_row else None,
|
||||
"market_config_note": (
|
||||
"Zelený bonus za výrobu je u FVE polí (asset_pv_array), ne v obchodní konfiguraci."
|
||||
),
|
||||
"endpoints": endpoints,
|
||||
"inverters": inverters,
|
||||
"batteries": [record_to_dict(r) for r in bat_rows],
|
||||
"pv_arrays": [record_to_dict(r) for r in pv_rows],
|
||||
"ev_chargers": ev_chargers,
|
||||
"vehicles": vehicles,
|
||||
"heat_pumps": heat_pumps,
|
||||
"operating_mode": operating_mode,
|
||||
"active_overrides": [record_to_dict(r) for r in override_rows],
|
||||
"operational": {
|
||||
"heartbeat_last_seen": _iso_utc(hb_row["last_seen"]) if hb_row else None,
|
||||
"heartbeat_status": hb_row["status"] if hb_row else None,
|
||||
"has_active_plan": run_row is not None,
|
||||
"active_plan_created_at": _iso_utc(run_row["created_at"]) if run_row else None,
|
||||
},
|
||||
}
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
485
frontend/src/pages/SiteConfiguration.tsx
Normal file
485
frontend/src/pages/SiteConfiguration.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
frontend/src/types/siteConfiguration.ts
Normal file
50
frontend/src/types/siteConfiguration.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user