x
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user