259 lines
9.1 KiB
TypeScript
259 lines
9.1 KiB
TypeScript
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>
|
||
)
|
||
}
|