From 16f6146834f829c85f90b6dc9aea1fcf969df576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 27 Mar 2026 07:28:44 +0100 Subject: [PATCH] ux fixes --- frontend/src/App.tsx | 32 +- frontend/src/App.tsx.bak_mobilefix | 1038 ---------------------- frontend/src/components/common/Icons.tsx | 3 + frontend/src/index.css | 50 ++ frontend/src/index.css.bak_mobilefix | 631 ------------- 5 files changed, 80 insertions(+), 1674 deletions(-) delete mode 100644 frontend/src/App.tsx.bak_mobilefix delete mode 100644 frontend/src/index.css.bak_mobilefix diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 86283da..34cce7b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import { IconSettings, IconSun, IconTemperature, + IconUser, IconX, } from "./components/common/Icons"; import { api } from "./api/client"; @@ -576,6 +577,7 @@ export default function App() { const dataEnabled = authenticated || authEnabled === false; const currentRole = publicMode ? null : (authQuery.data?.role ?? null); + const currentUserName = publicMode ? null : (authQuery.data?.display_name ?? authQuery.data?.user ?? null); const isAdmin = authEnabled === false || currentRole === "admin"; const hasWarehouseAccess = !publicMode && isAdmin; const hasSettingsAccess = !publicMode && isAdmin; @@ -770,12 +772,10 @@ export default function App() {
{config.app.site_name}
{t(language, "operatorPanel")}
- {connected ? t(language, "connected") : t(language, "disconnected")} - {!publicMode && currentRole ? {currentRole} : null} - {!publicMode ? : null} - {!publicMode ? : null} + {!publicMode ? : null} + {!publicMode ? : null}
@@ -793,6 +793,28 @@ export default function App() { ?
: null; + const footer = !publicMode ? ( + + ) : null; + const menu = (
@@ -859,7 +881,7 @@ export default function App() { {activeTab === "kiosk" && <>
applyKioskDraftChange(kioskEditorMode, value)} onModeChange={setKioskEditorMode} selectedMode={kioskEditorMode} labels={widgetLabels} buckets={config.capabilities.buckets} compareModes={config.capabilities.comparison_modes} saving={saveKioskSettingsMutation.isPending && saveKioskSettingsMutation.variables?.mode === kioskEditorMode} dirty={currentKioskDirty} canSave={canPersistCurrentKioskSettings} saveNotice={kioskSaveNotice[kioskEditorMode]} onSave={saveCurrentKioskSettings} onReset={() => resetKioskDraft(kioskEditorMode)} allowPublicMode={isAdmin} chartItems={chartMetricCandidates} heroItems={blockMetricCandidates} />
} {activeTab === "settings" && <>
item.metric_id !== "energy_total")} selected={liveMetrics.filter((item) => item !== "energy_total")} onChange={setLiveMetrics} />
diagnosticsQuery.refetch()} />
{isAdmin ?
createUserMutation.mutate()} passwordReset={passwordReset} onPasswordResetChange={setPasswordReset} onResetPassword={() => resetPasswordMutation.mutate()} onRoleChange={(username, role) => updateUserRoleMutation.mutate({ username, role })} roleUpdating={updateUserRoleMutation.isPending} />
: null}
} -
+ {footer} ); } diff --git a/frontend/src/App.tsx.bak_mobilefix b/frontend/src/App.tsx.bak_mobilefix deleted file mode 100644 index 906ea6e..0000000 --- a/frontend/src/App.tsx.bak_mobilefix +++ /dev/null @@ -1,1038 +0,0 @@ -import { useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { EChartsOption } from "echarts"; -import { - IconArrowsMove, - IconBolt, - IconChartBar, - IconChecklist, - IconClockHour4, - IconDatabaseImport, - IconDeviceDesktop, - IconHistory, - IconLanguage, - IconLayoutDashboard, - IconLogin2, - IconLogout, - IconMoon, - IconPlayerPlay, - IconRefresh, - IconSettings, - IconSun, - IconTemperature, - IconX, -} from "./components/common/Icons"; -import { api } from "./api/client"; -import { EChart } from "./components/common/EChart"; -import { labelForMetric, localeForLanguage, normalizeLanguage, t, translateCompareMode, type Language } from "./i18n"; -import { useAnalytics, useDashboardConfig, useHistoricalImport, useRealtimeHistory, useRealtimeSocket } from "./hooks"; -import { formatDateTime, formatDurationShort, formatPercent, formatShortTime, formatValue } from "./lib/format"; -import type { - AnalyticsPayload, - AuthStatus, - AuthUsersPayload, - BucketPoint, - DashboardConfig, - DiagnosticsPayload, - DistributionPayload, - KioskChartGroup, - HistoryPayload, - HistoricalStatus, - KioskSettingsPayload, - MetricValue, - SnapshotGroupRow, - SnapshotPayload, -} from "./types"; - -type ThemeMode = "light" | "dark"; -type TabKey = "realtime" | "archive" | "analytics" | "warehouse" | "kiosk" | "settings"; -type ViewMode = "normal" | "kiosk"; -type WidgetId = "hero" | "quickMetrics" | "history" | "status" | "strings" | "production" | "comparison" | "distribution" | "importStatus"; -type LiveWidgetId = "hero" | "quickMetrics" | "history" | "status" | "strings"; -type BlockTarget = "hero" | "quick"; - -const STORAGE_KEYS = { - theme: "pv-theme-v4", - language: "pv-language-v4", - kioskWidgets: "pv-kiosk-widgets-v4", - viewMode: "pv-view-mode-v4", - blockConfig: "pv-block-config-v4", - liveWidgets: "pv-live-widgets-v4", - liveMetrics: "pv-live-metrics-v4", - archiveMetrics: "pv-archive-metrics-v4", - liveAutoRefresh: "pv-live-autorefresh-v1", -}; - -const DEFAULT_KIOSK_WIDGETS: WidgetId[] = ["hero", "history", "strings", "status", "production", "comparison", "importStatus"]; -const DEFAULT_LIVE_WIDGETS: LiveWidgetId[] = ["hero", "quickMetrics", "history", "status", "strings"]; -const DEFAULT_BLOCK_CONFIG: Record = { - hero: ["ac_power", "dc_power_total", "energy_today", "energy_total"], - quick: ["energy_today", "energy_yesterday", "energy_total", "dc_power_total", "today_vs_yesterday"], -}; -const DEFAULT_LIVE_METRICS = ["ac_power", "string_1_power", "string_2_power"]; -const DEFAULT_KIOSK_HERO_METRICS = ["ac_power", "dc_power_total", "energy_today", "energy_total"]; -const DEFAULT_KIOSK_CHART_GROUPS: KioskChartGroup[] = [{ id: "overview", title: null, metric_ids: ["ac_power", "dc_power_total", "inverter_temp"] }]; -function getKioskRouteMode(): "public" | "private" | null { - const pathname = window.location.pathname.replace(/\/+$/, "") || "/"; - if (pathname.endsWith("/kiosk/public")) return "public"; - if (pathname.endsWith("/kiosk/private")) return "private"; - const url = new URL(window.location.href); - if (url.searchParams.get("publicKiosk") === "1") return "public"; - if (url.searchParams.get("privateKiosk") === "1") return "private"; - return null; -} -const KIOSK_ROUTE_MODE = getKioskRouteMode(); -const PUBLIC_KIOSK = KIOSK_ROUTE_MODE === "public"; -const PRIVATE_KIOSK_ROUTE = KIOSK_ROUTE_MODE === "private"; - -const widgetOrder: Array<{ id: WidgetId; tab: TabKey; icon: typeof IconLayoutDashboard }> = [ - { id: "hero", tab: "realtime", icon: IconLayoutDashboard }, - { id: "quickMetrics", tab: "realtime", icon: IconChecklist }, - { id: "history", tab: "realtime", icon: IconHistory }, - { id: "status", tab: "realtime", icon: IconBolt }, - { id: "strings", tab: "realtime", icon: IconArrowsMove }, - { id: "production", tab: "analytics", icon: IconChartBar }, - { id: "comparison", tab: "analytics", icon: IconRefresh }, - { id: "importStatus", tab: "warehouse", icon: IconDatabaseImport }, -]; - -function readStorage(key: string, fallback: T, parser?: (raw: string) => T): T { - try { - const raw = window.localStorage.getItem(key); - if (!raw) return fallback; - return parser ? parser(raw) : (JSON.parse(raw) as T); - } catch { - return fallback; - } -} -function writeStorage(key: string, value: T): void { - try { window.localStorage.setItem(key, typeof value === "string" ? value : JSON.stringify(value)); } catch {} -} -function parseViewModeFromLocation(): ViewMode { - if (KIOSK_ROUTE_MODE) return "kiosk"; - const url = new URL(window.location.href); - return url.searchParams.get("mode") === "kiosk" ? "kiosk" : "normal"; -} -function syncViewModeToLocation(mode: ViewMode): void { - if (KIOSK_ROUTE_MODE) return; - const url = new URL(window.location.href); - if (mode === "kiosk") url.searchParams.set("mode", "kiosk"); else url.searchParams.delete("mode"); - window.history.replaceState({}, "", url.toString()); -} -function iconForMetric(metricId: string) { - if (metricId.includes("temp")) return ; - if (metricId.includes("energy")) return ; - return ; -} -function buildWidgetLabel(language: Language, widgetId: WidgetId): string { - const labels: Record = { - hero: language === "en" ? "Hero metrics" : "Karty hero", - quickMetrics: t(language, "quickMetrics"), - history: t(language, "kioskCharts"), - status: t(language, "systemStatus"), - strings: t(language, "strings"), - production: t(language, "chartProduction"), - comparison: t(language, "chartComparison"), - distribution: t(language, "chartDistribution"), - importStatus: language === "en" ? "Data warehouse" : "Hurtownia danych", - }; - return labels[widgetId]; -} -function buildTablerChartTheme(theme: ThemeMode) { - return theme === "dark" - ? { text: "#cbd5e1", grid: "rgba(255,255,255,0.08)", tooltip: "rgba(15, 23, 42, 0.96)", series: ["#4dabf7", "#20c997", "#f59f00", "#e64980", "#9775fa", "#ff922b", "#66d9e8", "#adb5bd", "#94d82d", "#ffa8a8"] } - : { text: "#334155", grid: "rgba(15,23,42,0.12)", tooltip: "rgba(255,255,255,0.98)", series: ["#206bc4", "#2fb344", "#f59f00", "#d63384", "#7950f2", "#fd7e14", "#1098ad", "#868e96", "#74b816", "#fa5252"] }; -} -function formatChartNumber(value: number | string | null | undefined, locale: string): string { - if (value === null || value === undefined || value === "" || Number.isNaN(Number(value))) return "--"; - return Number(value).toLocaleString(locale, { minimumFractionDigits: 0, maximumFractionDigits: 2 }); -} -function buildUnitLabel(units: Array): string { - const unique = units.filter((item): item is string => Boolean(item)).filter((item, index, list) => list.indexOf(item) === index); - return unique.join(" / "); -} -function buildDefaultKioskChartGroups(): KioskChartGroup[] { - return DEFAULT_KIOSK_CHART_GROUPS.map((group) => ({ ...group, metric_ids: [...group.metric_ids] })); -} -function sanitizeKioskHeroMetrics(metricIds: string[] | undefined, items?: Array<{ metric_id: string; label: string; unit: string }>): string[] { - const allowed = items?.length ? new Set(items.map((item) => item.metric_id)) : null; - const normalized = Array.from(new Set((metricIds ?? []).map((metricId) => String(metricId || "").trim()).filter(Boolean))).filter((metricId) => !allowed || allowed.has(metricId)); - if (normalized.length) return normalized.slice(0, 8); - if (items?.length) { - const fallback = DEFAULT_KIOSK_HERO_METRICS.filter((metricId) => allowed?.has(metricId)); - return (fallback.length ? fallback : items.slice(0, 4).map((item) => item.metric_id)).slice(0, 8); - } - return [...DEFAULT_KIOSK_HERO_METRICS]; -} -function sanitizeKioskChartGroups(groups: KioskChartGroup[] | undefined, items?: Array<{ metric_id: string; label: string; unit: string }>): KioskChartGroup[] { - const allowed = items?.length ? new Set(items.map((item) => item.metric_id)) : null; - const normalized = (groups ?? []).map((group, index) => ({ - id: String(group?.id || `chart_${index + 1}`), - title: (group?.title ?? "") || null, - metric_ids: Array.from(new Set((group?.metric_ids ?? []).map((metricId) => String(metricId || "").trim()).filter(Boolean))).filter((metricId) => !allowed || allowed.has(metricId)), - })).filter((group) => group.metric_ids.length); - if (normalized.length) return normalized.slice(0, 8); - if (items?.length) { - const preferred = ["ac_power", "dc_power_total", "inverter_temp"].filter((metricId) => allowed?.has(metricId)); - const fallback = preferred.length ? preferred : items.slice(0, 3).map((item) => item.metric_id); - return [{ id: "overview", title: null, metric_ids: fallback }]; - } - return buildDefaultKioskChartGroups(); -} -function buildChartGroupAutoTitle(group: KioskChartGroup, items: Array<{ metric_id: string; label: string; unit: string }>, language: Language): string { - if (group.title?.trim()) return group.title.trim(); - const labels = group.metric_ids.map((metricId) => items.find((item) => item.metric_id === metricId)?.label).filter(Boolean) as string[]; - if (labels.length === 1) return labels[0]; - if (labels.length >= 2 && labels.length <= 3) return labels.join(" / "); - return language === "en" ? `Chart ${group.id}` : `Wykres ${group.id}`; -} -function classifyChartMetric(item: { metric_id: string; label: string; unit: string }): "temp" | "ac" | "dc" | "other" { - const metricId = item.metric_id.toLowerCase(); - const label = item.label.toLowerCase(); - const unit = item.unit.toLowerCase(); - if (metricId.includes("temp") || label.includes("temp") || unit.includes("°") || unit.includes("c")) return "temp"; - if (metricId.includes("ac") || label.includes(" ac") || label.startsWith("ac") || label.includes("falownik")) return "ac"; - if (metricId.includes("dc") || metricId.includes("string_") || label.includes("dc") || label.includes("string")) return "dc"; - return "other"; -} -function buildPresetKioskChartGroups(language: Language, items: Array<{ metric_id: string; label: string; unit: string }>, preset: "single" | "split"): KioskChartGroup[] { - if (!items.length) return buildDefaultKioskChartGroups(); - if (preset === "single") return [{ id: "overview", title: null, metric_ids: items.map((item) => item.metric_id).slice(0, 12) }]; - const grouped: Record<"ac" | "dc" | "temp" | "other", Array<{ metric_id: string; label: string; unit: string }>> = { ac: [], dc: [], temp: [], other: [] }; - items.forEach((item) => grouped[classifyChartMetric(item)].push(item)); - const charts: KioskChartGroup[] = []; - if (grouped.ac.length) charts.push({ id: "ac", title: "AC", metric_ids: grouped.ac.map((item) => item.metric_id).slice(0, 12) }); - if (grouped.dc.length) charts.push({ id: "dc", title: "DC", metric_ids: grouped.dc.map((item) => item.metric_id).slice(0, 12) }); - if (grouped.temp.length) charts.push({ id: "temp", title: language === "en" ? "Temperature" : "Temperatura", metric_ids: grouped.temp.map((item) => item.metric_id).slice(0, 12) }); - if (grouped.other.length) charts.push({ id: "other", title: language === "en" ? "Other" : "Inne", metric_ids: grouped.other.map((item) => item.metric_id).slice(0, 12) }); - return charts.length ? charts : [{ id: "overview", title: null, metric_ids: items.map((item) => item.metric_id).slice(0, 12) }]; -} -function filterHistoryByChartGroup(history: HistoryPayload | undefined, group: KioskChartGroup): HistoryPayload | undefined { - return filterHistoryByMetrics(history, group.metric_ids); -} -function buildLiveHistoryOption(history: HistoryPayload | undefined, theme: ThemeMode, language: Language): EChartsOption { - const palette = buildTablerChartTheme(theme); - const series = history?.series ?? []; - const locale = localeForLanguage(language); - const units = series - .map((item) => item.unit || "") - .filter((item, index, list) => list.indexOf(item) === index); - const yAxes = units.map((unit, index) => { - const position: "left" | "right" = index % 2 === 0 ? "left" : "right"; - return { - type: "value" as const, - name: unit, - position, - offset: index > 1 ? Math.floor((index - 1) / 2) * 56 : 0, - nameTextStyle: { color: palette.text }, - axisLabel: { color: palette.text, formatter: (value: number) => formatChartNumber(value, locale) }, - splitLine: index === 0 ? { lineStyle: { color: palette.grid } } : { show: false }, - axisLine: { lineStyle: { color: palette.grid } }, - }; - }); - return { - animation: false, - color: palette.series, - tooltip: { - trigger: "axis", - backgroundColor: palette.tooltip, - borderColor: palette.grid, - textStyle: { color: palette.text }, - formatter: (params: any) => { - const rows = Array.isArray(params) ? params : [params]; - const header = rows[0]?.axisValueLabel ?? ""; - return [header, ...rows.map((row) => `${row.marker ?? ""} ${row.seriesName}: ${formatChartNumber(row.value, locale)}`)].join("
"); - }, - }, - legend: { type: "scroll", top: 0, textStyle: { color: palette.text }, itemGap: 16 }, - grid: { left: 12, right: yAxes.length > 1 ? 52 : 16, top: 48, bottom: 72, containLabel: true }, - xAxis: { - type: "category", - boundaryGap: false, - axisLabel: { color: palette.text }, - axisLine: { lineStyle: { color: palette.grid } }, - data: (series[0]?.points ?? []).map((point: any) => formatShortTime(point.timestamp, locale)), - }, - yAxis: yAxes.length - ? yAxes - : [{ - type: "value" as const, - name: buildUnitLabel(series.map((item) => item.unit)), - nameTextStyle: { color: palette.text }, - axisLabel: { color: palette.text, formatter: (value: number) => formatChartNumber(value, locale) }, - splitLine: { lineStyle: { color: palette.grid } }, - axisLine: { lineStyle: { color: palette.grid } }, - }], - dataZoom: [ - { type: "inside", zoomOnMouseWheel: true, moveOnMouseMove: true, moveOnMouseWheel: true, preventDefaultMouseMove: true }, - { type: "slider", height: 28, bottom: 18, borderColor: palette.grid, fillerColor: "rgba(32, 107, 196, 0.16)", handleSize: "80%" }, - ], - series: series.map((item, index) => ({ - name: item.unit ? `${item.label} [${item.unit}]` : item.label, - type: "line", - smooth: true, - connectNulls: true, - showSymbol: false, - sampling: "lttb", - yAxisIndex: Math.max(units.indexOf(item.unit || ""), 0), - lineStyle: { width: index === 0 ? 3 : 2 }, - emphasis: { focus: "series" }, - data: item.points.map((point: any) => point.value), - })), - }; -} -function buildBarOption(points: BucketPoint[], unit: string, theme: ThemeMode, language: Language): EChartsOption { - const palette = buildTablerChartTheme(theme); - const locale = localeForLanguage(language); - return { - color: [palette.series[0]], - tooltip: { trigger: "axis", backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text }, valueFormatter: (value) => value == null ? "--" : formatValue(Number(value), unit, 2, locale) }, - grid: { left: 12, right: 16, top: 16, bottom: 40, containLabel: true }, - xAxis: { type: "category", axisLabel: { color: palette.text, rotate: points.length > 12 ? 32 : 0 }, axisLine: { lineStyle: { color: palette.grid } }, data: points.map((point) => point.label) }, - yAxis: { type: "value", name: unit, nameTextStyle: { color: palette.text }, axisLabel: { color: palette.text, formatter: (value: number) => formatChartNumber(value, locale) }, splitLine: { lineStyle: { color: palette.grid } } }, - series: [{ type: "bar", barMaxWidth: 24, itemStyle: { borderRadius: [6, 6, 0, 0] }, data: points.map((point) => point.value) }], - }; -} -function buildComparisonOption(data: AnalyticsPayload | undefined, theme: ThemeMode, language: Language, comparisonDisplayMode: "line" | "bar"): EChartsOption { - const palette = buildTablerChartTheme(theme); - const current = data?.current ?? []; - const locale = localeForLanguage(language); - const unit = data?.unit ?? ""; - const comparisonSeries = (data?.comparisons?.length ? data.comparisons : [{ key: data?.compare_mode ?? "comparison", label: t(language, "comparisonPeriod"), points: data?.comparison ?? [] }]) - .filter((item) => item.points?.length) - .map((item) => ({ ...item, label: translateCompareMode(language, item.label || item.key) })); - const verticalLegend = comparisonSeries.length > 2; - return { - color: palette.series, - tooltip: { - trigger: "axis", - backgroundColor: palette.tooltip, - borderColor: palette.grid, - textStyle: { color: palette.text }, - formatter: (params: any) => { - const rows = Array.isArray(params) ? params : [params]; - const header = rows[0]?.axisValueLabel ?? ""; - return [header, ...rows.map((row) => `${row.marker ?? ""} ${row.seriesName}: ${row.value == null ? "--" : formatValue(Number(row.value), unit, 2, locale)}`)].join("
"); - }, - }, - legend: verticalLegend - ? { type: "scroll", orient: "vertical", top: 12, right: 0, bottom: 12, textStyle: { color: palette.text }, pageTextStyle: { color: palette.text } } - : { type: "scroll", top: 0, left: 0, right: 0, textStyle: { color: palette.text }, pageTextStyle: { color: palette.text } }, - grid: { left: 16, right: verticalLegend ? 180 : 20, top: verticalLegend ? 20 : 48, bottom: 18, containLabel: true }, - xAxis: { type: "category", axisLabel: { color: palette.text, interval: 0, rotate: current.length > 12 ? 35 : 0 }, axisLine: { lineStyle: { color: palette.grid } }, data: current.map((point) => point.label) }, - yAxis: { type: "value", name: unit, nameTextStyle: { color: palette.text }, axisLabel: { color: palette.text, formatter: (value: number) => formatChartNumber(value, locale) }, splitLine: { lineStyle: { color: palette.grid } } }, - series: [ - { name: unit ? `${t(language, "currentPeriod")} [${unit}]` : t(language, "currentPeriod"), type: "bar", barMaxWidth: comparisonDisplayMode === "bar" ? 18 : 22, emphasis: { focus: "series" }, data: current.map((point) => point.value) }, - ...comparisonSeries.map((seriesItem) => comparisonDisplayMode === "bar" - ? { name: unit ? `${seriesItem.label} [${unit}]` : seriesItem.label, type: "bar" as const, barMaxWidth: 18, emphasis: { focus: "series" as const }, data: current.map((_, pointIndex) => seriesItem.points[pointIndex]?.value ?? null) } - : { name: unit ? `${seriesItem.label} [${unit}]` : seriesItem.label, type: "line" as const, smooth: true, showSymbol: false, emphasis: { focus: "series" as const }, data: current.map((_, pointIndex) => seriesItem.points[pointIndex]?.value ?? null) }), - ], - }; -} - -function buildPieOption(data: DistributionPayload | undefined, theme: ThemeMode, language: Language): EChartsOption { - const palette = buildTablerChartTheme(theme); - const locale = localeForLanguage(language); - const unit = data?.unit ?? ""; - const slices = [...(data?.slices ?? [])].sort((a, b) => b.value - a.value).slice(0, 12); - return { - color: palette.series, - tooltip: { - trigger: "axis", - axisPointer: { type: "shadow" }, - backgroundColor: palette.tooltip, - borderColor: palette.grid, - textStyle: { color: palette.text }, - formatter: (params: any) => { - const row = Array.isArray(params) ? params[0] : params; - const index = row?.dataIndex ?? 0; - const slice = slices[index]; - if (!slice) return ""; - return `${row.name}
${formatValue(slice.value, unit, 2, locale)} · ${formatChartNumber(slice.share, locale)}%`; - }, - }, - grid: { left: 16, right: 28, top: 8, bottom: 8, containLabel: true }, - xAxis: { type: "value", name: unit, nameTextStyle: { color: palette.text }, axisLabel: { color: palette.text, formatter: (value: number) => formatChartNumber(value, locale) }, splitLine: { lineStyle: { color: palette.grid } } }, - yAxis: { type: "category", axisLabel: { color: palette.text }, data: slices.map((item) => item.label) }, - series: [{ type: "bar", data: slices.map((item) => ({ value: item.value, label: { show: true, position: "right", formatter: `${formatChartNumber(item.value, locale)} ${unit} · ${formatChartNumber(item.share, locale)}%`, color: palette.text } })), barMaxWidth: 22, itemStyle: { borderRadius: [0, 6, 6, 0] } }], - }; -} - -function liveRangeOptions(language: Language) { - return [ - { key: "today", label: language === "en" ? "Today" : "Dziś" }, - { key: "yesterday", label: language === "en" ? "Yesterday" : "Wczoraj" }, - { key: "6h", label: "6h" }, - { key: "12h", label: "12h" }, - { key: "24h", label: "24h" }, - { key: "48h", label: "48h" }, - { key: "7d", label: "7d" }, - ]; -} -function analyticsRangeOptions(language: Language) { - return [ - { key: "today", label: language === "en" ? "Today" : "Dziś" }, - { key: "yesterday", label: language === "en" ? "Yesterday" : "Wczoraj" }, - { key: "7d", label: "7d" }, - { key: "30d", label: "30d" }, - { key: "90d", label: "90d" }, - { key: "365d", label: language === "en" ? "365 days" : "365 dni" }, - ]; -} -function archiveQuickRangeOptions(language: Language) { - return [ - { key: "today", label: language === "en" ? "Today" : "Dziś" }, - { key: "yesterday", label: language === "en" ? "Yesterday" : "Wczoraj" }, - { key: "day_before_yesterday", label: language === "en" ? "2 days ago" : "Przedwczoraj" }, - ]; -} -function archiveListRangeOptions(language: Language) { - return [ - { key: "3d", label: language === "en" ? "3 days" : "3 dni" }, - { key: "7d", label: language === "en" ? "7 days" : "7 dni" }, - { key: "14d", label: language === "en" ? "14 days" : "14 dni" }, - { key: "30d", label: language === "en" ? "30 days" : "30 dni" }, - { key: "60d", label: language === "en" ? "60 days" : "60 dni" }, - ]; -} -const SINGLE_DAY_ARCHIVE_KEYS = new Set(["today", "yesterday", "day_before_yesterday"]); -const ONE_HOUR_MS = 60 * 60 * 1000; - -function toDateTimeLocalValue(value: Date): string { - const year = value.getFullYear(); - const month = String(value.getMonth() + 1).padStart(2, "0"); - const day = String(value.getDate()).padStart(2, "0"); - const hours = String(value.getHours()).padStart(2, "0"); - const minutes = String(value.getMinutes()).padStart(2, "0"); - return `${year}-${month}-${day}T${hours}:${minutes}`; -} -function startOfOffsetDay(daysOffset: number): Date { - const value = new Date(); - value.setHours(0, 0, 0, 0); - value.setDate(value.getDate() + daysOffset); - return value; -} -function archivePresetWindow(rangeKey: string): { start: string; end: string } | null { - if (rangeKey === "today") return { start: toDateTimeLocalValue(startOfOffsetDay(0)), end: toDateTimeLocalValue(new Date()) }; - if (rangeKey === "yesterday") return { start: toDateTimeLocalValue(startOfOffsetDay(-1)), end: toDateTimeLocalValue(startOfOffsetDay(0)) }; - if (rangeKey === "day_before_yesterday") return { start: toDateTimeLocalValue(startOfOffsetDay(-2)), end: toDateTimeLocalValue(startOfOffsetDay(-1)) }; - return null; -} -function trimSingleDayHistory(history: HistoryPayload | undefined, mode: string): HistoryPayload | undefined { - if (!history || !SINGLE_DAY_ARCHIVE_KEYS.has(mode)) return history; - const timestamps = history.series - .flatMap((series) => series.points) - .filter((point) => point.value !== null) - .map((point) => new Date(point.timestamp).getTime()) - .filter((value) => Number.isFinite(value)); - if (!timestamps.length) return history; - const historyStart = new Date(history.start).getTime(); - const historyEnd = new Date(history.end).getTime(); - const trimmedStart = Math.max(historyStart, Math.min(...timestamps) - ONE_HOUR_MS); - const trimmedEnd = Math.min(historyEnd, Math.max(...timestamps) + ONE_HOUR_MS); - return { - ...history, - start: new Date(trimmedStart).toISOString(), - end: new Date(trimmedEnd).toISOString(), - series: history.series.map((series) => ({ - ...series, - points: series.points.filter((point) => { - const timestamp = new Date(point.timestamp).getTime(); - return timestamp >= trimmedStart && timestamp <= trimmedEnd; - }), - })), - }; -} -function filterHistoryByMetrics(history: HistoryPayload | undefined, metricIds: string[]): HistoryPayload | undefined { - if (!history) return history; - const allowed = new Set(metricIds); - return { - ...history, - series: allowed.size ? history.series.filter((series) => allowed.has(series.metric_id)) : [], - }; -} -function getInitialTheme(config?: DashboardConfig): ThemeMode { - return readStorage(STORAGE_KEYS.theme, (config?.defaults.theme as ThemeMode) ?? "dark", (raw) => (raw === "light" ? "light" : "dark")); -} -function getInitialLanguage(config?: DashboardConfig): Language { - return normalizeLanguage(readStorage(STORAGE_KEYS.language, config?.defaults.language ?? "pl", (raw) => raw)); -} -function getVisibleWidgets(ids: WidgetId[]): WidgetId[] { const base = ids.filter((id, index) => ids.indexOf(id) === index); return base.length > 0 ? base : DEFAULT_KIOSK_WIDGETS; } -function toWidgetIds(ids: string[]): WidgetId[] { return getVisibleWidgets(ids.filter((id): id is WidgetId => widgetOrder.some((item) => item.id === id as WidgetId))); } -function getVisibleLiveWidgets(ids: LiveWidgetId[]): LiveWidgetId[] { - const base = ids.filter((id, index) => ids.indexOf(id) === index); - return base.length ? base : DEFAULT_LIVE_WIDGETS; -} -function toLiveWidgetIds(ids: string[]): LiveWidgetId[] { - return getVisibleLiveWidgets(ids.filter((id): id is LiveWidgetId => DEFAULT_LIVE_WIDGETS.includes(id as LiveWidgetId))); -} -function metricToHeroCard(metric: MetricValue): SnapshotPayload["hero_cards"][number] { - const numeric = typeof metric.value === "number" ? metric.value : Number(metric.value); - const accent = metric.metric_id.includes("temp") ? (Number.isFinite(numeric) && numeric >= 70 ? "rose" : Number.isFinite(numeric) && numeric >= 55 ? "amber" : "emerald") : (metric.status === "warn" ? "amber" : metric.status === "critical" ? "rose" : "emerald"); - return { metric_id: metric.metric_id, label: metric.label, value: metric.value, unit: metric.unit, accent, subtitle: metric.unit || metric.status || "" }; -} -function heroCardToMetric(card: SnapshotPayload["hero_cards"][number]): MetricValue { - const numeric = typeof card.value === "number" ? card.value : Number(card.value); - return { metric_id: card.metric_id, label: card.label, unit: card.unit, value: card.value, precision: Number.isFinite(numeric) && !Number.isInteger(numeric) ? 2 : 0, kind: typeof card.value === "string" ? "text" : "gauge", status: "neutral" }; -} -function getChartMetricCandidates(config?: DashboardConfig) { - const map = new Map(); - (config?.visible_entities ?? []) - .filter((item) => item.kind === "gauge") - .forEach((item) => map.set(item.metric_id, { metric_id: item.metric_id, label: item.label, unit: item.unit })); - return [...map.values()]; -} -function getBlockMetricCandidates(snapshot: SnapshotPayload) { - const map = new Map(); - snapshot.hero_cards.forEach((card) => map.set(card.metric_id, { metric_id: card.metric_id, label: card.label, unit: card.unit })); - Object.values(snapshot.kpis ?? {}).forEach((metric) => map.set(metric.metric_id, { metric_id: metric.metric_id, label: metric.label, unit: metric.unit })); - return [...map.values()]; -} - -export default function App() { - const queryClient = useQueryClient(); - const publicMode = PUBLIC_KIOSK; - const privateKioskRoute = PRIVATE_KIOSK_ROUTE; - const authQuery = useQuery({ queryKey: ["auth-status", publicMode], queryFn: api.getAuthStatus, staleTime: 20_000, retry: false, enabled: !publicMode }); - const authEnabled = publicMode ? false : (authQuery.data?.enabled ?? true); - const authenticated = publicMode ? true : (authQuery.data ? (!authEnabled || authQuery.data.authenticated) : false); - const configQuery = useDashboardConfig(authenticated || authEnabled === false); - const config = configQuery.data; - const privateKioskSettingsQuery = useQuery({ queryKey: ["kiosk-settings", "private"], queryFn: () => api.getKioskSettings("private"), enabled: !publicMode && (authenticated || authEnabled === false), staleTime: 30_000 }); - const publicKioskSettingsQuery = useQuery({ queryKey: ["kiosk-settings", "public"], queryFn: () => api.getKioskSettings("public"), enabled: publicMode || authenticated || authEnabled === false, staleTime: 30_000 }); - - const [theme, setTheme] = useState(() => getInitialTheme(undefined)); - const [language, setLanguage] = useState(() => getInitialLanguage(undefined)); - const [activeTab, setActiveTab] = useState(publicMode ? "kiosk" : "realtime"); - const [realtimeRange, setRealtimeRange] = useState("today"); - const [analyticsRange, setAnalyticsRange] = useState("30d"); - const [bucket, setBucket] = useState("day"); - const [compare, setCompare] = useState("previous_year"); - const [analyticsStart, setAnalyticsStart] = useState(""); - const [analyticsEnd, setAnalyticsEnd] = useState(""); - const [compareRanges, setCompareRanges] = useState>([{ key: "cmp_1", label: "Comparison 1", start: "", end: "" }, { key: "cmp_2", label: "Comparison 2", start: "", end: "" }]); - const initialArchiveWindow = archivePresetWindow("today"); - const [archiveStart, setArchiveStart] = useState(initialArchiveWindow?.start ?? ""); - const [archiveEnd, setArchiveEnd] = useState(initialArchiveWindow?.end ?? ""); - const [archiveRange, setArchiveRange] = useState("today"); - const [liveMetrics, setLiveMetrics] = useState(() => readStorage(STORAGE_KEYS.liveMetrics, DEFAULT_LIVE_METRICS)); - const [archiveMetrics, setArchiveMetrics] = useState(() => readStorage(STORAGE_KEYS.archiveMetrics, DEFAULT_LIVE_METRICS)); - const [liveAutoRefresh, setLiveAutoRefresh] = useState(() => readStorage(STORAGE_KEYS.liveAutoRefresh, true, (raw) => raw === "true")); - const [liveWidgets, setLiveWidgets] = useState(() => getVisibleLiveWidgets(readStorage(STORAGE_KEYS.liveWidgets, DEFAULT_LIVE_WIDGETS))); - const [viewMode, setViewMode] = useState(() => { const fromUrl = parseViewModeFromLocation(); return fromUrl === "kiosk" ? fromUrl : readStorage(STORAGE_KEYS.viewMode, "normal", (raw) => (raw === "kiosk" ? "kiosk" : "normal")); }); - const [kioskWidgets, setKioskWidgets] = useState(() => getVisibleWidgets(readStorage(STORAGE_KEYS.kioskWidgets, DEFAULT_KIOSK_WIDGETS))); - const [kioskEditorMode, setKioskEditorMode] = useState<"private" | "public">("private"); - const [privateKioskDraft, setPrivateKioskDraft] = useState({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, hero_metric_ids: DEFAULT_KIOSK_HERO_METRICS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none", chart_groups: buildDefaultKioskChartGroups() }); - const [publicKioskDraft, setPublicKioskDraft] = useState({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, hero_metric_ids: DEFAULT_KIOSK_HERO_METRICS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none", chart_groups: buildDefaultKioskChartGroups() }); - const [blockConfig, setBlockConfig] = useState>(() => readStorage(STORAGE_KEYS.blockConfig, DEFAULT_BLOCK_CONFIG)); - const [loginForm, setLoginForm] = useState({ username: "", password: "" }); - const [loginError, setLoginError] = useState(null); - const [newUser, setNewUser] = useState({ username: "", display_name: "", password: "", role: "user" }); - const [passwordReset, setPasswordReset] = useState<{ username: string; password: string }>({ username: "", password: "" }); - const [kioskSaveNotice, setKioskSaveNotice] = useState>({ public: null, private: null }); - const initializedRef = useRef(false); - const defaultKioskSerializedRef = useRef>({ - public: JSON.stringify({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, hero_metric_ids: DEFAULT_KIOSK_HERO_METRICS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none", chart_groups: buildDefaultKioskChartGroups() }), - private: JSON.stringify({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, hero_metric_ids: DEFAULT_KIOSK_HERO_METRICS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none", chart_groups: buildDefaultKioskChartGroups() }), - }); - const lastSyncedKioskRef = useRef>({ - public: defaultKioskSerializedRef.current.public, - private: defaultKioskSerializedRef.current.private, - }); - - useEffect(() => { - if (!config || initializedRef.current) return; - initializedRef.current = true; - setActiveTab((config.defaults.tab as TabKey) || (publicMode ? "kiosk" : "realtime")); - setRealtimeRange(config.defaults.realtime_range); - setAnalyticsRange(config.defaults.analytics_range); - setBucket(config.defaults.analytics_bucket); - setTheme((current) => current || ((config.defaults.theme as ThemeMode) ?? "dark")); - setLanguage((current) => current || normalizeLanguage(config.defaults.language)); - }, [config, publicMode]); - useEffect(() => { - if (!privateKioskSettingsQuery.data) return; - const normalized = { ...privateKioskSettingsQuery.data, mode: "private" as const, hero_metric_ids: sanitizeKioskHeroMetrics(privateKioskSettingsQuery.data.hero_metric_ids), chart_groups: sanitizeKioskChartGroups(privateKioskSettingsQuery.data.chart_groups) }; - lastSyncedKioskRef.current.private = JSON.stringify(normalized); - applyKioskDraftChange("private", normalized); - }, [privateKioskSettingsQuery.data]); - useEffect(() => { - if (!publicKioskSettingsQuery.data) return; - const normalized = { ...publicKioskSettingsQuery.data, mode: "public" as const, hero_metric_ids: sanitizeKioskHeroMetrics(publicKioskSettingsQuery.data.hero_metric_ids), chart_groups: sanitizeKioskChartGroups(publicKioskSettingsQuery.data.chart_groups) }; - lastSyncedKioskRef.current.public = JSON.stringify(normalized); - applyKioskDraftChange("public", normalized); - }, [publicKioskSettingsQuery.data]); - useEffect(() => { document.documentElement.setAttribute("data-bs-theme", theme); document.body.setAttribute("data-bs-theme", theme); writeStorage(STORAGE_KEYS.theme, theme); }, [theme]); - useEffect(() => { writeStorage(STORAGE_KEYS.language, language); }, [language]); - useEffect(() => { syncViewModeToLocation(viewMode); writeStorage(STORAGE_KEYS.viewMode, viewMode); }, [viewMode]); - useEffect(() => { writeStorage(STORAGE_KEYS.kioskWidgets, kioskWidgets); }, [kioskWidgets]); - useEffect(() => { writeStorage(STORAGE_KEYS.blockConfig, blockConfig); }, [blockConfig]); - useEffect(() => { writeStorage(STORAGE_KEYS.liveWidgets, liveWidgets); }, [liveWidgets]); - useEffect(() => { writeStorage(STORAGE_KEYS.liveMetrics, liveMetrics); }, [liveMetrics]); - useEffect(() => { writeStorage(STORAGE_KEYS.archiveMetrics, archiveMetrics); }, [archiveMetrics]); - useEffect(() => { writeStorage(STORAGE_KEYS.liveAutoRefresh, String(liveAutoRefresh)); }, [liveAutoRefresh]); - - const dataEnabled = authenticated || authEnabled === false; - const currentRole = publicMode ? null : (authQuery.data?.role ?? null); - const isAdmin = authEnabled === false || currentRole === "admin"; - const hasWarehouseAccess = !publicMode && isAdmin; - const hasSettingsAccess = !publicMode && isAdmin; - const canSavePrivateKioskSettings = !publicMode && dataEnabled; - const canSavePublicKioskSettings = !publicMode && isAdmin; - const { snapshot, connected, lastUpdated } = useRealtimeSocket(dataEnabled); - const chartMetricCandidates = useMemo(() => getChartMetricCandidates(config), [config]); - const blockMetricCandidates = useMemo(() => getBlockMetricCandidates(snapshot), [snapshot]); - useEffect(() => { - if (!chartMetricCandidates.length) return; - const allowed = new Set(chartMetricCandidates.map((item) => item.metric_id)); - setLiveMetrics((current) => { - const filtered = current.filter((item) => allowed.has(item)); - return filtered.length ? filtered : chartMetricCandidates.slice(0, 3).map((item) => item.metric_id); - }); - setArchiveMetrics((current) => { - const filtered = current.filter((item) => allowed.has(item)); - return filtered.length ? filtered : chartMetricCandidates.slice(0, 3).map((item) => item.metric_id); - }); - }, [chartMetricCandidates]); - useEffect(() => { - if (!blockMetricCandidates.length) return; - const allowed = new Set(blockMetricCandidates.map((item) => item.metric_id)); - setBlockConfig((current) => { - const next = { - hero: current.hero.filter((item) => allowed.has(item)), - quick: current.quick.filter((item) => allowed.has(item)), - }; - return { - hero: next.hero.length ? next.hero : blockMetricCandidates.filter((item) => DEFAULT_BLOCK_CONFIG.hero.includes(item.metric_id)).map((item) => item.metric_id), - quick: next.quick.length ? next.quick : blockMetricCandidates.filter((item) => DEFAULT_BLOCK_CONFIG.quick.includes(item.metric_id)).map((item) => item.metric_id), - }; - }); - }, [blockMetricCandidates]); - useEffect(() => { - if (!chartMetricCandidates.length) return; - setPrivateKioskDraft((current) => ({ ...current, chart_groups: sanitizeKioskChartGroups(current.chart_groups, chartMetricCandidates) })); - setPublicKioskDraft((current) => ({ ...current, chart_groups: sanitizeKioskChartGroups(current.chart_groups, chartMetricCandidates) })); - }, [chartMetricCandidates]); - useEffect(() => { - if (!blockMetricCandidates.length) return; - setPrivateKioskDraft((current) => ({ ...current, hero_metric_ids: sanitizeKioskHeroMetrics(current.hero_metric_ids, blockMetricCandidates) })); - setPublicKioskDraft((current) => ({ ...current, hero_metric_ids: sanitizeKioskHeroMetrics(current.hero_metric_ids, blockMetricCandidates) })); - }, [blockMetricCandidates]); - const liveHistoryMetrics = useMemo(() => liveMetrics, [liveMetrics]); - const effectiveKioskSettings = publicMode ? publicKioskDraft : privateKioskDraft; - const effectiveKioskChartGroups = useMemo(() => sanitizeKioskChartGroups(effectiveKioskSettings.chart_groups, chartMetricCandidates), [effectiveKioskSettings.chart_groups, chartMetricCandidates]); - const effectiveKioskHeroMetricIds = useMemo(() => sanitizeKioskHeroMetrics(effectiveKioskSettings.hero_metric_ids, blockMetricCandidates), [effectiveKioskSettings.hero_metric_ids, blockMetricCandidates]); - const kioskActive = publicMode || privateKioskRoute || viewMode === "kiosk"; - const effectiveKioskWidgets = toWidgetIds(kioskActive ? effectiveKioskSettings.widgets : kioskWidgets); - const effectiveRealtimeRange = kioskActive ? effectiveKioskSettings.realtime_range : realtimeRange; - const effectiveAnalyticsRange = kioskActive ? effectiveKioskSettings.analytics_range : analyticsRange; - const effectiveBucket = kioskActive ? effectiveKioskSettings.analytics_bucket : bucket; - const effectiveCompare = kioskActive ? effectiveKioskSettings.compare_mode : compare; - const historyQuery = useRealtimeHistory(effectiveRealtimeRange, dataEnabled, { publicKiosk: publicMode, pauseAutoRefresh: !liveAutoRefresh }); - const sanitizedCompareRanges = compareRanges.filter((item) => item.start && item.end); - const analyticsOptions = analyticsStart && analyticsEnd && !kioskActive ? { start: analyticsStart, end: analyticsEnd, publicKiosk: publicMode, compareRanges: effectiveCompare === "custom_multi" ? sanitizedCompareRanges : undefined } : { publicKiosk: publicMode, compareRanges: effectiveCompare === "custom_multi" ? sanitizedCompareRanges : undefined }; - const analyticsQuery = useAnalytics(analyticsStart && analyticsEnd && !kioskActive ? "custom" : effectiveAnalyticsRange, effectiveBucket, effectiveCompare, dataEnabled, analyticsOptions); - const historical = useHistoricalImport(hasWarehouseAccess); - const archiveQuery = useRealtimeHistory(archiveStart && archiveEnd ? "custom" : archiveRange, dataEnabled, { start: archiveStart || undefined, end: archiveEnd || undefined, metrics: archiveMetrics, publicKiosk: publicMode, pauseAutoRefresh: true }); - const rawRealtimeHistoryData = useMemo(() => trimSingleDayHistory(historyQuery.data, effectiveRealtimeRange), [historyQuery.data, effectiveRealtimeRange]); - const liveHistoryData = useMemo(() => filterHistoryByMetrics(rawRealtimeHistoryData, liveHistoryMetrics), [rawRealtimeHistoryData, liveHistoryMetrics]); - const archiveHistoryData = useMemo(() => filterHistoryByMetrics(trimSingleDayHistory(archiveQuery.data, archiveRange), archiveMetrics), [archiveQuery.data, archiveRange, archiveMetrics]); - const usersQuery = useQuery({ queryKey: ["auth-users"], queryFn: api.getUsers, enabled: dataEnabled && isAdmin, staleTime: 15_000 }); - const diagnosticsQuery = useQuery({ queryKey: ["diagnostics"], queryFn: api.getDiagnostics, enabled: hasSettingsAccess, staleTime: 20_000 }); - - const loginMutation = useMutation({ mutationFn: () => api.login(loginForm.username, loginForm.password), onSuccess: async () => { setLoginError(null); setLoginForm((value) => ({ ...value, password: "" })); await queryClient.invalidateQueries({ queryKey: ["auth-status"] }); await queryClient.invalidateQueries({ queryKey: ["dashboard-config"] }); }, onError: (error: Error) => setLoginError(parseError(error) || t(language, "loginError")) }); - const logoutMutation = useMutation({ mutationFn: api.logout, onSuccess: async () => { await queryClient.clear(); await queryClient.invalidateQueries({ queryKey: ["auth-status"] }); } }); - const createUserMutation = useMutation({ mutationFn: () => api.createUser(newUser), onSuccess: async () => { setNewUser({ username: "", display_name: "", password: "", role: "user" }); await usersQuery.refetch(); } }); - const resetPasswordMutation = useMutation({ mutationFn: () => api.resetUserPassword(passwordReset.username, passwordReset.password), onSuccess: async () => { setPasswordReset({ username: "", password: "" }); await usersQuery.refetch(); } }); - const updateUserRoleMutation = useMutation({ mutationFn: ({ username, role }: { username: string; role: string }) => api.updateUserRole(username, role), onSuccess: async () => { await usersQuery.refetch(); await queryClient.invalidateQueries({ queryKey: ["auth-status"] }); } }); - - const saveKioskSettingsMutation = useMutation({ - mutationFn: (payload: KioskSettingsPayload) => api.saveKioskSettings(payload), - onSuccess: async (saved, payload) => { - const normalized = { ...saved, mode: payload.mode }; - lastSyncedKioskRef.current[payload.mode] = JSON.stringify(normalized); - if (payload.mode === "public") setPublicKioskDraft(normalized); else setPrivateKioskDraft(normalized); - setKioskSaveNotice((current) => ({ ...current, [payload.mode]: language === "en" ? "Saved." : "Zapisano." })); - await queryClient.invalidateQueries({ queryKey: ["kiosk-settings", payload.mode] }); - }, - onError: (error: Error, payload) => { - const message = parseError(error) || (language === "en" ? "Save failed." : "Nie udało się zapisać."); - setKioskSaveNotice((current) => ({ ...current, [payload.mode]: message })); - }, - }); - const applyKioskDraftChange = (mode: "public" | "private", next: KioskSettingsPayload) => { - const normalized: KioskSettingsPayload = { ...next, mode, hero_metric_ids: sanitizeKioskHeroMetrics(next.hero_metric_ids), chart_groups: sanitizeKioskChartGroups(next.chart_groups) }; - if (mode === "public") setPublicKioskDraft(normalized); else setPrivateKioskDraft(normalized); - setKioskSaveNotice((current) => ({ ...current, [mode]: null })); - }; - - useEffect(() => { - if (!isAdmin && kioskEditorMode === "public") { - setKioskEditorMode("private"); - } - }, [isAdmin, kioskEditorMode]); - - useEffect(() => { - if ((activeTab === "warehouse" && !hasWarehouseAccess) || (activeTab === "settings" && !hasSettingsAccess)) { - setActiveTab("realtime"); - } - }, [activeTab, hasSettingsAccess, hasWarehouseAccess]); - - const privateKioskDirty = JSON.stringify(privateKioskDraft) !== lastSyncedKioskRef.current.private; - const publicKioskDirty = JSON.stringify(publicKioskDraft) !== lastSyncedKioskRef.current.public; - const currentKioskDirty = kioskEditorMode === "public" ? publicKioskDirty : privateKioskDirty; - const canPersistCurrentKioskSettings = kioskEditorMode === "public" ? canSavePublicKioskSettings : canSavePrivateKioskSettings; - const resetKioskDraft = (mode: "public" | "private") => { - const serialized = lastSyncedKioskRef.current[mode] || defaultKioskSerializedRef.current[mode]; - const parsed = JSON.parse(serialized) as KioskSettingsPayload; - applyKioskDraftChange(mode, { ...parsed, mode }); - }; - const saveCurrentKioskSettings = () => { - if (!canPersistCurrentKioskSettings || saveKioskSettingsMutation.isPending) return; - const payload = kioskEditorMode === "public" ? publicKioskDraft : privateKioskDraft; - setKioskSaveNotice((current) => ({ ...current, [payload.mode]: null })); - saveKioskSettingsMutation.mutate(payload); - }; - - const locale = localeForLanguage(language); - const widgetLabels = useMemo(() => { const map = new Map(); for (const item of widgetOrder) map.set(item.id, buildWidgetLabel(language, item.id)); return map; }, [language]); - const effectiveLiveWidgets = useMemo(() => getVisibleLiveWidgets(toLiveWidgetIds(liveWidgets)), [liveWidgets]); - const summary = analyticsQuery.production.data?.summary; - const statusHiddenMetrics = new Set(["inverter_temp", "data_refresh", "data_freshness"]); - const topStatus = (snapshot.status ?? []).filter((metric) => !statusHiddenMetrics.has(metric.metric_id)); - const metricLookup = useMemo(() => { - const map = new Map(); - snapshot.hero_cards.forEach((card) => map.set(card.metric_id, heroCardToMetric(card))); - Object.values(snapshot.kpis ?? {}).forEach((metric) => map.set(metric.metric_id, metric)); - return map; - }, [snapshot.hero_cards, snapshot.kpis]); - const heroCardLookup = useMemo(() => new Map(snapshot.hero_cards.map((card) => [card.metric_id, card])), [snapshot.hero_cards]); - const heroCards = blockConfig.hero.map((metricId) => heroCardLookup.get(metricId) ?? (metricLookup.get(metricId) ? metricToHeroCard(metricLookup.get(metricId)!) : null)).filter(Boolean) as SnapshotPayload["hero_cards"]; - const kioskHeroCards = effectiveKioskHeroMetricIds.map((metricId) => heroCardLookup.get(metricId) ?? (metricLookup.get(metricId) ? metricToHeroCard(metricLookup.get(metricId)!) : null)).filter(Boolean) as SnapshotPayload["hero_cards"]; - const quickMetrics = blockConfig.quick.map((metricId) => metricLookup.get(metricId)).filter(Boolean) as MetricValue[]; - const publicKioskUrl = `${window.location.origin}/kiosk/public`; - const privateKioskUrl = `${window.location.origin}/kiosk/private`; - - const allWidgets: Record = { - hero: , - quickMetrics: , - history: {liveAutoRefresh ? (language === "en" ? "Auto refresh active" : "Auto-odświeżanie aktywne") : (language === "en" ? "Auto refresh paused" : "Auto-odświeżanie zatrzymane")}
{liveAutoRefresh ? (language === "en" ? "The live chart refreshes automatically every 30 seconds." : "Wykres live odświeża się automatycznie co 30 sekund.") : (language === "en" ? "The live chart is frozen until you resume automatic refresh or refresh it manually." : "Wykres live jest zamrożony, dopóki nie wznowisz auto-odświeżania albo nie odświeżysz go ręcznie.")}
} />, - status: , - strings: , - production: , - comparison: , - distribution: null, - importStatus: , - }; - const renderWidget = (widgetId: WidgetId) => { - if (widgetId === "history") { - const groups = effectiveKioskChartGroups; - const columnClass = groups.length <= 1 ? "col-12" : "col-12 col-xxl-6"; - return groups.map((group, index) => ( -
- chartMetricCandidates.find((item) => item.metric_id === metricId)?.label).filter(Boolean).join(" · ") || t(language, "realtimeSubtitle")} - /> -
- )); - } - const content = allWidgets[widgetId]; - if (!content) return null; - return
{content}
; - }; - const renderLiveWidget = (widgetId: LiveWidgetId) => { - const content = allWidgets[widgetId]; - if (!content) return null; - const className = widgetId === "hero" - ? "col-12" - : widgetId === "quickMetrics" - ? "col-12 col-xl-4" - : widgetId === "history" - ? "col-12 col-xl-8" - : widgetId === "status" - ? "col-12 col-xl-4" - : "col-12 col-xl-8"; - return
{content}
; - }; - - if ((!publicMode && authQuery.isLoading) || (authEnabled && !authenticated && loginMutation.isPending)) return ; - if (authEnabled && !authenticated) return loginMutation.mutate()} onThemeToggle={() => setTheme((current) => (current === "dark" ? "light" : "dark"))} onLanguageToggle={() => setLanguage((current) => (current === "pl" ? "en" : "pl"))} loading={loginMutation.isPending} error={loginError} />; - if (configQuery.isLoading || !config) return ; - - const navbar = ( -
-
-
{config.app.site_name}
{t(language, "operatorPanel")}
-
- {connected ? t(language, "connected") : t(language, "disconnected")} - {!publicMode && currentRole ? {currentRole} : null} - - - {!publicMode ? : null} - {!publicMode ? : null} -
-
-
- ); - const menuTabs: Array<{ id: TabKey; label: string; icon: ReactElement; visible: boolean }> = [ - { id: "realtime" as TabKey, label: language === "en" ? "Live" : "Live", icon: , visible: true }, - { id: "archive" as TabKey, label: language === "en" ? "Historical live" : "Dane chwilowe", icon: , visible: true }, - { id: "analytics" as TabKey, label: t(language, "analytics"), icon: , visible: true }, - { id: "warehouse" as TabKey, label: language === "en" ? "Data warehouse" : "Hurtownia danych", icon: , visible: hasWarehouseAccess }, - { id: "kiosk" as TabKey, label: t(language, "kiosk"), icon: , visible: true }, - { id: "settings" as TabKey, label: t(language, "settings"), icon: , visible: hasSettingsAccess }, - ].filter((item) => item.visible); - - const subnavRefreshControls = activeTab === "realtime" - ?
- : null; - - const menu = ( -
-
-
-
- {menuTabs.map((item) => ( - - ))} -
-
- {subnavRefreshControls} -
- - {t(language, "updatedAt")}: {formatDateTime(lastUpdated, locale)} -
-
-
-
-
- ); - - if (viewMode === "kiosk" || publicMode) { - return
{config.app.site_name}
{t(language, "kioskHint")}
{!publicMode ? : null}
{effectiveKioskWidgets.map((widgetId) => renderWidget(widgetId))}
; - } - - return ( -
{navbar}{menu}
- {activeTab === "realtime" && <>
{effectiveLiveWidgets.map((widgetId) => renderLiveWidget(widgetId))}
} - - {activeTab === "archive" && <>
{ setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={archiveQuickRangeOptions(language)} /> item.key === archiveRange) ? archiveRange : ""} onChange={(value) => { setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={[{ key: "", label: language === "en" ? "Choose range" : "Wybierz zakres" }, ...archiveListRangeOptions(language), { key: "custom", label: language === "en" ? "Custom range" : "Własny zakres" }]} />
{ setArchiveRange("custom"); setArchiveStart(e.target.value); }} />
{ setArchiveRange("custom"); setArchiveEnd(e.target.value); }} />
{language === "en" ? "Refresh on change" : "Odświeżanie po zmianie"}
{language === "en" ? "This chart reloads only when you change the range or selected metrics." : "Ten wykres przeładowuje się tylko po zmianie zakresu albo wybranych metryk."}
} />
item.metric_id !== "energy_total")} selected={archiveMetrics.filter((item) => item !== "energy_total")} onChange={setArchiveMetrics} />
} - - {activeTab === "analytics" && <>
{ if (value !== "custom") { setAnalyticsRange(value); setAnalyticsStart(""); setAnalyticsEnd(""); } }} options={[...config.capabilities.ranges.filter((item) => !["6h", "24h", "48h", "1d", "3d", "14d", "60d", "ytd"].includes(item.key)).map((item) => ({ key: item.key, label: translateRangeLabel(language, item.key, item.label) })), { key: "custom", label: language === "en" ? "Custom" : "Ręczny" }]} /> ({ key: item.key, label: translateBucket(language, item.key) }))} />
{analyticsStart || analyticsEnd || (analyticsRange === "custom") || compare === "custom_multi" ?
setAnalyticsStart(e.target.value)} />
setAnalyticsEnd(e.target.value)} />
{compare === "custom_multi" ?
{language === "en" ? "Comparison ranges" : "Zakresy porównawcze"}
{compareRanges.map((item, index) =>
setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, label: e.target.value } : current))} />
setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, start: e.target.value } : current))} />
setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, end: e.target.value } : current))} />
)}
: null}
: null}
item.key === compare)?.label ?? compare} />
{allWidgets.production}
{allWidgets.comparison}
} - - {activeTab === "warehouse" && <>
historical.start.mutate(payload)} onSyncNow={() => historical.syncNow.mutate()} onCancel={() => historical.cancel.mutate()} />
} - - {activeTab === "kiosk" && <>
applyKioskDraftChange(kioskEditorMode, value)} onModeChange={setKioskEditorMode} selectedMode={kioskEditorMode} labels={widgetLabels} buckets={config.capabilities.buckets} compareModes={config.capabilities.comparison_modes} saving={saveKioskSettingsMutation.isPending && saveKioskSettingsMutation.variables?.mode === kioskEditorMode} dirty={currentKioskDirty} canSave={canPersistCurrentKioskSettings} saveNotice={kioskSaveNotice[kioskEditorMode]} onSave={saveCurrentKioskSettings} onReset={() => resetKioskDraft(kioskEditorMode)} allowPublicMode={isAdmin} chartItems={chartMetricCandidates} heroItems={blockMetricCandidates} />
} - - {activeTab === "settings" && <>
item.metric_id !== "energy_total")} selected={liveMetrics.filter((item) => item !== "energy_total")} onChange={setLiveMetrics} />
diagnosticsQuery.refetch()} />
{isAdmin ?
createUserMutation.mutate()} passwordReset={passwordReset} onPasswordResetChange={setPasswordReset} onResetPassword={() => resetPasswordMutation.mutate()} onRoleChange={(username, role) => updateUserRoleMutation.mutate({ username, role })} roleUpdating={updateUserRoleMutation.isPending} />
: null}
} -
- ); -} - -function parseError(error: Error): string | null { const match = error.message.match(/"detail"\s*:\s*"([^"]+)"/); return match?.[1] ?? error.message; } -function requestFullscreen() { const element = document.documentElement as HTMLElement & { webkitRequestFullscreen?: () => Promise | void; msRequestFullscreen?: () => Promise | void; }; if (element.requestFullscreen) { void element.requestFullscreen(); return; } if (element.webkitRequestFullscreen) { void element.webkitRequestFullscreen(); return; } if (element.msRequestFullscreen) void element.msRequestFullscreen(); } -function translateBucket(language: Language, key: string): string { const map: Record = { day: { pl: "Dzień", en: "Day" }, week: { pl: "Tydzień", en: "Week" }, month: { pl: "Miesiąc", en: "Month" }, year: { pl: "Rok", en: "Year" } }; return map[key]?.[language] ?? key; } -function comparisonOptions(language: Language) { return [{ key: "none", label: translateCompareMode(language, "none") }, { key: "previous_period", label: translateCompareMode(language, "previous_period") }, { key: "previous_year", label: translateCompareMode(language, "previous_year") }, { key: "previous_year_2", label: translateCompareMode(language, "previous_year_2") }, { key: "previous_year_3", label: translateCompareMode(language, "previous_year_3") }, { key: "previous_month_12", label: translateCompareMode(language, "previous_month_12") }, { key: "previous_month_24", label: translateCompareMode(language, "previous_month_24") }, { key: "custom_multi", label: translateCompareMode(language, "custom_multi") }]; } - -function applyArchivePreset(rangeKey: string, setStart: (value: string) => void, setEnd: (value: string) => void) { - const preset = archivePresetWindow(rangeKey); - if (preset) { - setStart(preset.start); - setEnd(preset.end); - return; - } - if (rangeKey !== "custom") { - setStart(""); - setEnd(""); - } -} -function LoadingScreen({ language }: { language: Language }) { return
{t(language, "loading")}…
; } -function LoginPage({ language, theme, form, onChange, onSubmit, onThemeToggle, onLanguageToggle, loading, error }: { language: Language; theme: ThemeMode; form: { username: string; password: string }; onChange: (value: { username: string; password: string }) => void; onSubmit: () => void; onThemeToggle: () => void; onLanguageToggle: () => void; loading: boolean; error: string | null; }) { return

{t(language, "loginTitle")}

{t(language, "loginSubtitle")}
onChange({ ...form, username: event.target.value })} autoComplete="username" />
onChange({ ...form, password: event.target.value })} autoComplete="current-password" onKeyDown={(event) => event.key === "Enter" && onSubmit()} />
{error ?
{error}
: null}
{language === "en" ? "Use your account to manage dashboards, analytics and user permissions." : "Użyj swojego konta, aby zarządzać dashboardami, analityką i uprawnieniami użytkowników."}
; } -function NavItem({ icon, active, onClick, label }: { icon: ReactElement; active: boolean; onClick: () => void; label: string }) { return
  • ; } -function PageHeader({ title, subtitle, children }: { title: string; subtitle: string; children?: ReactNode }) { return
    PV Insight

    {title}

    {subtitle}
    {children ?
    {children}
    : null}
    ; } -function RefreshModeSwitch({ language, autoEnabled, onChange, compact = false }: { language: Language; autoEnabled: boolean; onChange: (value: boolean) => void; compact?: boolean }) { - const autoLabel = language === "en" ? "Auto" : "Auto"; - const pausedLabel = language === "en" ? "Paused" : "Pauza"; - const switchLabel = language === "en" ? "Chart refresh mode" : "Tryb odświeżania wykresu"; - return
    {compact ? null :
    {language === "en" ? "Chart refresh" : "Odświeżanie wykresu"}
    }
    ; -} -function SegmentedSelect({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return
    {label}
    {options.map((option) => )}
    ; } -function SelectField({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return
    ; } -function translateRangeLabel(language: Language, key: string, fallback: string): string { const map: Record = { today: { pl: "Dziś", en: "Today" }, yesterday: { pl: "Wczoraj", en: "Yesterday" }, "7d": { pl: "7 dni", en: "7d" }, "30d": { pl: "30 dni", en: "30d" }, "90d": { pl: "90 dni", en: "90d" }, "365d": { pl: "365 dni", en: "365 days" }, custom: { pl: "Ręczny", en: "Custom" } }; return map[key]?.[language] ?? fallback; } -function HeroCards({ cards, locale, language }: { cards: SnapshotPayload["hero_cards"]; locale: string; language: Language }) { return
    {cards.map((card) =>
    {iconForMetric(card.metric_id)}{card.unit || "live"}
    {labelForMetric(language, card.metric_id, card.label)}
    {formatValue(card.value, card.unit, 2, locale)}
    {card.subtitle}
    )}
    ; } -function QuickMetrics({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return

    {t(language, "quickMetrics")}

    {metrics.map((metric) =>
    {labelForMetric(language, metric.metric_id, metric.label)}
    {metric.unit}
    {formatValue(metric.value, metric.unit, 2, locale)}
    )}
    ; } -function StatusStat({ label, value }: { label: string; value: string }) { return
    {label}
    {value}
    ; } -function LiveHistoryPanel({ data, language, theme, title, subtitle, footer, freezeUpdates = false, chartKey }: { data?: HistoryPayload; language: Language; theme: ThemeMode; title: string; subtitle: string; footer?: ReactNode; freezeUpdates?: boolean; chartKey?: string }) { - const [displayData, setDisplayData] = useState(data); - - useEffect(() => { - if (!freezeUpdates) { - setDisplayData(data); - } - }, [data, freezeUpdates]); - - useEffect(() => { - if (!freezeUpdates && chartKey) { - setDisplayData(data); - } - }, [chartKey, data, freezeUpdates]); - - const resolvedData = freezeUpdates ? displayData : data; - const chartOption = useMemo(() => buildLiveHistoryOption(resolvedData, theme, language), [resolvedData, theme, language]); - - return

    {title}

    {subtitle}
    {footer ?
    {footer}
    : null}
    ; -} -function SystemStatus({ items, locale, language }: { items: MetricValue[]; locale: string; language: Language }) { return

    {t(language, "systemStatus")}

    {items.map((metric) =>
    {labelForMetric(language, metric.metric_id, metric.label)}
    {metric.unit || metric.status}
    {formatValue(metric.value, metric.unit, 2, locale)}
    )}
    ; } -function StringPanels({ rows, locale, language }: { rows: SnapshotGroupRow[]; locale: string; language: Language }) { return

    {t(language, "strings")}

    {rows.map((row) =>
    {row.label}
    {Object.values(row.values).map((metric) =>
    {labelForMetric(language, metric.metric_id, metric.label)}{formatValue(metric.value, metric.unit, 2, locale)}
    )}
    )}
    ; } -function StatusDot({ ok }: { ok: boolean }) { return ; } -function StatusPanel({ metrics, locale, language, connected, lastUpdated, diagnostics, config }: { metrics: MetricValue[]; locale: string; language: Language; connected: boolean; lastUpdated?: string | null; diagnostics?: DiagnosticsPayload; config?: DashboardConfig }) { - const infoItems = [ - { label: language === "en" ? "Live feed" : "Połączenie live", value: connected ? (language === "en" ? "Connected" : "Połączono") : (language === "en" ? "Waiting" : "Oczekiwanie"), dot: }, - diagnostics ? { label: "InfluxDB", value: diagnostics.influx.reachable ? (language === "en" ? "Connected" : "Połączono") : (language === "en" ? "Error" : "Błąd"), dot: } : null, - { label: language === "en" ? "Last update" : "Ostatni odczyt", value: formatDateTime(lastUpdated, locale) }, - config ? { label: language === "en" ? "System" : "System", value: `${config.app.version} · ${config.app.timezone}` } : null, - config ? { label: language === "en" ? "Installed power" : "Moc instalacji", value: `${formatValue(config.app.installed_power_kwp, "kWp", 2, locale)}` } : null, - ].filter(Boolean) as Array<{ label: string; value: string; dot?: ReactNode }>; - return

    {t(language, "systemStatus")}

    {infoItems.map((item) =>
    {item.label}
    {item.dot ?? null}{item.value}
    )}
    {metrics.length ?
    {metrics.map((metric) =>
    {labelForMetric(language, metric.metric_id, metric.label)}
    {metric.unit || metric.status}
    {formatValue(metric.value, metric.unit, 2, locale)}
    )}
    : null}
    ; -} -function StringsPanel({ rows, locale, language }: { rows: SnapshotGroupRow[]; locale: string; language: Language }) { return ; } -function SummaryCards({ summary, language, locale, compareLabel }: { summary?: AnalyticsPayload["summary"]; language: Language; locale: string; compareLabel: string }) { const items = [{ key: t(language, "summaryTotal"), value: formatValue(summary?.total, summary?.unit ?? "kWh", 2, locale), badge: compareLabel }, { key: t(language, "summaryAverage"), value: formatValue(summary?.average_bucket, summary?.unit ?? "kWh", 2, locale) }, { key: t(language, "summaryBest"), value: summary ? `${summary.best_bucket_label} · ${formatValue(summary.best_bucket_value, summary.unit, 2, locale)}` : "--" }, { key: t(language, "summaryCo2"), value: formatValue(summary?.co2_saved_kg, "kg", 1, locale) }]; return
    {items.map((item) =>
    {item.key}
    {item.value}
    {item.badge ?
    {item.badge}
    : null}
    )}
    ; } - -function ProductionPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) { return

    {t(language, "chartProduction")}

    {t(language, "chartProductionSubtitle")}
    ; } -function ComparisonPanel({ data, language, theme, compareMode }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode; compareMode: string }) { - const [comparisonDisplayMode, setComparisonDisplayMode] = useState<"line" | "bar">("line"); - const hasComparisonData = Boolean(data?.comparisons?.some((item) => item.points?.length) || data?.comparison?.length); - return

    {t(language, "chartComparison")}

    {compareMode === "none" - ? (language === "en" ? "Comparison chart restored. It shows the current period until you pick a comparison mode." : "Wykres porównań został przywrócony. Pokazuje bieżący okres, dopóki nie wybierzesz trybu porównania.") - : comparisonDisplayMode === "line" - ? (language === "en" ? "Line view works better when comparing many periods." : "Widok liniowy lepiej działa przy większej liczbie porównań.") - : (language === "en" ? "Grouped bars make direct value comparison easier." : "Słupki ułatwiają porównanie wartości 1:1.")}
    setComparisonDisplayMode(value as "line" | "bar")} options={[{ key: "line", label: language === "en" ? "Line" : "Linia" }, { key: "bar", label: language === "en" ? "Bars" : "Słupki" }]} />
    {hasComparisonData || (data?.current?.length ?? 0) > 0 ? :
    {language === "en" ? "No data available for the comparison chart yet." : "Brak danych do wykresu porównań."}
    }
    ; -} -function DistributionPanel({ data, language, theme, locale }: { data?: DistributionPayload; language: Language; theme: ThemeMode; locale: string }) { return

    {t(language, "chartDistribution")}

    {formatValue(data?.total, data?.unit ?? "kWh", 2, locale)}
    ; } -function HistoricalPanel({ status, language, locale, compact = false }: { status?: HistoricalStatus; language: Language; locale: string; compact?: boolean }) { if (!status) return
    {t(language, "noDataDescription")}
    ; return

    {language === "en" ? "Data warehouse" : "Hurtownia danych"}

    {t(language, "importArchiveSubtitle")}
    {!compact ? <>
    {t(language, "activeChunk")}{status.active_chunk_index}/{status.total_chunks}
    {status.recent_chunks.map((chunk) => )}
    {t(language, "recentChunks")}{t(language, "status")}kWh
    #{chunk.chunk_index}
    {chunk.start_date} → {chunk.end_date}
    {chunk.state}{formatValue(chunk.energy_kwh, "kWh", 2, locale)}
    {status.recent_events.map((event, index) =>
    {event.title}
    {event.message}
    {formatShortTime(event.timestamp, locale)}
    )}
    : null}
    ; } -function ImportControls({ status, language, onStart, onSyncNow, onCancel }: { status?: HistoricalStatus; language: Language; onStart: (payload: { start_date?: string; end_date?: string; chunk_days?: number; force?: boolean }) => void; onSyncNow: () => void; onCancel: () => void; }) { const [startDate, setStartDate] = useState(status?.available_start_date ?? ""); const [endDate, setEndDate] = useState(status?.available_end_date ?? ""); const [chunkDays, setChunkDays] = useState(String(status?.default_chunk_days ?? 7)); useEffect(() => { if (!status) return; setStartDate((current) => current || status.available_start_date || ""); setEndDate((current) => current || status.available_end_date || ""); setChunkDays((current) => current || String(status.default_chunk_days || 7)); }, [status]); return

    {language === "en" ? "Import controls" : "Sterowanie importem"}

    setStartDate(event.target.value)} />
    setEndDate(event.target.value)} />
    setChunkDays(event.target.value)} />
    ; } -function KioskModeCard({ active, title, subtitle, onClick }: { active: boolean; title: string; subtitle: string; onClick: () => void; }) { return ; } -function KioskLayoutPanel({ language, widgets, onChange, labels }: { language: Language; widgets: WidgetId[]; onChange: (value: WidgetId[]) => void; labels: Map; }) { const available = widgetOrder.map((item) => item.id); const selected = widgets; const unselected = available.filter((item) => !selected.includes(item)); const move = (id: WidgetId, direction: -1 | 1) => { const index = selected.indexOf(id); if (index === -1) return; const target = index + direction; if (target < 0 || target >= selected.length) return; const next = [...selected]; [next[index], next[target]] = [next[target], next[index]]; onChange(next); }; const toggle = (id: WidgetId) => { if (selected.includes(id)) { const next = selected.filter((item) => item !== id); onChange(next.length ? next : selected); return; } onChange([...selected, id]); }; return

    {language === "en" ? "2. Section order" : "2. Kolejność sekcji"}

    {language === "en" ? "Top list is shown in kiosk from left to right, top to bottom." : "Lista u góry jest wyświetlana w kiosku dokładnie w tej kolejności."}
    {language === "en" ? "Tip: keep the most important sections first: hero, chart, strings/status." : "Wskazówka: na początku trzymaj najważniejsze sekcje: hero, wykres, stringi/status."}
    {language === "en" ? "Visible in kiosk" : "Widoczne w kiosku"}
    {selected.map((id, index) =>
    {index + 1}. {labels.get(id)}
    {widgetOrder.find((item) => item.id === id)?.tab}
    )}
    {language === "en" ? "Available sections" : "Dostępne sekcje"}
    {unselected.map((id) => )}
    ; } -function KioskSettingsEditorPanel({ language, value, onChange, onSave, onReset, selectedMode, onModeChange, labels, buckets, compareModes, saving, dirty, canSave, saveNotice, allowPublicMode, chartItems, heroItems }: { language: Language; value: KioskSettingsPayload; onChange: (value: KioskSettingsPayload) => void; onSave: () => void; onReset: () => void; selectedMode: "public" | "private"; onModeChange: (value: "public" | "private") => void; labels: Map; buckets: Array<{ key: string; label: string }>; compareModes: string[]; saving: boolean; dirty: boolean; canSave: boolean; saveNotice: string | null; allowPublicMode: boolean; chartItems: Array<{ metric_id: string; label: string; unit: string }>; heroItems: Array<{ metric_id: string; label: string; unit: string }>; }) { - const widgets = toWidgetIds(value.widgets); - const chartGroups = sanitizeKioskChartGroups(value.chart_groups, chartItems); - const heroMetricIds = sanitizeKioskHeroMetrics(value.hero_metric_ids, heroItems); - return

    {language === "en" ? "1. Kiosk type and ranges" : "1. Typ kiosku i zakresy"}

    {allowPublicMode ? (language === "en" ? "Choose the kiosk audience first, then tune the content." : "Najpierw wybierz odbiorcę kiosku, potem dopracuj zawartość.") : (language === "en" ? "These settings affect only your private kiosk after login and are stored per user." : "Te ustawienia dotyczą tylko Twojego prywatnego kiosku po zalogowaniu i zapisują się osobno dla użytkownika.")}
    {dirty ? (language === "en" ? "Unsaved changes" : "Niezapisane zmiany") : (language === "en" ? "Up to date" : "Aktualne")}
    {allowPublicMode ?
    onModeChange("private")} />
    onModeChange("public")} />
    :
    {language === "en" ? "You can manage your own private kiosk layout and ranges here." : "Tutaj ustawisz własny prywatny kiosk: układ, zakresy i porównanie."}
    }
    onChange({ ...value, widgets: widgetsValue })} labels={labels} /> onChange({ ...value, hero_metric_ids: heroMetricIdsValue })} /> onChange({ ...value, chart_groups: groupsValue })} />
    ; -} - -function KioskHeroPanel({ language, items, selected, onChange, heroEnabled }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; heroEnabled: boolean; }) { - const safeSelected = sanitizeKioskHeroMetrics(selected, items); - const toggle = (metricId: string) => { - const next = safeSelected.includes(metricId) - ? (safeSelected.length > 1 ? safeSelected.filter((item) => item !== metricId) : safeSelected) - : [...safeSelected, metricId]; - onChange(sanitizeKioskHeroMetrics(next, items)); - }; - return

    {language === "en" ? "3. Hero card content" : "3. Zawartość kart hero"}

    {language === "en" ? "Choose exactly which KPIs should appear in the kiosk hero section." : "Wybierz dokładnie, które KPI mają pojawiać się w sekcji hero kiosku."}
    {safeSelected.length}
    {!heroEnabled ?
    {language === "en" ? "Hero section is hidden in kiosk layout. Enable Hero section above to display these cards." : "Sekcja hero jest ukryta w układzie kiosku. Włącz sekcję Hero powyżej, aby pokazać te karty."}
    : null}
    {items.map((item) => )}
    ; -} - -function KioskChartGroupsPanel({ language, items, groups, onChange, historyEnabled }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; groups: KioskChartGroup[]; onChange: (value: KioskChartGroup[]) => void; historyEnabled: boolean; }) { - const safeGroups = sanitizeKioskChartGroups(groups, items); - const updateGroup = (groupId: string, updater: (group: KioskChartGroup) => KioskChartGroup) => onChange(safeGroups.map((group) => group.id === groupId ? updater(group) : group)); - const move = (groupId: string, direction: -1 | 1) => { - const index = safeGroups.findIndex((group) => group.id === groupId); - if (index === -1) return; - const target = index + direction; - if (target < 0 || target >= safeGroups.length) return; - const next = [...safeGroups]; - [next[index], next[target]] = [next[target], next[index]]; - onChange(next); - }; - const addGroup = () => { - const fallbackMetricIds = items.slice(0, Math.min(items.length, 3)).map((item) => item.metric_id); - if (!fallbackMetricIds.length) return; - onChange([...safeGroups, { id: `chart_${Date.now()}`, title: null, metric_ids: [fallbackMetricIds[0]] }]); - }; - const removeGroup = (groupId: string) => { - const next = safeGroups.filter((group) => group.id !== groupId); - onChange(next.length ? next : sanitizeKioskChartGroups([], items)); - }; - const toggleMetric = (groupId: string, metricId: string) => updateGroup(groupId, (group) => ({ - ...group, - metric_ids: group.metric_ids.includes(metricId) - ? (group.metric_ids.length > 1 ? group.metric_ids.filter((item) => item !== metricId) : group.metric_ids) - : [...group.metric_ids, metricId], - })); - return

    {language === "en" ? "4. Kiosk chart content" : "4. Zawartość wykresów kiosku"}

    {language === "en" ? "Create one combined chart or split kiosk into separate AC, DC, temperature and other charts." : "Ułóż jeden wykres zbiorczy albo kilka osobnych kart, np. AC, DC i temperatura."}
    {!historyEnabled ?
    {language === "en" ? "The chart section is hidden in kiosk layout. Enable History section above to show these charts." : "Sekcja wykresów jest ukryta w układzie kiosku. Włącz sekcję wykresu powyżej, aby je pokazać."}
    : null}{safeGroups.map((group, index) =>
    {language === "en" ? `Chart ${index + 1}` : `Wykres ${index + 1}`}
    updateGroup(group.id, (current) => ({ ...current, title: event.target.value || null }))} />
    {group.metric_ids.length}
    {items.map((item) => )}
    )}{!items.length ?
    {language === "en" ? "No realtime metrics available for kiosk charts." : "Brak dostępnych metryk live do wykresów kiosku."}
    : null}
    ; -} - - -function KioskLinkPanel({ language, publicKioskUrl, privateKioskUrl, publicSettings, privateSettings, showPublicLink }: { language: Language; publicKioskUrl: string; privateKioskUrl: string; publicSettings: KioskSettingsPayload; privateSettings: KioskSettingsPayload; showPublicLink: boolean }) { const [copied, setCopied] = useState<"public" | "private" | null>(null); const copy = async (value: string, mode: "public" | "private") => { await navigator.clipboard.writeText(value); setCopied(mode); window.setTimeout(() => setCopied(null), 1500); }; return
    {showPublicLink ?

    {language === "en" ? "2. Share kiosk" : "2. Udostępnianie kiosku"}

    {language === "en" ? "Public kiosk" : "Kiosk publiczny"}
    {language === "en" ? "No login required." : "Nie wymaga logowania."}
    TV
    {language === "en" ? "Live" : "Live"}: {publicSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {publicSettings.analytics_range}
    {language === "en" ? "Private kiosk" : "Kiosk prywatny"}
    {language === "en" ? "Requires login." : "Wymaga logowania."}
    {language === "en" ? "Secure" : "Bezpieczny"}
    {language === "en" ? "Live" : "Live"}: {privateSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {privateSettings.analytics_range}
    :

    {language === "en" ? "Private kiosk link" : "Link do prywatnego kiosku"}

    {language === "en" ? "Your private kiosk" : "Twój prywatny kiosk"}
    {language === "en" ? "Requires login and uses your saved kiosk layout." : "Wymaga logowania i używa Twojego zapisanego układu kiosku."}
    {language === "en" ? "User" : "User"}
    {language === "en" ? "Live" : "Live"}: {privateSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {privateSettings.analytics_range}
    }

    {language === "en" ? "Quick guidance" : "Szybka wskazówka"}

    {showPublicLink ? (language === "en" ? "Public kiosk is best for shared screens. Private kiosk is better when you want full data access after login." : "Publiczny kiosk sprawdzi się na współdzielonych ekranach. Prywatny kiosk jest lepszy, gdy po zalogowaniu ma być dostęp do pełnych danych.") : (language === "en" ? "Private kiosk keeps your own layout and ranges separate from the admin configuration." : "Prywatny kiosk zachowuje Twój własny układ i zakresy oddzielnie od konfiguracji administratora.")}
    ; } -function ActionTile({ active, icon, title, subtitle, onClick }: { active: boolean; icon: ReactNode; title: string; subtitle?: string; onClick: () => void; }) { return ; } -function AppearancePanel({ language, setLanguage, theme, setTheme, viewMode, setViewMode, userName }: { language: Language; setLanguage: (value: Language) => void; theme: ThemeMode; setTheme: (value: ThemeMode) => void; viewMode: ViewMode; setViewMode: (value: ViewMode) => void; userName: string; }) { return

    {language === "en" ? "Quick settings" : "Szybkie ustawienia"}

    {language === "en" ? "Most useful display options in one place." : "Najważniejsze opcje ekranu w jednym miejscu."}
    {userName ? {userName} : null}
    {t(language, "theme")}
    } title={t(language, "light")} onClick={() => setTheme("light")} />
    } title={t(language, "dark")} onClick={() => setTheme("dark")} />
    {t(language, "viewMode")}
    } title={t(language, "normalMode")} onClick={() => setViewMode("normal")} />
    } title={t(language, "kioskMode")} onClick={() => setViewMode("kiosk")} />
    {language === "en" ? "Language" : "Język"}
    } title="Polski" onClick={() => setLanguage("pl")} />
    } title="English" onClick={() => setLanguage("en")} />
    ; } -function MetricSelectorCard({ language, title, items, selected, onChange }: { language: Language; title: string; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; }) { const toggle = (metricId: string) => onChange(selected.includes(metricId) ? selected.filter((item) => item !== metricId) : [...selected, metricId]); return

    {title}

    {language === "en" ? "Select what should appear on charts." : "Wybierz co ma pojawiać się na wykresach."}
    {selected.length}
    {items.map((item) => )}
    ; } -function LiveChartMetricsPanel({ language, items, selected, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; }) { return ; } -function LiveSectionVisibilityPanel({ language, selected, onChange }: { language: Language; selected: LiveWidgetId[]; onChange: (value: LiveWidgetId[]) => void; }) { - const items: Array<{ id: LiveWidgetId; label: string; description: string }> = [ - { id: "hero", label: language === "en" ? "Hero cards" : "Karty hero", description: language === "en" ? "Top KPI summary" : "Najważniejsze KPI na górze" }, - { id: "quickMetrics", label: t(language, "quickMetrics"), description: language === "en" ? "Compact KPI list" : "Szybka lista KPI" }, - { id: "history", label: t(language, "kioskCharts"), description: language === "en" ? "Main live chart" : "Główny wykres live" }, - { id: "status", label: t(language, "systemStatus"), description: language === "en" ? "Connection and device status" : "Połączenie i stan urządzenia" }, - { id: "strings", label: t(language, "strings"), description: language === "en" ? "String power and voltage" : "Moc i napięcie stringów" }, - ]; - const visible = selected; - const hidden = items.filter((item) => !visible.includes(item.id)); - const move = (id: LiveWidgetId, direction: -1 | 1) => { - const index = visible.indexOf(id); - if (index === -1) return; - const target = index + direction; - if (target < 0 || target >= visible.length) return; - const next = [...visible]; - [next[index], next[target]] = [next[target], next[index]]; - onChange(next); - }; - const toggle = (id: LiveWidgetId) => { - if (visible.includes(id)) { - const next = visible.filter((item) => item !== id); - onChange(next.length ? next : visible); - return; - } - onChange([...visible, id]); - }; - return

    {language === "en" ? "LIVE layout" : "Układ LIVE"}

    {language === "en" ? "Decide which sections are visible and in what order they appear." : "Ustaw, które sekcje są widoczne i w jakiej kolejności się pokazują."}
    {visible.length}
    {language === "en" ? "The order below directly controls the desktop LIVE dashboard." : "Kolejność poniżej bezpośrednio steruje układem desktopowego dashboardu LIVE."}
    {language === "en" ? "Visible and ordered" : "Widoczne i uporządkowane"}
    {visible.map((id, index) => { const item = items.find((entry) => entry.id === id); if (!item) return null; return
    {index + 1}
    {item.label}
    {item.description}
    ; })}
    {language === "en" ? "Hidden sections" : "Ukryte sekcje"}
    {hidden.length ?
    {hidden.map((item) => )}
    :
    {language === "en" ? "All available sections are currently visible." : "Wszystkie dostępne sekcje są teraz widoczne."}
    }
    {language === "en" ? "Quick preview" : "Szybki podgląd"}
    {visible.map((id, index) => `${index + 1}. ${items.find((item) => item.id === id)?.label ?? id}`).join(" → ")}
    ; -} -function BlockVisibilityPanel({ language, items, config, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; config: Record; onChange: (value: Record) => void; }) { - const updateTarget = (target: BlockTarget, next: string[]) => onChange({ ...config, [target]: next }); - const renderSection = (target: BlockTarget, title: string, subtitle: string) => { - const selected = config[target]; - const hidden = items.filter((item) => !selected.includes(item.metric_id)); - const move = (metricId: string, direction: -1 | 1) => { - const index = selected.indexOf(metricId); - if (index === -1) return; - const targetIndex = index + direction; - if (targetIndex < 0 || targetIndex >= selected.length) return; - const next = [...selected]; - [next[index], next[targetIndex]] = [next[targetIndex], next[index]]; - updateTarget(target, next); - }; - const toggle = (metricId: string) => { - if (selected.includes(metricId)) { - const next = selected.filter((item) => item !== metricId); - updateTarget(target, next.length ? next : selected); - return; - } - updateTarget(target, [...selected, metricId]); - }; - return
    {title}
    {subtitle}
    {selected.length}
    {selected.map((metricId, index) => { const metric = items.find((item) => item.metric_id === metricId); if (!metric) return null; return
    {index + 1}
    {metric.label}
    {metric.unit || "—"}
    ; })}
    {language === "en" ? "Add metric" : "Dodaj metrykę"}
    {hidden.map((item) => )}
    {!hidden.length ?
    {language === "en" ? "All metrics are already used in this block." : "Wszystkie metryki są już użyte w tym bloku."}
    : null}
    ; - }; - return

    {language === "en" ? "Homepage block builder" : "Budowanie bloków strony głównej"}

    {language === "en" ? "Arrange metrics interactively instead of relying on click order." : "Układaj metryki interaktywnie zamiast polegać na kolejności klikania."}
    {language === "en" ? "The first items appear first on desktop, so you can control hierarchy visually." : "Pierwsze elementy wyświetlają się jako pierwsze na desktopie, więc hierarchię ustawiasz wizualnie."}
    {renderSection("hero", language === "en" ? "Hero metrics" : "Karty hero", language === "en" ? "Top KPI cards on the homepage." : "Górne karty KPI na stronie głównej.")}{renderSection("quick", language === "en" ? "Quick metrics" : "Szybkie metryki", language === "en" ? "Compact KPI list next to the chart." : "Kompaktowa lista KPI obok wykresu.")}
    ; -} -function DiagnosticBadge({ ok, label }: { ok: boolean; label: string }) { return {label}; } -function DiagnosticPanel({ language, locale, data, loading, onRefresh }: { language: Language; locale: string; data?: DiagnosticsPayload; loading: boolean; onRefresh: () => void; }) { return

    {language === "en" ? "Diagnostics" : "Diagnostyka"}

    {language === "en" ? "API, InfluxDB and application status." : "Stan API, InfluxDB i aplikacji."}
    {loading && !data ?
    {t(language, "loading")}…
    : null}{data ? <>
    {language === "en" ? "InfluxDB connection" : "Połączenie z InfluxDB"}
    URL
    {data.influx.url}
    {language === "en" ? "Database" : "Baza"}
    {data.influx.database}
    {language === "en" ? "User" : "Użytkownik"}
    {data.influx.username_masked || "—"}
    {language === "en" ? "Timeout / SSL" : "Timeout / SSL"}
    {data.influx.timeout_seconds}s / {data.influx.verify_ssl ? "verify" : "no-verify"}
    {data.influx.error ?
    {data.influx.error}
    : null}
    {language === "en" ? "Application details" : "Szczegóły aplikacji"}
    {language === "en" ? "API prefix" : "Prefix API"}
    {data.api.prefix}
    {language === "en" ? "Started at" : "Uruchomiono"}
    {formatDateTime(data.app.started_at, locale)}
    {language === "en" ? "Timezone" : "Strefa czasu"}
    {data.app.timezone}
    {language === "en" ? "SQLite" : "SQLite"}
    {data.storage.sqlite_path}
    {language === "en" ? "History sync" : "Synchronizacja historii"}
    {data.storage.historical_import_enabled ? (language === "en" ? "Enabled" : "Włączona") : (language === "en" ? "Disabled" : "Wyłączona")} · auto: {data.storage.auto_sync_enabled ? "on" : "off"} · chunk: {data.storage.default_chunk_days}
    :
    {language === "en" ? "No diagnostics data." : "Brak danych diagnostycznych."}
    }
    ; } -function AdminUsersPanel({ language, users, currentUsername, newUser, onNewUserChange, onCreate, passwordReset, onPasswordResetChange, onResetPassword, onRoleChange, roleUpdating }: { language: Language; users: AuthUsersPayload["items"]; currentUsername?: string | null; newUser: { username: string; display_name: string; password: string; role: string }; onNewUserChange: (value: { username: string; display_name: string; password: string; role: string }) => void; onCreate: () => void; passwordReset: { username: string; password: string }; onPasswordResetChange: (value: { username: string; password: string }) => void; onResetPassword: () => void; onRoleChange: (username: string, role: string) => void; roleUpdating: boolean; }) { return

    {language === "en" ? "Admin user management" : "Zarządzanie użytkownikami"}

    {language === "en" ? "Create user" : "Dodaj użytkownika"}
    onNewUserChange({ ...newUser, username: e.target.value })} />
    onNewUserChange({ ...newUser, display_name: e.target.value })} />
    onNewUserChange({ ...newUser, password: e.target.value })} />
    {language === "en" ? "Reset password" : "Zmiana hasła"}
    onPasswordResetChange({ ...passwordReset, password: e.target.value })} />
    {language === "en" ? "Permissions" : "Uprawnienia"}
    {language === "en" ? "Switch accounts between user and admin directly in the table below." : "Przełączaj konta między user i admin bezpośrednio w tabeli poniżej."}
    {language === "en" ? "The last active admin cannot be downgraded." : "Ostatni aktywny administrator nie może zostać zdegradowany."}
    {users.map((user) => { const nextRole = user.role === "admin" ? "user" : "admin"; return ; })}
    {language === "en" ? "Username" : "Login"}{language === "en" ? "Display name" : "Nazwa"}Role{language === "en" ? "Updated" : "Aktualizacja"}{language === "en" ? "Actions" : "Akcje"}
    {user.username}
    {currentUsername === user.username ?
    {language === "en" ? "Current session" : "Bieżąca sesja"}
    : null}
    {user.display_name}{user.role}{formatDateTime(user.updated_at, language === "en" ? "en-GB" : "pl-PL")}
    ; } diff --git a/frontend/src/components/common/Icons.tsx b/frontend/src/components/common/Icons.tsx index cdf59ac..895f1df 100644 --- a/frontend/src/components/common/Icons.tsx +++ b/frontend/src/components/common/Icons.tsx @@ -76,6 +76,9 @@ export function IconSun(props: IconProps) { export function IconTemperature(props: IconProps) { return ; } +export function IconUser(props: IconProps) { + return ; +} export function IconX(props: IconProps) { return ; } diff --git a/frontend/src/index.css b/frontend/src/index.css index 00aaed9..cdac50b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -389,6 +389,51 @@ body { transform: translateY(-5px) rotate(-45deg); } +.pv-navbar-action--icon-only { + width: 2.5rem; + min-width: 2.5rem; + padding-inline: 0 !important; +} + +.pv-footer { + margin-top: auto; + padding: 0.9rem 0; + background: color-mix(in srgb, var(--tblr-bg-surface, #fff) 86%, transparent); + backdrop-filter: blur(14px); +} + +.pv-footer-shell { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.85rem 1rem; + min-height: 3rem; +} + +.pv-footer-item { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.pv-footer-item--muted { + color: var(--tblr-secondary); +} + +.pv-footer-icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--tblr-secondary); +} + +.pv-footer-text { + min-width: 0; +} + @media (max-width: 1200px) { .pv-subnav-side { width: 100%; @@ -398,6 +443,11 @@ body { } @media (max-width: 768px) { + .pv-footer-shell { + flex-direction: column; + align-items: flex-start; + } + .pv-subnav-shell { min-height: auto; } diff --git a/frontend/src/index.css.bak_mobilefix b/frontend/src/index.css.bak_mobilefix deleted file mode 100644 index 12a8f8a..0000000 --- a/frontend/src/index.css.bak_mobilefix +++ /dev/null @@ -1,631 +0,0 @@ -:root { - --tblr-border-radius: 0.55rem; - --tblr-border-radius-lg: 0.7rem; - --tblr-border-radius-sm: 0.4rem; - --tblr-card-border-radius: 0.7rem; - --tblr-shadow-sm: 0 0.125rem 0.25rem rgba(15, 23, 42, 0.05); - --pv-shell-bg: #f4f6f9; - --pv-card-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), 0 12px 24px rgba(15, 23, 42, 0.04); -} - -[data-bs-theme="dark"] { - --pv-shell-bg: #0b1220; - --pv-card-shadow: 0 1px 2px rgba(0, 0, 0, 0.28), 0 14px 30px rgba(0, 0, 0, 0.24); -} - -html, -body, -#root { - min-height: 100%; -} - -body { - background: var(--pv-shell-bg); -} - -.page { - min-height: 100vh; -} - -.page-body { - padding-top: 1rem; - padding-bottom: 1.5rem; -} - -.pv-navbar, -.pv-subnav { - backdrop-filter: blur(14px); -} - -.pv-card, -.login-card { - border-width: 1px; - box-shadow: var(--pv-card-shadow); -} - -.pv-card .card-header, -.pv-card .card-body, -.login-card .card-body { - padding: 1rem 1rem; -} - -.pv-hero-card .display-6 { - letter-spacing: -0.03em; -} - -.pv-chart { - width: 100%; - height: 340px; -} - -.pv-chart-sm { - width: 100%; - height: 280px; -} - -.status-row, -.string-panel { - background: rgba(127, 127, 127, 0.03); -} - -.kiosk-shell { - background: - radial-gradient(circle at top right, rgba(32, 107, 196, 0.08), transparent 30%), - var(--pv-shell-bg); -} - -.login-page-shell { - background: - radial-gradient(circle at top, rgba(32, 107, 196, 0.12), transparent 28%), - var(--pv-shell-bg); -} - -.btn-group > .btn, -.form-control, -.card, -.border, -.progress, -.badge, -.alert { - border-radius: 0.65rem !important; -} - -.card-table tbody tr:last-child td { - border-bottom-width: 0; -} - -.nav-link.active { - font-weight: 600; -} - -.table-responsive { - overflow-x: auto; -} - -@media (max-width: 768px) { - .pv-chart, - .pv-chart-sm { - height: 260px; - } - - .page-body { - padding-top: 0.75rem; - } -} - - -.pv-nav-link { - display: inline-flex !important; - align-items: center; - justify-content: center; - gap: 0.1rem; - min-height: 1.75rem; - padding: 0.5rem 0.5rem !important; -} - -.pv-nav-icon { - width: 1rem; - min-width: 1rem; - height: 1rem; - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 1.1rem; - line-height: 1; -} - -.pv-nav-icon svg { - display: block; - width: 1.1rem; - height: 1.1rem; -} - -.pv-nav-title { - display: inline-flex; - align-items: center; - line-height: 1.1; - white-space: nowrap; -} - -.pv-navbar-action { - border-color: transparent !important; - background: rgba(127, 127, 127, 0.1) !important; - color: inherit !important; -} - -.pv-navbar-action:hover, -.pv-navbar-action:focus { - background: rgba(127, 127, 127, 0.18) !important; -} - -.pv-filter-grid { - display: grid; - grid-template-columns: repeat(3, minmax(180px, 1fr)); - gap: 0.75rem; - align-items: end; -} - -.pv-filter-grid-live { - grid-template-columns: minmax(260px, 1.45fr) minmax(240px, 0.92fr) minmax(210px, 0.95fr); -} - -.pv-filter-grid-archive { - grid-template-columns: minmax(240px, 1.35fr) minmax(180px, 0.9fr) repeat(2, minmax(160px, 1fr)); -} - -.pv-segmented { - min-width: 0; -} - -.pv-segmented-label { - margin-bottom: 0.35rem; - font-size: 0.75rem; - color: var(--tblr-secondary); -} - -.pv-segmented-group { - display: flex !important; - flex-wrap: wrap; - gap: 0.45rem; -} - -.pv-segmented-group > .btn { - border-radius: 0.65rem !important; - flex: 1 0 auto; -} - -.pv-filter-field { - min-width: 0; -} - -@media (max-width: 992px) { - .pv-filter-grid, - .pv-filter-grid-live, - .pv-filter-grid-archive { - grid-template-columns: 1fr; - } -} - - -.pv-card .btn.text-start { - white-space: normal; -} - -.text-break { - word-break: break-word; -} - - -.pv-subnav-shell { - min-height: 4rem; -} - -.pv-menu-tab { - display: inline-flex !important; - align-items: center; - gap: 0.55rem; - border: 1px solid transparent !important; - border-radius: 999px !important; - padding: 0.6rem 0.95rem !important; - background: rgba(127, 127, 127, 0.08) !important; - color: inherit !important; - font-weight: 500; -} - -.pv-menu-tab:hover, -.pv-menu-tab:focus { - background: rgba(127, 127, 127, 0.14) !important; -} - -.pv-menu-tab.active { - background: rgba(32, 107, 196, 0.14) !important; - border-color: rgba(32, 107, 196, 0.22) !important; - color: var(--tblr-primary) !important; - box-shadow: 0 0 0 1px rgba(32, 107, 196, 0.06) inset; -} - -.pv-menu-tab-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.1rem; - min-width: 1.1rem; -} - -.pv-subnav-side { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: flex-end; - gap: 0.85rem; - min-width: 0; - margin-left: auto; -} - -.pv-subnav-refresh-group { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: flex-end; - gap: 0.5rem; - min-width: 0; -} - -.pv-menu-meta { - display: inline-flex; - align-items: center; - gap: 0.45rem; - color: var(--tblr-secondary); - font-size: 0.875rem; - white-space: nowrap; - min-width: 0; -} - -@media (max-width: 1200px) { - .pv-subnav-side { - width: 100%; - justify-content: space-between; - } -} - -@media (max-width: 992px) { - .pv-subnav-side { - width: 100%; - flex-direction: column; - align-items: stretch; - } - - .pv-subnav-refresh-group { - width: 100%; - flex-direction: column; - align-items: stretch; - } - - .pv-menu-meta { - width: 100%; - justify-content: center; - white-space: normal; - } -} - - -.pv-date-field, -.pv-filter-field { - min-width: 0; -} - -.pv-date-input { - min-height: 2.8rem; - font-size: 0.95rem; -} - -.pv-action-tile, -.pv-chip-button { - border-radius: 0.9rem !important; -} - -.pv-order-card { - border: 1px solid var(--tblr-border-color, rgba(15, 23, 42, 0.12)); - border-radius: 1rem; - padding: 0.9rem 1rem; - background: color-mix(in srgb, var(--tblr-bg-surface, #fff) 90%, transparent); -} - -.pv-order-index { - width: 2rem; - min-width: 2rem; - height: 2rem; - border-radius: 999px; - display: inline-flex; - align-items: center; - justify-content: center; - font-weight: 700; - background: rgba(32, 107, 196, 0.12); - color: var(--tblr-primary, #206bc4); -} - -@media (max-width: 992px) { - .page-header .col-auto.ms-auto { - width: 100%; - } -} - -@media (max-width: 576px) { - .pv-segmented-group { - display: grid !important; - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .pv-segmented-group > .btn, - .pv-chip-button, - .pv-action-tile, - .pv-order-card .btn { - width: 100%; - } - - .pv-filter-grid-archive { - grid-template-columns: 1fr; - } - - .pv-date-input { - min-height: 3rem; - } - - .pv-order-card .btn-list { - width: 100%; - display: grid; - grid-template-columns: 1fr; - gap: 0.5rem; - } -} - -.login-layout { - min-height: 72vh; -} - -.login-card-enhanced { - overflow: hidden; - border-width: 1px; -} - -.login-card-enhanced::before { - content: ""; - position: absolute; - inset: 0 0 auto 0; - height: 5px; - background: linear-gradient(90deg, rgba(32, 107, 196, 0.95), rgba(47, 179, 68, 0.9)); -} - -.login-card-enhanced .card-body { - position: relative; -} - -.login-showcase-card { - border: 1px solid rgba(32, 107, 196, 0.14); - border-radius: 1rem; - padding: 2rem; - background: - radial-gradient(circle at top right, rgba(32, 107, 196, 0.18), transparent 30%), - rgba(255, 255, 255, 0.74); - box-shadow: var(--pv-card-shadow); -} - -[data-bs-theme="dark"] .login-showcase-card { - background: - radial-gradient(circle at top right, rgba(32, 107, 196, 0.22), transparent 32%), - rgba(11, 18, 32, 0.82); - border-color: rgba(148, 163, 184, 0.18); -} - -.login-showcase-badge { - display: inline-flex; - align-items: center; - gap: 0.4rem; - padding: 0.45rem 0.75rem; - border-radius: 999px; - background: rgba(32, 107, 196, 0.12); - color: #206bc4; - font-size: 0.8rem; - font-weight: 600; - margin-bottom: 1rem; -} - -.login-showcase-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.85rem; -} - -.login-stat-card { - padding: 1rem; - border-radius: 0.9rem; - border: 1px solid rgba(148, 163, 184, 0.18); - background: rgba(255, 255, 255, 0.78); -} - -[data-bs-theme="dark"] .login-stat-card { - background: rgba(15, 23, 42, 0.84); - border-color: rgba(148, 163, 184, 0.14); -} - -.login-input-stack { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.login-submit-button { - margin-top: 0.25rem; -} - -.login-footer-note { - margin-top: 1rem; - padding-top: 1rem; - border-top: 1px solid rgba(148, 163, 184, 0.18); - color: var(--tblr-secondary); - font-size: 0.88rem; -} - - -.pv-page-header-actions { - width: 100%; - display: flex; - justify-content: flex-end; -} - -.pv-page-header-actions--wide { - width: min(100%, 920px); - margin-left: auto; -} - -.pv-page-header-actions--right { - justify-items: end; - align-items: end; -} - -.pv-page-header-actions--right > :first-child { - justify-self: stretch; -} - -.pv-page-header-range-grid { - display: grid; - grid-template-columns: minmax(260px, 1fr) minmax(220px, 0.92fr); - gap: 0.75rem; - width: min(100%, 620px); -} - -.pv-page-header-range-grid--single { - grid-template-columns: minmax(280px, 1fr); - width: min(100%, 420px); -} - -.pv-refresh-panel { - min-width: 240px; -} - -.pv-refresh-panel--compact { - width: min(100%, 176px); - min-width: 0; -} - -.pv-refresh-switch { - position: relative; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - align-items: center; - width: 100%; - min-height: 3rem; - padding: 0.28rem; - border: 1px solid color-mix(in srgb, var(--tblr-border-color, rgba(148, 163, 184, 0.2)) 90%, transparent); - border-radius: 999px; - background: color-mix(in srgb, var(--tblr-bg-surface, #fff) 88%, rgba(32, 107, 196, 0.05)); - color: var(--tblr-secondary); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); - overflow: hidden; - transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; -} - -.pv-refresh-switch:hover, -.pv-refresh-switch:focus-visible { - border-color: color-mix(in srgb, var(--tblr-primary, #206bc4) 36%, var(--tblr-border-color, rgba(148, 163, 184, 0.18))); - box-shadow: 0 0 0 0.12rem rgba(32, 107, 196, 0.12); -} - -.pv-refresh-panel--compact .pv-refresh-switch { - min-height: 2.5rem; - padding: 0.18rem; -} - -.pv-refresh-switch-thumb { - position: absolute; - top: 0.28rem; - bottom: 0.28rem; - left: 0.28rem; - width: calc(50% - 0.28rem); - border-radius: 999px; - background: linear-gradient(135deg, rgba(32, 107, 196, 0.96), rgba(52, 118, 220, 0.86)); - box-shadow: 0 10px 24px rgba(32, 107, 196, 0.2); - transition: transform 0.22s ease; -} - -.pv-refresh-switch.is-paused .pv-refresh-switch-thumb { - transform: translateX(100%); - background: linear-gradient(135deg, rgba(100, 116, 139, 0.96), rgba(71, 85, 105, 0.86)); - box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18); -} - -.pv-refresh-switch-option { - position: relative; - z-index: 1; - min-width: 0; - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 2.35rem; - padding: 0 0.75rem; - font-size: 0.92rem; - font-weight: 700; - color: var(--tblr-secondary); - transition: color 0.18s ease, opacity 0.18s ease; -} - -.pv-refresh-panel--compact .pv-refresh-switch-thumb { - top: 0.18rem; - bottom: 0.18rem; - left: 0.18rem; - width: calc(50% - 0.18rem); -} - -.pv-refresh-panel--compact .pv-refresh-switch-option { - min-height: 1.95rem; - padding: 0 0.7rem; - font-size: 0.84rem; -} - -.pv-refresh-switch.is-auto .pv-refresh-switch-option--auto, -.pv-refresh-switch.is-paused .pv-refresh-switch-option--paused { - color: #fff; -} - -.pv-refresh-switch.is-auto .pv-refresh-switch-option--paused, -.pv-refresh-switch.is-paused .pv-refresh-switch-option--auto { - opacity: 0.82; -} - -.pv-chart-footer-controls { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)) minmax(220px, 1.1fr); - gap: 0.85rem; - align-items: end; -} - -.pv-chart-footer-status { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -@media (max-width: 992px) { - .pv-chart-footer-controls, - .pv-page-header-range-grid, - .pv-page-header-range-grid--single { - grid-template-columns: 1fr; - width: 100%; - } - - .pv-page-header-actions, - .pv-page-header-actions--wide { - width: 100%; - } -} - -@media (max-width: 576px) { - .pv-refresh-panel { - min-width: 0; - } - - .pv-refresh-switch-option { - font-size: 0.88rem; - padding: 0 0.55rem; - } -}