const { useState, useEffect, useCallback } = React; // ---- API layer ---- const TOKEN_KEY = "admin_token"; const ADMIN_KEY = "admin_info"; const getToken = (): string => sessionStorage.getItem(TOKEN_KEY) ?? ""; const getAdmin = (): any => JSON.parse(sessionStorage.getItem(ADMIN_KEY) ?? "null"); async function apiRequest(path: string, options: RequestInit = {}): Promise { const headers = new Headers(options.headers); headers.set("Authorization", `Bearer ${getToken()}`); if (!(options.body instanceof FormData)) headers.set("Content-Type", "application/json"); const res = await fetch(path, { ...options, headers }); if (res.status === 401) { sessionStorage.clear(); window.location.reload(); throw new Error("unauthenticated"); } const body = await res.json(); if (!res.ok) throw new Error(body.message ?? `HTTP ${res.status}`); return body; } interface ListOpts { sort?: string; dir?: "asc" | "desc"; q?: string; filters?: Record; } const listApi = (endpoint: string, page: number, opts: ListOpts = {}, perPage = 25) => { const params = new URLSearchParams({ page: String(page), per_page: String(perPage) }); if (opts.sort) params.set("sort", opts.sort); if (opts.dir) params.set("dir", opts.dir); if (opts.q) params.set("q", opts.q); for (const [key, value] of Object.entries(opts.filters ?? {})) if (value !== "") params.set(key, value); return apiRequest(`${endpoint}?${params.toString()}`); }; // Friendly labels for the SwapOperation status codes (SwapOperationResource // rendered "In Progress" / "Completed" / "Failed (Billable)" / "Failed"). const STATUS_LABELS: Record = { in_progress: "In Progress", completed: "Completed", failed: "Failed", failed_billable: "Failed (Billable)", }; const statusLabel = (value: unknown): string => STATUS_LABELS[String(value)] ?? String(value); // Badge coloring matching the Filament status/target badges. function badgeClass(kind: string, value: unknown): string { const v = String(value); if (kind === "status") { if (v === "completed" || v === "approved") return "ok"; if (v === "failed" || v === "rejected") return "bad"; return "warn"; // in_progress, failed_billable, pending } if (kind === "target") return v === "All Stations" ? "info" : "warn"; return "neutral"; } const postApi = (endpoint: string, payload: unknown) => apiRequest(endpoint, { method: "POST", body: JSON.stringify(payload) }); const putApi = (endpoint: string, payload: unknown) => apiRequest(endpoint, { method: "PUT", body: JSON.stringify(payload) }); const patchApi = (endpoint: string, payload: unknown) => apiRequest(endpoint, { method: "PATCH", body: JSON.stringify(payload) }); const deleteApi = (endpoint: string) => apiRequest(endpoint, { method: "DELETE" }); const uploadApi = (endpoint: string, form: FormData) => apiRequest(endpoint, { method: "POST", body: form }); // downloadBinary fetches a binary endpoint with the auth header and triggers a // browser save (a plain can't carry the bearer token). async function downloadBinary(endpoint: string, filename: string) { const res = await fetch(endpoint, { headers: { Authorization: `Bearer ${getToken()}` } }); if (!res.ok) { window.alert("Download failed"); return; } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } // ---- toasts ---- // Generic, app-wide toast notifications. Call `toast(message, kind)` from // any event handler, anywhere in the app — no props or context wiring // needed — to surface a transient result for an action (e.g. "request // sent" vs "request failed"). Not tied to any one feature; reuse this for // future one-off action feedback instead of a bespoke inline message. // // Deliberately separate from the persistent, backend-backed Notifications // bell (NotificationBell/admin_notifications): that one is a durable log of // server-side events (like an OTA's eventual outcome); this one is // ephemeral, client-only feedback for "did the button click itself work." type ToastKind = "success" | "error" | "info"; interface ToastItem { id: number; kind: ToastKind; message: string; } let toastItems: ToastItem[] = []; let toastListeners: ((items: ToastItem[]) => void)[] = []; let toastSeq = 0; function dismissToast(id: number) { toastItems = toastItems.filter((t) => t.id !== id); toastListeners.forEach((listen) => listen(toastItems)); } function toast(message: string, kind: ToastKind = "info") { const item: ToastItem = { id: ++toastSeq, kind, message }; toastItems = [...toastItems, item]; toastListeners.forEach((listen) => listen(toastItems)); setTimeout(() => dismissToast(item.id), 5000); } function ToastContainer() { const [items, setItems] = useState(toastItems); useEffect(() => { toastListeners.push(setItems); return () => { toastListeners = toastListeners.filter((listen) => listen !== setItems); }; }, []); if (items.length === 0) return null; return (
{items.map((t) => (
dismissToast(t.id)}> {t.message}
))}
); } // ---- routing ---- // Client-side routing over the History API so every page has its own URL // (mirrors the Filament panel: /admin/stations, /admin/users/1/edit, …). // The Go SPA handler serves index.html for any /admin/* path, so deep links // and reloads work. const BASE = "/admin"; const TAB_SLUGS: Record = { Dashboard: "dashboard", Monitoring: "monitoring", Stations: "stations", Users: "users", Companies: "companies", Representatives: "representatives", Batteries: "batteries", "Battery Requests": "battery-requests", "User Requests": "user-requests", Swaps: "swaps", "Event Logs": "event-logs", Firmware: "firmware", Settings: "settings", Admins: "admins", Technicians: "technicians", "Key Access Log": "key-access-log", }; const SLUG_TABS: Record = Object.fromEntries( Object.entries(TAB_SLUGS).map(([tab, slug]) => [slug, tab]), ); // navigate pushes a new URL and notifies listeners (useRoute) to re-render. function navigate(path: string) { if (path !== window.location.pathname) { window.history.pushState({}, "", path); window.dispatchEvent(new PopStateEvent("popstate")); } } // useRoute returns the path segments below /admin, updating on nav/back/forward. function useRoute(): string[] { const [path, setPath] = useState(() => window.location.pathname); useEffect(() => { const onPop = () => setPath(window.location.pathname); window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, []); const rel = path.replace(/^\/admin\/?/, "").replace(/\/+$/, ""); return rel === "" ? [] : rel.split("/"); } // ---- shared presentation ---- interface Field { name: string; label: string; // "password-confirm" renders two password inputs (new + confirm) under one // logical field: both blank means "leave unchanged", both must match to // submit, and only the new-password value is sent (keyed by `name`). type?: "text" | "number" | "password" | "password-confirm" | "checkbox" | "select" | "readonly"; required?: boolean; options?: { value: string; label: string }[]; source?: "companies" | "stations"; // relationship dropdown for select fields } function formatCell(value: unknown): any { if (value === null || value === undefined) return —; if (typeof value === "boolean") return {value ? "yes" : "no"}; if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) { const date = new Date(value); if (!Number.isNaN(date.getTime())) { return date.toLocaleString(undefined, { year: "2-digit", month: "numeric", day: "numeric", hour: "numeric", minute: "2-digit", hour12: true, }); } return value.replace("T", " ").replace(/\.\d+Z$/, ""); } return String(value); } function Modal(props: { title: string; onClose: () => void; children: any }) { return (
e.stopPropagation()}>

{props.title}

{props.children}
); } // FormField renders one field. Kept as its own component so a select with a // relationship `source` can use the useFilterOptions hook. function FormField(props: { field: Field; value: any; onChange: (value: any) => void; }) { const { field: f } = props; const dynamic = useFilterOptions(f.type === "select" ? f.source : undefined); const options = f.options ?? dynamic; if (f.type === "readonly") { return (
{formatCell(props.value)}
); } if (f.type === "password-confirm") { const v = props.value ?? { password: "", confirm: "" }; return ( <>
props.onChange({ ...v, password: e.target.value })} />
props.onChange({ ...v, confirm: e.target.value })} />
); } return (
{f.type === "checkbox" ? ( props.onChange(e.target.checked)} /> ) : f.type === "select" ? ( ) : ( props.onChange(e.target.value)} /> )}
); } function ResourceForm(props: { fields: Field[]; initial?: Record; submitLabel: string; onSubmit: (values: Record) => Promise; }) { const seed: Record = {}; for (const f of props.fields) { const raw = props.initial?.[f.name]; // Coerce checkbox seeds to real booleans — list rows return tinyints (1/0) // which would otherwise be submitted as numbers and rejected by the API. if (f.type === "checkbox") seed[f.name] = Boolean(raw) && raw !== "0"; else if (f.type === "password-confirm") seed[f.name] = { password: "", confirm: "" }; else seed[f.name] = raw ?? ""; } const [values, setValues] = useState>(seed); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const setField = (name: string, value: any) => setValues((v) => ({ ...v, [name]: value })); const submit = async () => { for (const f of props.fields) { if (f.type === "password-confirm") { const v = values[f.name]; if (v.password !== v.confirm) return setError("Passwords do not match."); continue; } if (f.required && (values[f.name] === "" || values[f.name] == null)) return setError(`${f.label} is required.`); } const payload: Record = {}; for (const f of props.fields) { if (f.type === "readonly") continue; const v = values[f.name]; if (f.type === "checkbox") { payload[f.name] = Boolean(v); continue; } if (f.type === "password-confirm") { if (v.password !== "") payload[f.name] = v.password; continue; } if (v === "" || v == null) continue; if (f.type === "number") payload[f.name] = Number(v); else if (f.type === "select") payload[f.name] = /^\d+$/.test(String(v)) ? Number(v) : v; else payload[f.name] = v; } setBusy(true); setError(""); try { await props.onSubmit(payload); } catch (e: any) { setError(e.message ?? "Request failed"); } finally { setBusy(false); } }; return (
{props.fields.map((f) => ( setField(f.name, value)} /> ))}
{error}
); } interface Column { key: string; label: string; sortable?: boolean; badge?: "status" | "target" | "online" | "check"; format?: "filesize"; } // Filters mirror the Filament table filters. Static filters carry their own // options; relationship filters name a `source` list endpoint (companies / // stations) whose rows populate an id -> name dropdown. interface FilterDef { param: string; label: string; options?: { value: string; label: string }[]; source?: "companies" | "stations"; defaultValue?: string; } // Relationship-filter options are fetched once per source and shared. const optionCache: Record = {}; function useFilterOptions(source?: "companies" | "stations") { const [options, setOptions] = useState<{ value: string; label: string }[]>( source ? optionCache[source] ?? [] : [], ); useEffect(() => { if (!source || optionCache[source]) return; listApi(`/api/admin/${source}`, 1, { sort: "name", dir: "asc" }, 200) .then((b: any) => { const opts = (b.data ?? []).map((row: any) => ({ value: String(row.id), label: row.name ?? `#${row.id}`, })); optionCache[source] = opts; setOptions(opts); }) .catch(() => setOptions([])); }, [source]); return options; } function FilterControl(props: { def: FilterDef; value: string; onChange: (value: string) => void; }) { const dynamic = useFilterOptions(props.def.source); const options = props.def.options ?? dynamic; return ( ); } function ResourceView(props: { endpoint: string; columns: Column[]; createFields?: Field[]; editFields?: Field[]; canDelete?: boolean; searchable?: boolean; filters?: FilterDef[]; defaultSort?: string; defaultDir?: "asc" | "desc"; extraRowActions?: (row: any, reload: () => void) => any; headerExtra?: (reload: () => void) => any; }) { const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [editing, setEditing] = useState(null); const [error, setError] = useState(""); const [sort, setSort] = useState(props.defaultSort ?? ""); const [dir, setDir] = useState<"asc" | "desc">(props.defaultDir ?? "asc"); const [query, setQuery] = useState(""); const [search, setSearch] = useState(""); const [filterValues, setFilterValues] = useState>(() => { const seed: Record = {}; for (const f of props.filters ?? []) seed[f.param] = f.defaultValue ?? ""; return seed; }); const filterKey = JSON.stringify(filterValues); const reload = useCallback(() => { setLoading(true); setError(""); listApi(props.endpoint, page, { sort, dir, q: search, filters: filterValues }) .then((b: any) => { setRows(b.data ?? []); setTotal(b.total ?? 0); }) .catch((e: any) => setError(e.message ?? "Load failed")) .finally(() => setLoading(false)); }, [props.endpoint, page, sort, dir, search, filterKey]); useEffect(reload, [reload]); const toggleSort = (key: string) => { setPage(1); if (sort === key) { setDir(dir === "asc" ? "desc" : "asc"); } else { setSort(key); setDir("asc"); } }; const remove = async (row: any) => { if (!window.confirm(`Delete record #${row.id}? This cannot be undone.`)) return; try { await deleteApi(`${props.endpoint}/${row.id}`); reload(); } catch (e: any) { window.alert(e.message ?? "Delete failed"); } }; const renderCell = (c: Column, row: any) => { if (c.badge === "check") { const yes = row[c.key] === true || Number(row[c.key]) === 1; return ( {yes ? "✓" : "✗"} ); } if (c.badge === "online") { const on = Number(row[c.key]) === 1; return {on ? "online" : "offline"}; } if (c.badge && row[c.key] != null) { const text = c.badge === "status" ? statusLabel(row[c.key]) : String(row[c.key]); return {text}; } if (c.format === "filesize") { const bytes = Number(row[c.key]); return row[c.key] == null ? — : `${(bytes / 1024).toFixed(1)} KB`; } return formatCell(row[c.key]); }; const hasActions = props.canDelete || props.editFields || props.extraRowActions; const totalPages = Math.max(1, Math.ceil(total / 25)); return ( <>
{props.createFields && ( )} {props.headerExtra?.(reload)} {props.searchable && ( setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { setPage(1); setSearch(query); } }} /> {search && ( )} )} {(props.filters ?? []).map((f) => ( { setPage(1); setFilterValues({ ...filterValues, [f.param]: value }); }} /> ))} {error}
{loading ? (

Loading…

) : rows.length === 0 ? (

No records.

) : ( {props.columns.map((c) => ( ))} {hasActions && } {rows.map((row, i) => ( {props.columns.map((c) => ( ))} {hasActions && ( )} ))}
toggleSort(c.key) : undefined} className={c.sortable ? "sortable" : ""} > {c.label} {c.sortable && sort === c.key ? (dir === "asc" ? " ▲" : " ▼") : ""} Actions
{renderCell(c, row)} {props.extraRowActions?.(row, reload)} {props.editFields && ( )} {props.canDelete && ( )}
)}
Page {page} / {totalPages} ({total} rows)
{creating && props.createFields && ( setCreating(false)}> { await postApi(props.endpoint, v); setCreating(false); reload(); }} /> )} {editing && props.editFields && ( setEditing(null)}> { await putApi(`${props.endpoint}/${editing.id}`, v); setEditing(null); reload(); }} /> )} ); } // ---- specialized views ---- function UsersView() { return ( ( )} /> ); } // Fields for the dedicated user edit page (parity with the Filament form: // User Information, Verification, and read-only Usage Statistics). const USER_EDIT_FIELDS: Field[] = [ { name: "name", label: "Name" }, { name: "email", label: "Email" }, { name: "phone_number", label: "Phone number (local, e.g. 01xxxxxxxxx)" }, { name: "password", label: "New password (leave both blank to keep unchanged)", type: "password-confirm" }, { name: "company_id", label: "Company", type: "select", source: "companies" }, { name: "location", label: "Location" }, { name: "phone_verified", label: "Phone verified", type: "checkbox" }, { name: "email_verified", label: "Email verified", type: "checkbox" }, { name: "reservation_count", label: "Reservation count", type: "readonly" }, { name: "sign_in_count", label: "Sign-in count", type: "readonly" }, { name: "swap_count", label: "Swap count", type: "readonly" }, { name: "total_kilometers", label: "Total kilometers", type: "readonly" }, { name: "last_successful_login", label: "Last successful login", type: "readonly" }, { name: "last_verified_phone", label: "Last verified phone", type: "readonly" }, { name: "last_verified_email", label: "Last verified email", type: "readonly" }, ]; // Related-record tables shown under the user edit form (Filament relation // managers: Reservations, Swap Operations, Logs). interface RelatedTab { key: string; label: string; endpoint: string; columns: Column[]; } const USER_RELATED_TABS: RelatedTab[] = [ { key: "reservations", label: "Reservations", endpoint: "reservations", columns: [ { key: "station_name", label: "Station" }, { key: "slot_number", label: "Slot" }, { key: "timeout", label: "Timeout (s)" }, { key: "expires_at", label: "Expires At" }, { key: "ended_at", label: "Ended At" }, { key: "created_at", label: "Created At" }, ], }, { key: "swaps", label: "Swap Operations", endpoint: "swap-operations", columns: [ { key: "id", label: "ID" }, { key: "swap_time", label: "Swap Time" }, { key: "swap_finish_time", label: "Finish Time" }, { key: "old_battery_percentage", label: "Old %" }, { key: "current_battery_percentage", label: "New %" }, { key: "successful", label: "Successful", badge: "check" }, ], }, { key: "logs", label: "Logs", endpoint: "logs", columns: [ { key: "created_at", label: "Time" }, { key: "type", label: "Event", badge: "status" }, { key: "ip", label: "IP" }, { key: "device_name", label: "Device" }, { key: "user_agent", label: "User Agent" }, ], }, ]; function UserRelated(props: { userId: string | number }) { const [active, setActive] = useState(USER_RELATED_TABS[0].key); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const tab = USER_RELATED_TABS.find((t) => t.key === active) ?? USER_RELATED_TABS[0]; useEffect(() => { setLoading(true); apiRequest(`/api/admin/users/${props.userId}/${tab.endpoint}`) .then((b: any) => setRows(b.data ?? [])) .catch(() => setRows([])) .finally(() => setLoading(false)); }, [props.userId, active]); const cell = (c: Column, row: any) => { if (c.badge === "check") { const yes = row[c.key] === true || Number(row[c.key]) === 1; return {yes ? "✓" : "✗"}; } if (c.badge === "status" && row[c.key] != null) return {String(row[c.key])}; return formatCell(row[c.key]); }; return (
{USER_RELATED_TABS.map((t) => ( ))}
{loading ? (

Loading…

) : rows.length === 0 ? (

No {tab.label.toLowerCase()}.

) : ( {tab.columns.map((c) => ( ))} {rows.map((row, i) => ( {tab.columns.map((c) => ( ))} ))}
{c.label}
{cell(c, row)}
)}
); } // UserEditPage is the dedicated edit route (/admin/users/{id}/edit) — a full // page with the form and related-record tables, mirroring the Filament Edit // User page rather than a popup. function UserEditPage(props: { userId: string }) { const [user, setUser] = useState(null); const [error, setError] = useState(""); useEffect(() => { setError(""); setUser(null); apiRequest(`/api/admin/users/${props.userId}`) .then((b: any) => setUser(b.data)) .catch((e: any) => setError(e.message ?? "Failed to load user")); }, [props.userId]); const back = () => navigate(`${BASE}/users`); if (error) return (

{error}

); if (!user) return
Loading…
; return ( <>

Edit user #{user.id}

{ await patchApi(`/api/admin/users/${user.id}`, v); back(); }} />
); } // StationsView wraps the Stations ResourceView to add an in-app confirm // modal for the fleet-wide force-check action (a native window.confirm() // looks out of place — browser chrome, not the app — for something this // consequential). // StationSlotsModal shows live per-slot telemetry (voltage, charging, door // state, battery percentage) from the station's periodic status heartbeat — // no swap required, so it also covers idle, already-seated batteries. function StationSlotsModal(props: { station: any; onClose: () => void }) { const [slots, setSlots] = useState(null); const [error, setError] = useState(""); useEffect(() => { apiRequest(`/api/admin/stations/${props.station.id}/slots`) .then((b: any) => setSlots(b.data ?? [])) .catch((e: any) => setError(e.message ?? "Load failed")); }, [props.station.id]); return ( {error ? (

{error}

) : slots === null ? (

Loading…

) : slots.length === 0 ? (

No slots reported yet.

) : (
{props.station.empty_slot == null && (

Empty slot is not set. Set it under Edit — stations with hard-wired chargers can't find it themselves.

)} {slots.map((slot) => { const hasReading = slot.voltage != null; const charging = slot.charging === true; const doorOpen = slot.door_open === true; const isEmptySlot = props.station.empty_slot != null && slot.slot_number === props.station.empty_slot; return ( ); })}
Slot Battery Charging Door Voltage Current Last Reported
{slot.slot_number} {isEmptySlot && ( <> {" "} empty )} {hasReading ? `${slot.percentage ?? 0}%` : —} {charging ? "✓" : "✗"} {doorOpen ? "open" : "closed"} {hasReading ? `${slot.voltage}V` : —} {slot.current != null ? `${slot.current}A` : —} {formatCell(slot.last_reported_at)}
)}
); } function DiagnosticsKeyModal(props: { station: any; onClose: () => void }) { const base = `/api/admin/stations/${props.station.id}/diagnostics-key`; const [key, setKey] = useState(""); const [shown, setShown] = useState(false); const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const reveal = async () => { setBusy(true); setError(""); try { const res = await apiRequest(base); setKey(res.key); setShown(true); } catch (e: any) { setError(e.message ?? "Could not load the key."); } finally { setBusy(false); } }; const copy = async () => { try { await navigator.clipboard.writeText(key); toast("Key copied.", "success"); } catch { toast("Copy failed — select the key and copy it manually.", "error"); } }; const regenerate = async () => { setError(""); setBusy(true); try { const res = await postApi(`${base}/regenerate`, {}); setKey(res.key); setShown(true); setConfirming(false); toast("New key generated. The station picks it up on its next config sync.", "success"); } catch (e: any) { toast(e.message ?? "Could not regenerate the key.", "error"); } finally { setBusy(false); } }; return ( {confirming ? ( <>

Regenerating invalidates the current key. Technicians will be refused by this station until it syncs its config (usually within a few minutes).

) : ( <>
{shown ? key : "••••••••••••••••••••••••••••••••••••••••••••"}
{error}
{!shown ? ( ) : ( )}
)}
); } function StationsView() { const [confirmingForceAll, setConfirmingForceAll] = useState(false); const [reloadFn, setReloadFn] = useState<(() => void) | null>(null); const [busy, setBusy] = useState(false); const [viewingSlots, setViewingSlots] = useState(null); const [keyStation, setKeyStation] = useState(null); return ( <> ( )} extraRowActions={(row, reload) => ( <> )} /> {viewingSlots && setViewingSlots(null)} />} {keyStation && setKeyStation(null)} />} {confirmingForceAll && ( setConfirmingForceAll(false)}>

Every station will check for a firmware update on its next config poll.

)} ); } function SwapsView() { const [assigning, setAssigning] = useState(null); const [reloadFn, setReloadFn] = useState<(() => void) | null>(null); return ( <> row.failure_category === "hardware_error" && !row.battery_dispensed_at ? ( ) : null } /> {assigning && ( setAssigning(null)}> { await postApi(`/api/admin/swap-operations/${assigning.id}/assign-slots`, v); setAssigning(null); reloadFn?.(); }} /> )} ); } function FirmwareUpload(props: { onDone: () => void }) { const [version, setVersion] = useState(""); const [title, setTitle] = useState(""); const [stationID, setStationID] = useState(""); const [isActive, setIsActive] = useState(true); const [file, setFile] = useState(null); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const submit = async () => { if (!version || !file) return setError("Version and a .bin file are required."); const form = new FormData(); form.set("version", version); form.set("title", title); form.set("is_active", isActive ? "true" : "false"); if (stationID) form.set("station_id", stationID); form.set("firmware", file); setBusy(true); setError(""); try { await uploadApi("/api/admin/firmware", form); props.onDone(); } catch (e: any) { setError(e.message ?? "Upload failed"); } finally { setBusy(false); } }; return (
setVersion(e.target.value)} />
setTitle(e.target.value)} />
setStationID(e.target.value)} />
setIsActive(e.target.checked)} />
setFile(e.target.files?.[0] ?? null)} />
{error}
); } function FirmwareView() { const [uploading, setUploading] = useState(false); const [reloadFn, setReloadFn] = useState<(() => void) | null>(null); return ( <> ( )} extraRowActions={(row, reload) => ( <> )} /> {uploading && ( setUploading(false)}> { setUploading(false); reloadFn?.(); }} /> )} ); } // FirmwareEditPage is the dedicated edit route (/admin/firmware/{id}/edit), // mirroring the Filament "Edit Firmware Update" page: Release Information, // Firmware Binary (with optional replacement), and Targeting & Rollout. function FirmwareEditPage(props: { firmwareId: string }) { const stationOptions = useFilterOptions("stations"); const [fw, setFw] = useState(null); const [form, setForm] = useState>({}); const [file, setFile] = useState(null); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); useEffect(() => { setError(""); apiRequest(`/api/admin/firmware/${props.firmwareId}`) .then((b: any) => { const row = b.data; setFw(row); setForm({ version: row.version ?? "", title: row.title ?? "", notes: row.notes ?? "", checksum: row.checksum ?? "", station_id: row.station_id != null ? String(row.station_id) : "", is_active: Number(row.is_active) === 1, // MySQL "YYYY-MM-DD HH:MM:SS" -> datetime-local "YYYY-MM-DDTHH:MM". rolled_out_at: row.rolled_out_at ? String(row.rolled_out_at).replace(" ", "T").slice(0, 16) : "", }); }) .catch((e: any) => setError(e.message ?? "Failed to load firmware")); }, [props.firmwareId]); const back = () => navigate(`${BASE}/firmware`); const setField = (k: string, v: any) => setForm((f) => ({ ...f, [k]: v })); const save = async () => { if (!form.version) return setError("Version is required."); setBusy(true); setError(""); try { await patchApi(`/api/admin/firmware/${props.firmwareId}`, { version: form.version, title: form.title, notes: form.notes, checksum: form.checksum, station_id: form.station_id === "" ? null : Number(form.station_id), is_active: Boolean(form.is_active), rolled_out_at: form.rolled_out_at === "" ? null : form.rolled_out_at, }); if (file) { const fd = new FormData(); fd.set("firmware", file); await uploadApi(`/api/admin/firmware/${props.firmwareId}/file`, fd); } back(); } catch (e: any) { setError(e.message ?? "Save failed"); } finally { setBusy(false); } }; if (error && !fw) return (

{error}

); if (!fw) return
Loading…
; return ( <>

Edit firmware #{fw.id}

Release Information

setField("version", e.target.value)} />
setField("title", e.target.value)} />