This commit is contained in:
Dusan Vojacek
2026-03-20 14:30:03 +01:00
parent 2cc5ccfda7
commit 897b95f728
48 changed files with 4034 additions and 842 deletions

View File

@@ -1,5 +1,12 @@
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 }) {
@@ -11,9 +18,182 @@ function SectionTitle({ kicker, title }: { kicker: string; title: string }) {
)
}
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">
@@ -31,7 +211,8 @@ export function Settings() {
<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ř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}
@@ -45,52 +226,31 @@ export function Settings() {
</section>
<section>
<SectionTitle kicker="EV" title="Deadline nabíjení (připravuje se)" />
<p className="mb-4 text-sm text-slate-500">
Zatím pouze rozhraní; napojení na API a session přijde v další iteraci.
<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>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-slate-800 bg-slate-900/40 p-4">
<p className="text-sm font-medium text-slate-200">Tesla</p>
<div className="mt-3 flex flex-wrap gap-3">
<label className="flex flex-col text-xs text-slate-500">
Cílové SoC %
<input
type="number"
min={0}
max={100}
placeholder="např. 80"
disabled
className="mt-1 w-28 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400"
{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()}
/>
</label>
<label className="flex min-w-[200px] flex-col text-xs text-slate-500">
Deadline
<input type="datetime-local" disabled className="mt-1 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400" />
</label>
))}
</div>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900/40 p-4">
<p className="text-sm font-medium text-slate-200">Zoe</p>
<div className="mt-3 flex flex-wrap gap-3">
<label className="flex flex-col text-xs text-slate-500">
Cílové SoC %
<input
type="number"
min={0}
max={100}
placeholder="např. 80"
disabled
className="mt-1 w-28 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400"
/>
</label>
<label className="flex min-w-[200px] flex-col text-xs text-slate-500">
Deadline
<input type="datetime-local" disabled className="mt-1 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400" />
</label>
</div>
</div>
</div>
</>
)}
</section>
</div>
</div>