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.ev import router as ev_router
|
||||||
from app.routers.full_status import router as full_status_router
|
from app.routers.full_status import router as full_status_router
|
||||||
from app.routers.plan import router as plan_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_log_handler import WSLogHandler
|
||||||
from app.ws_manager import manager
|
from app.ws_manager import manager
|
||||||
from fastapi import (
|
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(plan_router, prefix="/api/v1")
|
||||||
app.include_router(ev_router, prefix="/api/v1")
|
app.include_router(ev_router, prefix="/api/v1")
|
||||||
app.include_router(full_status_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(economics_router, prefix="/api/v1")
|
||||||
app.include_router(energy_flows_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 EnergyFlows from './pages/EnergyFlows'
|
||||||
import { Logs } from './pages/Logs'
|
import { Logs } from './pages/Logs'
|
||||||
import Planning from './pages/Planning'
|
import Planning from './pages/Planning'
|
||||||
|
import SiteConfiguration from './pages/SiteConfiguration'
|
||||||
import { Settings } from './pages/Settings'
|
import { Settings } from './pages/Settings'
|
||||||
|
|
||||||
function SiteCombo() {
|
function SiteCombo() {
|
||||||
@@ -75,6 +76,9 @@ function AppLayout() {
|
|||||||
<NavLink to="/energy-flows" className={tabClass}>
|
<NavLink to="/energy-flows" className={tabClass}>
|
||||||
Toky
|
Toky
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink to="/site-config" className={tabClass}>
|
||||||
|
Konfigurace
|
||||||
|
</NavLink>
|
||||||
<NavLink to="/settings" className={tabClass}>
|
<NavLink to="/settings" className={tabClass}>
|
||||||
Nastavení
|
Nastavení
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -109,6 +113,7 @@ export default function App() {
|
|||||||
<Route path="planning" element={<Planning />} />
|
<Route path="planning" element={<Planning />} />
|
||||||
<Route path="economics" element={<Economics />} />
|
<Route path="economics" element={<Economics />} />
|
||||||
<Route path="energy-flows" element={<EnergyFlows />} />
|
<Route path="energy-flows" element={<EnergyFlows />} />
|
||||||
|
<Route path="site-config" element={<SiteConfiguration />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="logs" element={<Logs />} />
|
<Route path="logs" element={<Logs />} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import axios, { type AxiosInstance } from 'axios'
|
import axios, { type AxiosInstance } from 'axios'
|
||||||
|
|
||||||
import type { FullStatusResponse } from '../types/fullStatus'
|
import type { FullStatusResponse } from '../types/fullStatus'
|
||||||
|
import type { SiteConfigurationResponse } from '../types/siteConfiguration'
|
||||||
import type { Notification } from '../types/dashboard'
|
import type { Notification } from '../types/dashboard'
|
||||||
import type { CurrentPlanResponse, RunPlanResponse } from '../types/plan'
|
import type { CurrentPlanResponse, RunPlanResponse } from '../types/plan'
|
||||||
|
|
||||||
@@ -53,6 +54,11 @@ export async function getSiteStatusFull(siteId: number): Promise<FullStatusRespo
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSiteConfiguration(siteId: number): Promise<SiteConfigurationResponse> {
|
||||||
|
const { data } = await client.get<SiteConfigurationResponse>(`/sites/${siteId}/configuration`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export type SiteNotificationsResponse = {
|
export type SiteNotificationsResponse = {
|
||||||
notifications: Notification[]
|
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