Files
ems/frontend/src/pages/Settings.tsx
Dusan Vojacek 897b95f728 x
2026-03-20 14:30:03 +01:00

259 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios from 'axios'
import { Car } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { patchEvSession, type ActiveEvSessionRow } from '../api/backend'
import { ModeLog } from '../components/ModeLog'
import { ModeSelector } from '../components/ModeSelector'
import { useEVSessions } from '../hooks/useEVSessions'
import { useSiteStatus } from '../hooks/useSiteStatus'
function SectionTitle({ kicker, title }: { kicker: string; title: string }) {
return (
<div className="mb-4">
<p className="text-xs font-semibold uppercase tracking-widest text-slate-500">{kicker}</p>
<h2 className="text-lg font-semibold text-slate-100">{title}</h2>
</div>
)
}
function toDatetimeLocalValue(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const h = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${y}-${m}-${day}T${h}:${min}`
}
/** Dnešní HH:00 nebo zítřejší, pokud už je po té hodině (včetně celé hodiny). */
function nextDeadlineAtHour(hour: number): Date {
const now = new Date()
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, 0, 0, 0)
if (d.getTime() <= now.getTime()) {
d.setDate(d.getDate() + 1)
}
return d
}
function isoToDatetimeLocal(iso: string): string {
return toDatetimeLocalValue(new Date(iso))
}
function datetimeLocalToIsoUtc(local: string): string {
const d = new Date(local)
if (Number.isNaN(d.getTime())) {
return new Date().toISOString()
}
return d.toISOString()
}
function vehicleTitle(s: ActiveEvSessionRow): string {
const m = (s.make ?? '').trim()
const mo = (s.model ?? '').trim()
if (!m && !mo) return 'Neznámé vozidlo'
return `${m} ${mo}`.trim()
}
/** Popisek do toastu preferuje model (např. Model Y). */
function toastVehicleLabel(s: ActiveEvSessionRow): string {
const mo = (s.model ?? '').trim()
if (mo) return mo
return vehicleTitle(s)
}
const CHARGER_SLOTS: { code: string; label: string }[] = [
{ code: 'ev-charger-1', label: 'Tesla' },
{ code: 'ev-charger-2', label: 'Zoe' },
]
function EvChargerCard({
siteId,
chargerLabel,
session,
onSaved,
}: {
siteId: number
chargerLabel: string
session: ActiveEvSessionRow | undefined
onSaved: () => void
}) {
const [soc, setSoc] = useState<number | ''>('')
const [deadlineLocal, setDeadlineLocal] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!session) {
setSoc('')
setDeadlineLocal('')
return
}
const defSoc = session.target_soc_pct ?? session.default_target_soc_pct ?? 80
setSoc(Math.round(Number(defSoc)))
if (session.target_deadline) {
setDeadlineLocal(isoToDatetimeLocal(session.target_deadline))
} else {
const h = session.default_deadline_hour ?? 7
setDeadlineLocal(toDatetimeLocalValue(nextDeadlineAtHour(h)))
}
}, [
session?.id,
session?.target_soc_pct,
session?.target_deadline,
session?.default_deadline_hour,
session?.default_target_soc_pct,
])
if (!session) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-slate-800 bg-slate-900/25 p-8 text-center">
<Car className="h-12 w-12 text-slate-600" aria-hidden />
<p className="mt-4 text-sm font-medium text-slate-500">Nepřipojeno</p>
<p className="mt-1 text-xs text-slate-600">{chargerLabel}</p>
</div>
)
}
const kwh = ((session.energy_delivered_wh ?? 0) / 1000).toFixed(1)
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (soc === '' || !deadlineLocal) return
const clamped = Math.min(100, Math.max(10, Math.round(Number(soc))))
setSaving(true)
try {
await patchEvSession(siteId, session.id, {
target_soc_pct: clamped,
target_deadline: datetimeLocalToIsoUtc(deadlineLocal),
})
toast.success(`Deadline nastaven pro ${toastVehicleLabel(session)}`)
onSaved()
} catch (err) {
const msg =
axios.isAxiosError(err) && err.response?.data && typeof err.response.data === 'object'
? JSON.stringify(err.response.data)
: err instanceof Error
? err.message
: 'Neznámá chyba'
toast.error('Uložení se nezdařilo', { description: msg })
} finally {
setSaving(false)
}
}
return (
<div className="rounded-xl border border-slate-700 bg-slate-900/50 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-emerald-500/90">Připojeno</p>
<p className="mt-1 text-sm font-semibold text-slate-100">{vehicleTitle(session)}</p>
<p className="mt-0.5 text-xs text-slate-500">{chargerLabel}</p>
<form onSubmit={(e) => void onSubmit(e)} className="mt-4 space-y-3">
<p className="text-sm text-slate-400">
Energie v session:{' '}
<span className="font-medium text-slate-200">{kwh} kWh</span>
</p>
<div className="flex flex-wrap gap-3">
<label className="flex flex-col text-xs text-slate-500">
Target SoC %
<input
type="number"
min={10}
max={100}
step={1}
value={soc}
onChange={(e) => {
const v = e.target.value
setSoc(v === '' ? '' : Number(v))
}}
className="mt-1 w-28 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-200"
/>
</label>
<label className="flex min-w-[200px] flex-col text-xs text-slate-500">
Deadline
<input
type="datetime-local"
value={deadlineLocal}
onChange={(e) => setDeadlineLocal(e.target.value)}
className="mt-1 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-200"
/>
</label>
</div>
<button
type="submit"
disabled={saving || soc === '' || !deadlineLocal}
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Ukládám…' : 'Uložit'}
</button>
</form>
</div>
)
}
export function Settings() {
const { site, ready, reload } = useSiteStatus()
const siteId = site?.site_id ?? null
const { sessions, ready: evReady, error: evError, reload: reloadEv } = useEVSessions(siteId)
return (
<div className="min-h-screen bg-slate-950 p-4 md:p-8">
<div className="mx-auto max-w-7xl space-y-12">
<header className="border-b border-slate-800/80 pb-6">
<h1 className="text-2xl font-bold tracking-tight text-white">Nastavení</h1>
<p className="mt-1 text-sm text-slate-400">Provozní režim a plánování flexibilní zátěže</p>
{ready && site ? (
<p className="mt-2 text-sm text-slate-500">
Lokalita: <span className="text-slate-300">{site.site_name}</span> ({site.site_code})
</p>
) : null}
</header>
<section>
<SectionTitle kicker="Řízení" title="Provozní režim" />
<p className="mb-4 max-w-3xl text-sm text-slate-400">
Přepnutí zapíše stav do databáze a notifikuje Loxone. U dočasného režimu lze nastavit čas návratu; po
vypršení systém obnoví předchozí režim.
</p>
<ModeSelector
siteId={siteId}
currentMode={site?.active_mode}
onModeApplied={() => void reload()}
/>
<div className="mt-8">
<h3 className="mb-3 text-sm font-medium text-slate-300">Poslední přepnutí</h3>
<ModeLog siteId={siteId} />
</div>
</section>
<section>
<SectionTitle kicker="EV" title="Deadline nabíjení" />
<p className="mb-4 max-w-3xl text-sm text-slate-400">
Při připojení vozidla na wallbox se zobrazí aktivní session (dotaz každých 30 s). Cílový SoC a deadline se
ukládají do <span className="text-slate-500">ev_session</span> pro plánovač.
</p>
{siteId === null ? (
<p className="text-sm text-slate-500">Načítám lokalitu</p>
) : !evReady ? (
<p className="text-sm text-slate-500">Načítám EV session</p>
) : (
<>
{evError ? <p className="mb-3 text-sm text-amber-600/90">{evError}</p> : null}
<div className="grid gap-4 md:grid-cols-2">
{CHARGER_SLOTS.map((slot) => (
<EvChargerCard
key={slot.code}
siteId={siteId}
chargerLabel={slot.label}
session={sessions.find((s) => s.charger_code === slot.code)}
onSaved={() => void reloadEv()}
/>
))}
</div>
</>
)}
</section>
</div>
</div>
)
}