Initial commit

Made-with: Cursor
This commit is contained in:
Dusan Vojacek
2026-03-20 13:27:37 +01:00
commit 8b4af663d8
77 changed files with 13337 additions and 0 deletions

View File

@@ -0,0 +1,132 @@
import { useCallback, useEffect, useState } from 'react'
import { getJson } from '../api/postgrest'
import type { ModeLogRecentRow } from '../types/ems'
function modeBadgeClass(code: string): string {
const c = code.toUpperCase()
if (c === 'AUTO') return 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/35'
if (c === 'SELF_SUSTAIN') return 'bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-500/35'
if (c === 'CHARGE_CHEAP') return 'bg-violet-500/15 text-violet-200 ring-1 ring-violet-500/35'
if (c === 'PRESERVE') return 'bg-amber-500/15 text-amber-200 ring-1 ring-amber-500/35'
if (c === 'MANUAL') return 'bg-slate-600/50 text-slate-200 ring-1 ring-slate-500/40'
return 'bg-slate-700/60 text-slate-200 ring-1 ring-slate-600/50'
}
function num(v: string | number | null | undefined): number {
if (v == null) return NaN
const n = typeof v === 'number' ? v : Number(v)
return n
}
function formatDuration(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return '—'
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
const s = Math.floor(sec % 60)
if (h > 0) return `${h} h ${m} min`
if (m > 0) return `${m} min ${s > 0 ? `${s} s` : ''}`.trim()
return `${s} s`
}
function fmtTime(iso: string): string {
try {
const d = new Date(iso)
return d.toLocaleString('cs-CZ', { dateStyle: 'short', timeStyle: 'medium' })
} catch {
return iso
}
}
type Props = {
siteId: number | null
}
export function ModeLog({ siteId }: Props) {
const [rows, setRows] = useState<ModeLogRecentRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
if (siteId == null) {
setRows([])
setLoading(false)
return
}
setLoading(true)
setError(null)
try {
const data = await getJson<ModeLogRecentRow[]>('/vw_mode_log_recent', {
site_id: `eq.${siteId}`,
order: 'activated_at.desc',
limit: '20',
})
setRows(Array.isArray(data) ? data : [])
} catch (e) {
setError(String(e))
setRows([])
} finally {
setLoading(false)
}
}, [siteId])
useEffect(() => {
void load()
}, [load])
if (siteId == null) {
return <p className="text-sm text-slate-500">Vyberte nebo načtěte lokalitu.</p>
}
if (loading) {
return <div className="h-40 animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
if (error) {
return <p className="text-sm text-red-400">Nelze načíst log: {error}</p>
}
return (
<div className="overflow-x-auto rounded-xl border border-slate-800">
<table className="w-full min-w-[640px] border-collapse text-left text-sm">
<thead>
<tr className="border-b border-slate-800 bg-slate-900/80 text-xs font-semibold uppercase tracking-wide text-slate-500">
<th className="px-4 py-3">Čas</th>
<th className="px-4 py-3">Režim</th>
<th className="px-4 py-3">Trvání</th>
<th className="px-4 py-3">Kdo</th>
<th className="px-4 py-3">Poznámka</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
Žádné záznamy za posledních 7 dní.
</td>
</tr>
) : (
rows.map((r) => (
<tr key={r.id} className="border-b border-slate-800/80 hover:bg-slate-900/40">
<td className="whitespace-nowrap px-4 py-3 tabular-nums text-slate-300">{fmtTime(r.activated_at)}</td>
<td className="px-4 py-3">
<span
className={`inline-flex rounded-md px-2 py-0.5 text-xs font-semibold uppercase tracking-wide ${modeBadgeClass(r.mode_code)}`}
>
{r.mode_code}
</span>
</td>
<td className="whitespace-nowrap px-4 py-3 tabular-nums text-slate-400">{formatDuration(num(r.duration_sec))}</td>
<td className="max-w-[140px] truncate px-4 py-3 text-slate-400" title={r.activated_by ?? ''}>
{r.activated_by ?? '—'}
</td>
<td className="max-w-[280px] truncate px-4 py-3 text-slate-400" title={r.notes ?? ''}>
{r.notes?.trim() ? r.notes : '—'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,269 @@
import {
BatteryCharging,
Bot,
Car,
Check,
Home,
Shield,
Thermometer,
Wrench,
X,
} from 'lucide-react'
import axios from 'axios'
import { useCallback, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { postSiteMode } from '../api/backend'
export type OperatingModeCode = 'AUTO' | 'SELF_SUSTAIN' | 'CHARGE_CHEAP' | 'PRESERVE' | 'MANUAL'
type ModeDef = {
code: OperatingModeCode
title: string
description: string
ev: boolean
hp: boolean
Icon: typeof Bot
}
const MODES: ModeDef[] = [
{
code: 'AUTO',
title: 'AUTO',
description: 'EMS řídí FVE, baterii, EV a TČ podle plánu a cen.',
ev: true,
hp: true,
Icon: Bot,
},
{
code: 'SELF_SUSTAIN',
title: 'SELF_SUSTAIN',
description: 'Autonomní domácí režim bez exportu; EV a TČ zastaveny.',
ev: false,
hp: false,
Icon: Home,
},
{
code: 'CHARGE_CHEAP',
title: 'CHARGE_CHEAP',
description: 'Max. nabíjení baterie; EV a TČ vypnuty.',
ev: false,
hp: false,
Icon: BatteryCharging,
},
{
code: 'PRESERVE',
title: 'PRESERVE',
description: 'Držení SoC; EV a TČ zastaveny (dovolená / servis).',
ev: false,
hp: false,
Icon: Shield,
},
{
code: 'MANUAL',
title: 'MANUAL',
description: 'Servisní režim; žádné řízení z EMS.',
ev: false,
hp: false,
Icon: Wrench,
},
]
function modeBadgeRing(code: string): string {
const c = code.toUpperCase()
if (c === 'AUTO') return 'ring-emerald-500/50'
if (c === 'SELF_SUSTAIN') return 'ring-cyan-500/50'
if (c === 'CHARGE_CHEAP') return 'ring-violet-500/50'
if (c === 'PRESERVE') return 'ring-amber-500/50'
if (c === 'MANUAL') return 'ring-slate-500/50'
return 'ring-slate-600'
}
type Props = {
siteId: number | null
currentMode: string | null | undefined
onModeApplied?: () => void
}
export function ModeSelector({ siteId, currentMode, onModeApplied }: Props) {
const [pending, setPending] = useState<OperatingModeCode | null>(null)
const [notes, setNotes] = useState('')
const [validUntilLocal, setValidUntilLocal] = useState('')
const [optimisticMode, setOptimisticMode] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const displayMode = optimisticMode ?? currentMode ?? null
const normalizedCurrent = (displayMode ?? '').toUpperCase()
const closeModal = useCallback(() => {
setPending(null)
setNotes('')
setValidUntilLocal('')
}, [])
const confirmSwitch = useCallback(async () => {
if (siteId == null || pending == null) return
const modeCode = pending
const notePayload = notes.trim() === '' ? null : notes.trim()
const valid_until =
validUntilLocal.trim() === '' ? null : new Date(validUntilLocal).toISOString()
setSubmitting(true)
setOptimisticMode(modeCode)
closeModal()
try {
await postSiteMode(siteId, {
mode: modeCode,
notes: notePayload,
valid_until,
})
setOptimisticMode(null)
onModeApplied?.()
toast.success(`Režim ${modeCode} byl aktivován.`)
} catch (e: unknown) {
setOptimisticMode(null)
let msg = String(e)
if (axios.isAxiosError(e)) {
const d = e.response?.data as { detail?: unknown } | undefined
if (d?.detail != null) {
msg = Array.isArray(d.detail) ? d.detail.map((x) => JSON.stringify(x)).join('; ') : String(d.detail)
} else if (e.message) {
msg = e.message
}
}
toast.error('Přepnutí režimu se nezdařilo', { description: msg })
} finally {
setSubmitting(false)
}
}, [siteId, pending, notes, validUntilLocal, closeModal, onModeApplied])
const openConfirm = useCallback(
(code: OperatingModeCode) => {
if (siteId == null) {
toast.error('Chybí lokalita (site_id).')
return
}
if (code === normalizedCurrent) return
setPending(code)
setNotes('')
setValidUntilLocal('')
},
[siteId, normalizedCurrent],
)
const modalTitle = useMemo(() => {
if (!pending) return ''
const m = MODES.find((x) => x.code === pending)
return m?.title ?? pending
}, [pending])
return (
<div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{MODES.map(({ code, title, description, ev, hp, Icon }) => {
const active = normalizedCurrent === code
return (
<button
key={code}
type="button"
disabled={siteId == null || submitting}
onClick={() => openConfirm(code)}
className={[
'flex flex-col rounded-xl border p-4 text-left transition',
active
? 'border-emerald-500/70 bg-emerald-950/35 ring-2 ring-emerald-500/40'
: 'border-slate-800 bg-slate-900/40 hover:border-slate-600 hover:bg-slate-900/70',
submitting ? 'opacity-60' : '',
].join(' ')}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span
className={`flex h-9 w-9 items-center justify-center rounded-lg bg-slate-800/80 ring-1 ${modeBadgeRing(code)}`}
>
<Icon className="h-5 w-5 text-slate-200" aria-hidden />
</span>
<span className="text-sm font-semibold tracking-wide text-slate-100">{title}</span>
</div>
</div>
<p className="mt-2 line-clamp-2 text-xs leading-snug text-slate-400">{description}</p>
<div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-500">
<span className="flex items-center gap-1">
<Car className="h-3.5 w-3.5" aria-hidden />
EV
{ev ? (
<Check className="h-3.5 w-3.5 text-emerald-400" aria-label="povoleno" />
) : (
<X className="h-3.5 w-3.5 text-red-400" aria-label="zakázáno" />
)}
</span>
<span className="flex items-center gap-1">
<Thermometer className="h-3.5 w-3.5" aria-hidden />
{hp ? (
<Check className="h-3.5 w-3.5 text-emerald-400" aria-label="povoleno" />
) : (
<X className="h-3.5 w-3.5 text-red-400" aria-label="zakázáno" />
)}
</span>
</div>
</button>
)
})}
</div>
{pending ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="mode-confirm-title"
onClick={(ev) => {
if (ev.target === ev.currentTarget) closeModal()
}}
>
<div className="w-full max-w-md rounded-xl border border-slate-700 bg-slate-950 p-6 shadow-xl">
<h3 id="mode-confirm-title" className="text-lg font-semibold text-white">
Přepnout na {modalTitle}?
</h3>
<p className="mt-1 text-sm text-slate-400">Změna se zapíše do DB a odešle se signál do Loxone (je-li endpoint).</p>
<label className="mt-4 block text-xs font-medium uppercase tracking-wide text-slate-500">
Poznámka (volitelné)
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600"
placeholder="např. odjezd na víkend"
/>
</label>
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-slate-500">
Platí do (volitelné, lokální čas prohlížeče)
<input
type="datetime-local"
value={validUntilLocal}
onChange={(e) => setValidUntilLocal(e.target.value)}
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
/>
</label>
<div className="mt-6 flex justify-end gap-2">
<button
type="button"
onClick={closeModal}
className="rounded-lg border border-slate-600 px-4 py-2 text-sm font-medium text-slate-200 hover:bg-slate-800"
>
Zrušit
</button>
<button
type="button"
disabled={submitting}
onClick={() => void confirmSwitch()}
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500 disabled:opacity-50"
>
Potvrdit přepnutí
</button>
</div>
</div>
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,33 @@
import type { LucideIcon } from 'lucide-react'
function formatKw(powerW: number | null | undefined): string {
if (powerW == null || Number.isNaN(powerW)) return '—'
const kw = powerW / 1000
return `${kw.toFixed(2)} kW`
}
type Props = {
label: string
powerW: number | null | undefined
icon: LucideIcon
/** např. border-l-amber-400 */
borderClass: string
/** např. text-amber-400 */
iconClass: string
}
export function PowerFlowCard({ label, powerW, icon: Icon, borderClass, iconClass }: Props) {
return (
<div
className={`flex items-center gap-4 rounded-xl border border-slate-800 bg-slate-900/60 p-4 pl-3 shadow-sm backdrop-blur-sm border-l-4 ${borderClass}`}
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
<Icon className={`h-6 w-6 ${iconClass}`} aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</p>
<p className="truncate text-xl font-semibold tabular-nums text-slate-100">{formatKw(powerW)}</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,111 @@
import { useCallback, useEffect, useState } from 'react'
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { getJson } from '../api/postgrest'
import { instantPragueDay, pragueCalendarDay } from '../lib/pragueDate'
import type { SiteEffectivePriceRow } from '../types/ems'
function parseNum(v: string | number | null | undefined): number | null {
if (v == null) return null
if (typeof v === 'number' && !Number.isNaN(v)) return v
const n = Number(v)
return Number.isFinite(n) ? n : null
}
export type PricePoint = {
label: string
buy: number | null
sell: number | null
}
type Props = {
siteId: number | null
pollMs?: number
}
/** Efektivní nákup / prodej (Kč/kWh) pro dnešní den v Europe/Prague. */
export function PriceChart({ siteId, pollMs = 120_000 }: Props) {
const [points, setPoints] = useState<PricePoint[]>([])
const [ready, setReady] = useState(false)
const load = useCallback(async () => {
if (siteId == null) {
setPoints([])
setReady(true)
return
}
try {
const rows = await getJson<SiteEffectivePriceRow[]>('/vw_site_effective_price', {
site_id: `eq.${siteId}`,
order: 'interval_start.desc',
limit: '200',
})
const today = pragueCalendarDay()
const todayRows = Array.isArray(rows)
? rows.filter((r) => instantPragueDay(r.interval_start) === today)
: []
todayRows.sort((a, b) => new Date(a.interval_start).getTime() - new Date(b.interval_start).getTime())
const mapped: PricePoint[] = todayRows.map((r) => {
const t = new Date(r.interval_start)
return {
label: t.toLocaleTimeString('cs-CZ', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Prague',
}),
buy: parseNum(r.effective_buy_price_czk_kwh),
sell: parseNum(r.effective_sell_price_czk_kwh),
}
})
setPoints(mapped)
} catch {
setPoints([])
} finally {
setReady(true)
}
}, [siteId])
useEffect(() => {
void load()
const id = window.setInterval(() => void load(), pollMs)
return () => window.clearInterval(id)
}, [load, pollMs])
if (!ready || points.length === 0) {
return <div className="h-[280px] w-full animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
return (
<div className="h-[280px] w-full rounded-xl border border-slate-800 bg-slate-900/40 p-2 pt-4">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={points} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.6} />
<XAxis dataKey="label" tick={{ fill: '#94a3b8', fontSize: 10 }} interval="preserveStartEnd" />
<YAxis
tick={{ fill: '#94a3b8', fontSize: 11 }}
label={{ value: 'Kč/kWh', angle: -90, position: 'insideLeft', fill: '#64748b', fontSize: 11 }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '8px',
}}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line type="stepAfter" dataKey="buy" name="Nákup" stroke="#f97316" strokeWidth={2} dot={false} connectNulls />
<Line type="stepAfter" dataKey="sell" name="Prodej" stroke="#38bdf8" strokeWidth={2} dot={false} connectNulls />
</LineChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -0,0 +1,77 @@
function clampPct(n: number): number {
return Math.max(0, Math.min(100, n))
}
function parseSoc(v: string | number | null | undefined): number | null {
if (v == null) return null
if (typeof v === 'number' && !Number.isNaN(v)) return v
const x = Number(v)
return Number.isFinite(x) ? x : null
}
type Props = {
socPercent: string | number | null | undefined
loading?: boolean
}
const R = 52
const C = 2 * Math.PI * R
const STROKE = 8
export function SocGauge({ socPercent, loading }: Props) {
if (loading) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-slate-800 bg-slate-900/60 p-6">
<div className="h-36 w-36 animate-pulse rounded-full bg-slate-800/80" />
<div className="mt-4 h-4 w-24 animate-pulse rounded bg-slate-800/80" />
</div>
)
}
const raw = parseSoc(socPercent)
if (raw == null) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-slate-800 bg-slate-900/60 p-6">
<div className="h-36 w-36 animate-pulse rounded-full bg-slate-800/80" />
<div className="mt-4 h-3 w-20 animate-pulse rounded bg-slate-800/80" />
</div>
)
}
const pct = clampPct(raw)
const offset = C - (pct / 100) * C
return (
<div className="flex flex-col items-center rounded-xl border border-slate-800 bg-slate-900/60 p-6">
<div className="relative">
<svg width="140" height="140" viewBox="0 0 120 120" className="-rotate-90" aria-hidden>
<circle
cx="60"
cy="60"
r={R}
fill="none"
stroke="currentColor"
strokeWidth={STROKE}
className="text-slate-800"
/>
<circle
cx="60"
cy="60"
r={R}
fill="none"
stroke="currentColor"
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={C}
strokeDashoffset={offset}
className="text-emerald-500 transition-[stroke-dashoffset] duration-500"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold tabular-nums text-slate-50">{pct.toFixed(0)}</span>
<span className="text-xs text-slate-500">% SoC</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import type { TelemetryChartPoint } from '../hooks/useTelemetryToday'
type Props = {
points: TelemetryChartPoint[]
loading?: boolean
}
function ChartSkeleton() {
return <div className="h-[320px] w-full animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
export function TelemetryChart({ points, loading }: Props) {
if (loading || points.length === 0) {
return <ChartSkeleton />
}
return (
<div className="h-[320px] w-full rounded-xl border border-slate-800 bg-slate-900/40 p-2 pt-4">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={points} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.6} />
<XAxis dataKey="timeLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} />
<YAxis
tick={{ fill: '#94a3b8', fontSize: 11 }}
label={{ value: 'kW', angle: -90, position: 'insideLeft', fill: '#64748b', fontSize: 11 }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '8px',
}}
labelStyle={{ color: '#e2e8f0' }}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line type="monotone" dataKey="pv_kw" name="FVE" stroke="#facc15" strokeWidth={2} dot={false} connectNulls />
<Line type="monotone" dataKey="load_kw" name="Spotřeba" stroke="#3b82f6" strokeWidth={2} dot={false} connectNulls />
<Line type="monotone" dataKey="battery_kw" name="Baterie" stroke="#22c55e" strokeWidth={2} dot={false} connectNulls />
<Line type="monotone" dataKey="grid_kw" name="Síť" stroke="#94a3b8" strokeWidth={2} dot={false} connectNulls />
</LineChart>
</ResponsiveContainer>
</div>
)
}