Initial commit
Made-with: Cursor
This commit is contained in:
132
frontend/src/components/ModeLog.tsx
Normal file
132
frontend/src/components/ModeLog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
269
frontend/src/components/ModeSelector.tsx
Normal file
269
frontend/src/components/ModeSelector.tsx
Normal 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 />
|
||||
TČ
|
||||
{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>
|
||||
)
|
||||
}
|
||||
33
frontend/src/components/PowerFlowCard.tsx
Normal file
33
frontend/src/components/PowerFlowCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
frontend/src/components/PriceChart.tsx
Normal file
111
frontend/src/components/PriceChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
frontend/src/components/SocGauge.tsx
Normal file
77
frontend/src/components/SocGauge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
frontend/src/components/TelemetryChart.tsx
Normal file
54
frontend/src/components/TelemetryChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user