Initial commit

Made-with: Cursor
This commit is contained in:
Dusan Vojacek
2026-03-20 13:27:37 +01:00
commit 8b4af663d8
77 changed files with 13337 additions and 0 deletions

View 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 />
{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>
)
}