From d992b9651cdc64de3cfa995cc017eabea6a8dd31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 27 Mar 2026 08:37:08 +0100 Subject: [PATCH] ux fixes --- frontend/src/App.tsx.bak | 433 --------------------------------------- frontend/vite.config.ts | 2 +- 2 files changed, 1 insertion(+), 434 deletions(-) delete mode 100644 frontend/src/App.tsx.bak diff --git a/frontend/src/App.tsx.bak b/frontend/src/App.tsx.bak deleted file mode 100644 index 08e1770..0000000 --- a/frontend/src/App.tsx.bak +++ /dev/null @@ -1,433 +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, - IconLock, - 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, - DistributionPayload, - 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 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", - liveMetrics: "pv-live-metrics-v4", - archiveMetrics: "pv-archive-metrics-v4", -}; - -const DEFAULT_KIOSK_WIDGETS: WidgetId[] = ["hero", "history", "strings", "status", "production", "comparison", "importStatus"]; -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 PUBLIC_KIOSK = new URL(window.location.href).searchParams.get("publicKiosk") === "1"; - -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: "distribution", tab: "analytics", icon: IconChartBar }, - { 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 { - const url = new URL(window.location.href); - return url.searchParams.get("mode") === "kiosk" ? "kiosk" : "normal"; -} -function syncViewModeToLocation(mode: ViewMode): void { - 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, "chartPowerHistory"), - 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 buildLiveHistoryOption(history: HistoryPayload | undefined, theme: ThemeMode, language: Language): EChartsOption { - const palette = buildTablerChartTheme(theme); - const series = history?.series ?? []; - return { - color: palette.series, - tooltip: { trigger: "axis", backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text } }, - legend: { top: 0, textStyle: { color: palette.text }, itemGap: 16 }, - grid: { left: 12, right: 16, top: 48, bottom: 12, 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, localeForLanguage(language))) }, - yAxis: { type: "value", axisLabel: { color: palette.text }, splitLine: { lineStyle: { color: palette.grid } } }, - series: series.map((item, index) => ({ name: item.label, type: "line", smooth: true, connectNulls: true, showSymbol: false, 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); - return { - color: [palette.series[0]], - tooltip: { trigger: "axis", backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text }, valueFormatter: (value) => formatValue(Number(value), unit, 2, localeForLanguage(language)) }, - 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 }, 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): EChartsOption { - const palette = buildTablerChartTheme(theme); - const current = data?.current ?? []; - 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) })); - return { - color: palette.series, - tooltip: { trigger: "axis", axisPointer: { type: "shadow" }, backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text } }, - legend: { top: 0, textStyle: { color: palette.text } }, - grid: { left: 16, right: 20, top: 42, 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", axisLabel: { color: palette.text }, splitLine: { lineStyle: { color: palette.grid } } }, - series: [ - { name: t(language, "currentPeriod"), type: "bar", barMaxWidth: 18, data: current.map((point) => point.value) }, - ...comparisonSeries.map((seriesItem, index) => ({ name: seriesItem.label, type: "bar" as const, barMaxWidth: 18, data: current.map((_, pointIndex) => seriesItem.points[pointIndex]?.value ?? 0), itemStyle: { opacity: index === 0 ? 0.9 : 0.75 } })), - ], - }; -} -function buildPieOption(data: DistributionPayload | undefined, theme: ThemeMode): EChartsOption { - const palette = buildTablerChartTheme(theme); - 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 } }, - grid: { left: 16, right: 28, top: 8, bottom: 8, containLabel: true }, - xAxis: { type: "value", axisLabel: { color: palette.text }, 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: `${item.share}%`, 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 archiveRangeOptions(language: Language) { - return [ - { key: "1d", label: language === "en" ? "1 day" : "1 dzień" }, - { key: "3d", label: "3d" }, - { key: "7d", label: "7d" }, - { key: "14d", label: "14d" }, - { key: "30d", label: "30d" }, - { key: "60d", label: "60d" }, - { key: "custom", label: language === "en" ? "Custom" : "Ręczny" }, - ]; -} -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 getMetricCandidates(snapshot: SnapshotPayload, config?: DashboardConfig) { - const fromConfig = (config?.visible_entities ?? []) - .filter((item) => item.kind === "gauge") - .map((item) => ({ metric_id: item.metric_id, label: item.label, unit: item.unit })); - const map = new Map(); - fromConfig.forEach((item) => map.set(item.metric_id, item)); - return [...map.values()]; -} - -export default function App() { - const queryClient = useQueryClient(); - const publicMode = PUBLIC_KIOSK; - 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: authenticated || authEnabled === false, staleTime: 30_000 }); - const publicKioskSettingsQuery = useQuery({ queryKey: ["kiosk-settings", "public"], queryFn: () => api.getKioskSettings("public"), enabled: authenticated || authEnabled === false || publicMode, 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("6h"); - 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: "Porównanie 1", start: "", end: "" }, { key: "cmp_2", label: "Porównanie 2", start: "", end: "" }]); - const [archiveStart, setArchiveStart] = useState(""); - const [archiveEnd, setArchiveEnd] = useState(""); - const [archiveRange, setArchiveRange] = useState("1d"); - const [liveMetrics, setLiveMetrics] = useState(() => readStorage(STORAGE_KEYS.liveMetrics, DEFAULT_LIVE_METRICS)); - const [archiveMetrics, setArchiveMetrics] = useState(() => readStorage(STORAGE_KEYS.archiveMetrics, DEFAULT_LIVE_METRICS)); - 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, realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }); - const [publicKioskDraft, setPublicKioskDraft] = useState({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }); - 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 initializedRef = useRef(false); - - 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) setPrivateKioskDraft(privateKioskSettingsQuery.data); }, [privateKioskSettingsQuery.data]); - useEffect(() => { if (publicKioskSettingsQuery.data) setPublicKioskDraft(publicKioskSettingsQuery.data); }, [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.liveMetrics, liveMetrics); }, [liveMetrics]); - useEffect(() => { writeStorage(STORAGE_KEYS.archiveMetrics, archiveMetrics); }, [archiveMetrics]); - - const dataEnabled = authenticated || authEnabled === false; - const { snapshot, connected, lastUpdated } = useRealtimeSocket(dataEnabled); - const metricCandidates = useMemo(() => getMetricCandidates(snapshot, config), [snapshot, config]); - useEffect(() => { - if (!metricCandidates.length) return; - const allowed = new Set(metricCandidates.map((item) => item.metric_id)); - setLiveMetrics((current) => { - const filtered = current.filter((item) => allowed.has(item)); - return filtered.length ? filtered : metricCandidates.slice(0, 3).map((item) => item.metric_id); - }); - setArchiveMetrics((current) => { - const filtered = current.filter((item) => allowed.has(item)); - return filtered.length ? filtered : metricCandidates.slice(0, 3).map((item) => item.metric_id); - }); - }, [metricCandidates]); - const liveHistoryMetrics = useMemo(() => liveMetrics.filter((item) => item !== "inverter_temp"), [liveMetrics]); - const effectiveKioskSettings = publicMode ? publicKioskDraft : privateKioskDraft; - const kioskActive = publicMode || 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, { metrics: liveHistoryMetrics, publicKiosk: publicMode }); - 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(dataEnabled && !publicMode); - const archiveQuery = useRealtimeHistory(archiveStart && archiveEnd ? "custom" : archiveRange, dataEnabled, { start: archiveStart || undefined, end: archiveEnd || undefined, metrics: archiveMetrics, publicKiosk: publicMode }); - const usersQuery = useQuery({ queryKey: ["auth-users"], queryFn: api.getUsers, enabled: dataEnabled && (authQuery.data?.role === "admin"), staleTime: 15_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 saveKioskSettingsMutation = useMutation({ mutationFn: (payload: KioskSettingsPayload) => api.saveKioskSettings(payload), onSuccess: async (_, payload) => { await queryClient.invalidateQueries({ queryKey: ["kiosk-settings", payload.mode] }); } }); - - 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 summary = analyticsQuery.production.data?.summary; - const topStatus = snapshot.status ?? []; - const heroCards = snapshot.hero_cards.filter((card) => blockConfig.hero.includes(card.metric_id)); - const quickMetrics = Object.values(snapshot.kpis ?? {}).filter((metric) => blockConfig.quick.includes(metric.metric_id)); - const isAdmin = authQuery.data?.role === "admin"; - const kioskUrl = `${window.location.origin}${window.location.pathname}?mode=kiosk&publicKiosk=1`; - - const allWidgets: Record = { - hero: , - quickMetrics: , - history: , - status: , - strings: , - production: , - comparison: compare !== "none" ? : null, - distribution: , - importStatus: , - }; - const renderWidget = (widgetId: WidgetId) => { const content = allWidgets[widgetId]; if (!content) return null; 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 ? : null} - {!publicMode ? : null} -
-
-
- ); - const menu = ( -
    - } active={activeTab === "realtime"} onClick={() => setActiveTab("realtime")} label={language === "en" ? "Live" : "Live"} /> - } active={activeTab === "archive"} onClick={() => setActiveTab("archive")} label={language === "en" ? "Historical live" : "Dane chwilowe"} /> - } active={activeTab === "analytics"} onClick={() => setActiveTab("analytics")} label={t(language, "analytics")} /> - } active={activeTab === "warehouse"} onClick={() => setActiveTab("warehouse")} label={language === "en" ? "Data warehouse" : "Hurtownia danych"} /> - } active={activeTab === "kiosk"} onClick={() => setActiveTab("kiosk")} label={t(language, "kiosk")} /> - } active={activeTab === "settings"} onClick={() => setActiveTab("settings")} label={t(language, "settings")} /> -
{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" && <>
{renderWidget("hero")}
{allWidgets.quickMetrics}
{allWidgets.history}
{allWidgets.status}
{allWidgets.strings}
} - - {activeTab === "archive" && <>
{ setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={archiveRangeOptions(language)} />
{ setArchiveRange("custom"); setArchiveStart(e.target.value); }} />
{ setArchiveRange("custom"); setArchiveEnd(e.target.value); }} />
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"].includes(item.key)).map((item) => ({ key: item.key, label: 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.distribution}
{compare !== "none" ?
{allWidgets.comparison}
: null}
} - - {activeTab === "warehouse" && <>
historical.start.mutate(payload)} onSyncNow={() => historical.syncNow.mutate()} onCancel={() => historical.cancel.mutate()} />
} - - {activeTab === "kiosk" && <>
kioskEditorMode === "public" ? setPublicKioskDraft(value) : setPrivateKioskDraft(value)} onModeChange={setKioskEditorMode} selectedMode={kioskEditorMode} labels={widgetLabels} buckets={config.capabilities.buckets} compareModes={config.capabilities.comparison_modes} saving={saveKioskSettingsMutation.isPending && saveKioskSettingsMutation.variables?.mode === kioskEditorMode} onSave={() => saveKioskSettingsMutation.mutate(kioskEditorMode === "public" ? publicKioskDraft : privateKioskDraft)} />
} - - {activeTab === "settings" && <>
item.metric_id !== "energy_total")} selected={liveMetrics.filter((item) => item !== "energy_total")} onChange={setLiveMetrics} />
{isAdmin ?
createUserMutation.mutate()} passwordReset={passwordReset} onPasswordResetChange={setPasswordReset} onResetPassword={() => resetPasswordMutation.mutate()} />
: 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) { - 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}
; } -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 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 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, card.unit === "kWh" ? 2 : 2, locale)}
    {card.subtitle}
    )}
    ; } -function QuickMetrics({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return

    {t(language, "quickMetrics")}

    {metrics.map((metric) =>
    {iconForMetric(metric.metric_id)}
    {labelForMetric(language, metric.metric_id, metric.label)}
    {metric.unit || t(language, "status")}
    {formatValue(metric.value, metric.unit, 2, locale)}
    )}
    ; } -function LiveHistoryPanel({ data, language, theme, title, subtitle }: { data?: HistoryPayload; language: Language; theme: ThemeMode; title: string; subtitle: string }) { return

    {title}

    {subtitle}
    ; } -function StatusPanel({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return

    {t(language, "systemStatus")}

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

    {t(language, "strings")}

    {rows.length === 0 ?
    {t(language, "noDataDescription")}
    : rows.map((row) =>
    {row.label}
    DC
    {Object.values(row.values).map((metric) =>
    {labelForMetric(language, metric.metric_id, metric.label)}{formatValue(metric.value, metric.unit, 2, locale)}
    )}
    )}
    ; } -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 }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) { return

    {t(language, "chartComparison")}

    ; } -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 StatusStat({ label, value }: { label: string; value: string }) { return
    {label}
    {value}
    ; } -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 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

    {t(language, "kioskLayout")}

    {t(language, "kioskLayoutSubtitle")}
    {t(language, "saveLayout")}
    {t(language, "selected")}
    {selected.map((id) =>
    {labels.get(id)}
    )}
    {t(language, "available")}
    {unselected.map((id) => )}
    ; } -function KioskSettingsEditorPanel({ language, value, onChange, onSave, selectedMode, onModeChange, labels, buckets, compareModes, saving }: { language: Language; value: KioskSettingsPayload; onChange: (value: KioskSettingsPayload) => void; onSave: () => void; selectedMode: "public" | "private"; onModeChange: (value: "public" | "private") => void; labels: Map; buckets: Array<{ key: string; label: string }>; compareModes: string[]; saving: boolean; }) { const widgets = toWidgetIds(value.widgets); return

    {language === "en" ? "Kiosk settings" : "Ustawienia kiosku"}

    onChange({ ...value, widgets: widgetsValue })} labels={labels} />
    ; } -function KioskLinkPanel({ language, kioskUrl, settings }: { language: Language; kioskUrl: string; settings: KioskSettingsPayload }) { const [copied, setCopied] = useState(false); return

    {language === "en" ? "Public kiosk link" : "Publiczny link kiosku"}

    {language === "en" ? "This link opens kiosk mode without login for read-only public display." : "Ten link otwiera kiosk bez logowania, tylko do publicznego podglądu."}
    {language === "en" ? "Current public ranges:" : "Aktualne zakresy publiczne:"} live {settings.realtime_range}, analytics {settings.analytics_range}
    ; } -function AppearanceSecurityPanel({ language, theme, setTheme, viewMode, setViewMode, authEnabled, userName }: { language: Language; theme: ThemeMode; setTheme: (value: ThemeMode) => void; viewMode: ViewMode; setViewMode: (value: ViewMode) => void; authEnabled: boolean; userName: string; }) { return

    {t(language, "theme")}

    {t(language, "viewMode")}

    {t(language, "security")}

    {authEnabled ? t(language, "authEnabled") : t(language, "authDisabled")}
    {language === "en" ? "Admin user management is available below." : "Zarządzanie użytkownikami admina jest dostępne niżej."}
    {userName ?
    {userName}
    : null}
    ; } -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}

    {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 BlockVisibilityPanel({ language, items, config, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; config: Record; onChange: (value: Record) => void; }) { const toggle = (target: BlockTarget, metricId: string) => { const selected = config[target]; onChange({ ...config, [target]: selected.includes(metricId) ? selected.filter((item) => item !== metricId) : [...selected, metricId] }); }; return

    {language === "en" ? "Metric visibility in blocks" : "Widoczność metryk w blokach"}

    Hero metrics
    {items.map((item) => )}
    Quick metrics
    {items.map((item) => )}
    ; } -function AdminUsersPanel({ language, users, newUser, onNewUserChange, onCreate, passwordReset, onPasswordResetChange, onResetPassword }: { language: Language; users: AuthUsersPayload["items"]; 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; }) { 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 })} />
    {users.map((user) => )}
    {language === "en" ? "Username" : "Login"}{language === "en" ? "Display name" : "Nazwa"}Role{language === "en" ? "Updated" : "Aktualizacja"}
    {user.username}{user.display_name}{user.role}{formatDateTime(user.updated_at, language === "en" ? "en-GB" : "pl-PL")}
    ; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4408032..49e2a6d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,7 +3,7 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; const noStoreHeaders = { - "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate", + "Cache-Control": "no-store, no-cache", Pragma: "no-cache", Expires: "0", };