From 84fe898a744f2c8c8f96ecb04ba64852a0c17547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 24 Mar 2026 15:49:06 +0100 Subject: [PATCH] fix ux --- backend/app/models/definitions.py | 2 +- backend/app/routes/dashboard.py | 43 ++++++++++-- backend/app/routes/historical.py | 18 +++++ backend/app/services/analytics.py | 15 ++-- backend/app/services/energy.py | 8 ++- backend/app/services/kiosk_settings.py | 9 ++- frontend/src/App.tsx | 95 ++++++++++++++++++++------ frontend/src/index.css | 53 ++++++++++++++ frontend/src/types.ts | 2 +- 9 files changed, 203 insertions(+), 42 deletions(-) diff --git a/backend/app/models/definitions.py b/backend/app/models/definitions.py index 2d067f3..890da1d 100644 --- a/backend/app/models/definitions.py +++ b/backend/app/models/definitions.py @@ -79,7 +79,7 @@ class BucketPoint: label: str start: datetime end: datetime - value: float + value: float | None @dataclass diff --git a/backend/app/routes/dashboard.py b/backend/app/routes/dashboard.py index 7379bfb..e370b40 100644 --- a/backend/app/routes/dashboard.py +++ b/backend/app/routes/dashboard.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone import os import platform -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify, request, session from app.core_settings import get_settings from app.services.capabilities import build_capabilities @@ -19,6 +19,27 @@ dashboard_blueprint = Blueprint("dashboard", __name__) _APP_STARTED_AT = datetime.now(timezone.utc) +def _resolve_kiosk_mode(requested_mode: str, require_write_access: bool = False) -> tuple[str, str]: + normalized_mode = (requested_mode or "private").strip().lower() + auth_service = get_auth_service() + + if normalized_mode == "public": + if require_write_access and auth_service.enabled and session.get("auth_role") != "admin": + raise PermissionError("Brak uprawnien do edycji publicznego kiosku") + return "public", "public" + + if normalized_mode != "private": + raise ValueError("Mode musi byc jednym z: public, private") + + if (not auth_service.enabled) or session.get("auth_role") == "admin": + return "private", "private" + + username = session.get("auth_user") + if not username: + raise PermissionError("Authentication required") + return f"user:{username}", "private" + + @dashboard_blueprint.get("/dashboard/config") def dashboard_config(): settings = get_settings() @@ -65,8 +86,12 @@ def dashboard_config(): def dashboard_kiosk_settings(): requested_mode = request.args.get("mode") or ("public" if request.args.get("publicKiosk") == "1" else "private") try: - payload = get_kiosk_settings_service().get(requested_mode) + storage_mode, response_mode = _resolve_kiosk_mode(requested_mode) + payload = get_kiosk_settings_service().get(storage_mode) + payload["mode"] = response_mode return jsonify(to_plain(payload)) + except PermissionError as exc: + return jsonify({"detail": str(exc)}), 403 except ValueError as exc: return jsonify({"detail": str(exc)}), 400 @@ -74,11 +99,11 @@ def dashboard_kiosk_settings(): @dashboard_blueprint.put("/dashboard/kiosk-settings") def update_dashboard_kiosk_settings(): payload = request.get_json(silent=True) or {} - mode = payload.get("mode", "private") - auth_service = get_auth_service() + requested_mode = payload.get("mode", "private") try: - auth_service.require_admin() - updated = get_kiosk_settings_service().update_from_session(mode, payload) + storage_mode, response_mode = _resolve_kiosk_mode(requested_mode, require_write_access=True) + updated = get_kiosk_settings_service().update_from_session(storage_mode, payload) + updated["mode"] = response_mode return jsonify(to_plain(updated)) except PermissionError as exc: return jsonify({"detail": str(exc)}), 403 @@ -88,6 +113,12 @@ def update_dashboard_kiosk_settings(): @dashboard_blueprint.get("/dashboard/diagnostics") def dashboard_diagnostics(): + auth_service = get_auth_service() + try: + auth_service.require_admin() + except PermissionError as exc: + return jsonify({"detail": str(exc)}), 403 + settings = get_settings() influx_diagnostics = InfluxHTTPService(settings).diagnose() now = datetime.now(timezone.utc) diff --git a/backend/app/routes/historical.py b/backend/app/routes/historical.py index de44827..2bca119 100644 --- a/backend/app/routes/historical.py +++ b/backend/app/routes/historical.py @@ -4,20 +4,30 @@ from datetime import date from flask import Blueprint, jsonify, request +from app.services.auth import get_auth_service from app.services.historical_sync import get_historical_sync_service from app.utils.serialization import to_plain historical_blueprint = Blueprint("historical", __name__) service = get_historical_sync_service() +auth_service = get_auth_service() @historical_blueprint.get("/historical/status") def historical_status(): + try: + auth_service.require_admin() + except PermissionError as exc: + return jsonify({"detail": str(exc)}), 403 return jsonify(to_plain(service.status())) @historical_blueprint.post("/historical/start") def historical_start(): + try: + auth_service.require_admin() + except PermissionError as exc: + return jsonify({"detail": str(exc)}), 403 payload = request.get_json(silent=True) or {} try: status = service.start( @@ -35,6 +45,10 @@ def historical_start(): @historical_blueprint.post("/historical/sync-now") def historical_sync_now(): + try: + auth_service.require_admin() + except PermissionError as exc: + return jsonify({"detail": str(exc)}), 403 try: status = service.start(auto=True) return jsonify(to_plain(status)) @@ -44,6 +58,10 @@ def historical_sync_now(): @historical_blueprint.post("/historical/cancel") def historical_cancel(): + try: + auth_service.require_admin() + except PermissionError as exc: + return jsonify({"detail": str(exc)}), 403 return jsonify(to_plain(service.cancel())) diff --git a/backend/app/services/analytics.py b/backend/app/services/analytics.py index 275c725..2ef2670 100644 --- a/backend/app/services/analytics.py +++ b/backend/app/services/analytics.py @@ -37,7 +37,7 @@ class AnalyticsService: window = resolve_window(range_key=range_key, start=start, end=end) current_days = self.energy.daily_records_for_window(window.start, window.end, persist_missing=True) current = self.energy.bucketize_daily(current_days, bucket) - total = round(sum(item.value for item in current), 2) + total = round(sum((item.value or 0.0) for item in current), 2) comparison = [] comparison_total = None @@ -52,7 +52,7 @@ class AnalyticsService: compare_window = resolve_window(start=compare_start, end=compare_end) comparison_days = self.energy.daily_records_for_window(compare_window.start, compare_window.end, persist_missing=True) comparison_series = self.energy.bucketize_daily(comparison_days, bucket) - comparison_total_value = round(sum(point.value for point in comparison_series), 2) + comparison_total_value = round(sum((point.value or 0.0) for point in comparison_series), 2) comparisons.append({ "key": item.get("key") or f"custom_{index + 1}", "label": item.get("label") or f"Custom {index + 1}", @@ -70,7 +70,7 @@ class AnalyticsService: compare_window = shift_window(window, compare_mode) comparison_days = self.energy.daily_records_for_window(compare_window.start, compare_window.end, persist_missing=True) comparison = self.energy.bucketize_daily(comparison_days, bucket) - comparison_total = round(sum(item.value for item in comparison), 2) + comparison_total = round(sum((item.value or 0.0) for item in comparison), 2) comparison_delta_pct = compare_delta_pct(total, comparison_total) comparisons.append({ "key": compare_mode, @@ -82,8 +82,9 @@ class AnalyticsService: "points": comparison, }) - average_bucket = round(total / len(current), 2) if current else 0.0 - best_bucket = max(current, key=lambda item: item.value, default=None) + populated_current = [item for item in current if item.value is not None] + average_bucket = round(total / len(populated_current), 2) if populated_current else 0.0 + best_bucket = max(populated_current, key=lambda item: item.value or 0.0, default=None) return { "unit": "kWh", @@ -121,7 +122,7 @@ class AnalyticsService: ) -> dict: payload = self.production(range_key=range_key, bucket=bucket, compare_mode="none", start=start, end=end) current = payload["current"] - total = round(sum(item.value for item in current), 2) + total = round(sum((item.value or 0.0) for item in current), 2) denominator = total or 1.0 return { "unit": payload["unit"], @@ -134,7 +135,7 @@ class AnalyticsService: "share": round((item.value / denominator) * 100.0, 2), } for item in current - if item.value > 0 + if item.value is not None and item.value > 0 ], "meta": payload["meta"], } diff --git a/backend/app/services/energy.py b/backend/app/services/energy.py index cefffb2..fdf2065 100644 --- a/backend/app/services/energy.py +++ b/backend/app/services/energy.py @@ -104,7 +104,7 @@ class EnergyService: return rows def bucketize_daily(self, records: list[DailyEnergyRecord], bucket: str) -> list[BucketPoint]: - grouped: dict[str, dict] = defaultdict(lambda: {"value": 0.0, "start": None, "end": None, "label": ""}) + grouped: dict[str, dict] = defaultdict(lambda: {"value": 0.0, "start": None, "end": None, "label": "", "has_data": False}) for record in records: start = datetime.combine(record.day, time.min, tzinfo=self.tz) @@ -137,7 +137,9 @@ class EnergyService: current = grouped[key] current["label"] = label - current["value"] += record.energy_kwh + if record.samples_count > 0: + current["value"] += record.energy_kwh + current["has_data"] = True current["start"] = bucket_start if current["start"] is None else min(current["start"], bucket_start) current["end"] = bucket_end if current["end"] is None else max(current["end"], bucket_end) @@ -149,7 +151,7 @@ class EnergyService: label=item["label"], start=item["start"], end=item["end"], - value=round(item["value"], 2), + value=round(item["value"], 2) if item["has_data"] else None, ) ) return rows diff --git a/backend/app/services/kiosk_settings.py b/backend/app/services/kiosk_settings.py index 650728f..75bb495 100644 --- a/backend/app/services/kiosk_settings.py +++ b/backend/app/services/kiosk_settings.py @@ -9,6 +9,7 @@ from app.storage.kiosk_settings import SQLiteKioskSettingsRepository VALID_MODES = {"public", "private"} +USER_MODE_PREFIX = "user:" DEFAULT_WIDGETS = ["hero", "history", "strings", "status", "production", "comparison", "importStatus"] VALID_WIDGETS = {"hero", "quickMetrics", "history", "status", "strings", "production", "comparison", "distribution", "importStatus"} VALID_REALTIME_RANGES = {"today", "yesterday", "6h", "12h", "24h", "48h", "7d"} @@ -66,9 +67,11 @@ class KioskSettingsService: def _normalize_mode(self, mode: str) -> str: normalized = (mode or "").strip().lower() - if normalized not in VALID_MODES: - raise ValueError("Mode musi byc jednym z: public, private") - return normalized + if normalized in VALID_MODES: + return normalized + if normalized.startswith(USER_MODE_PREFIX) and len(normalized) > len(USER_MODE_PREFIX): + return normalized + raise ValueError("Mode musi byc jednym z: public, private") def _normalize_widgets(self, widgets: Any) -> list[str]: if not isinstance(widgets, list): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 44b7a21..bb7b9cf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -174,7 +174,7 @@ function buildBarOption(points: BucketPoint[], unit: string, theme: ThemeMode, l const locale = localeForLanguage(language); 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, locale) }, + 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 } } }, @@ -200,7 +200,7 @@ function buildComparisonOption(data: AnalyticsPayload | undefined, theme: ThemeM formatter: (params: any) => { const rows = Array.isArray(params) ? params : [params]; const header = rows[0]?.axisValueLabel ?? ""; - return [header, ...rows.map((row) => `${row.marker ?? ""} ${row.seriesName}: ${formatValue(Number(row.value), unit, 2, locale)}`)].join("
"); + return [header, ...rows.map((row) => `${row.marker ?? ""} ${row.seriesName}: ${row.value == null ? "--" : formatValue(Number(row.value), unit, 2, locale)}`)].join("
"); }, }, legend: verticalLegend @@ -331,6 +331,14 @@ function trimSingleDayHistory(history: HistoryPayload | undefined, mode: string) })), }; } +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")); } @@ -428,6 +436,12 @@ export default function App() { useEffect(() => { writeStorage(STORAGE_KEYS.archiveMetrics, archiveMetrics); }, [archiveMetrics]); 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 metricCandidates = useMemo(() => getMetricCandidates(snapshot, config), [snapshot, config]); useEffect(() => { @@ -454,12 +468,12 @@ export default function App() { 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 historical = useHistoricalImport(hasWarehouseAccess); const archiveQuery = useRealtimeHistory(archiveStart && archiveEnd ? "custom" : archiveRange, dataEnabled, { start: archiveStart || undefined, end: archiveEnd || undefined, metrics: archiveMetrics, publicKiosk: publicMode }); - const liveHistoryData = useMemo(() => trimSingleDayHistory(historyQuery.data, effectiveRealtimeRange), [historyQuery.data, effectiveRealtimeRange]); - const archiveHistoryData = useMemo(() => trimSingleDayHistory(archiveQuery.data, archiveRange), [archiveQuery.data, archiveRange]); - const usersQuery = useQuery({ queryKey: ["auth-users"], queryFn: api.getUsers, enabled: dataEnabled && (authQuery.data?.role === "admin"), staleTime: 15_000 }); - const diagnosticsQuery = useQuery({ queryKey: ["diagnostics"], queryFn: api.getDiagnostics, enabled: dataEnabled && !publicMode, staleTime: 20_000 }); + const liveHistoryData = useMemo(() => filterHistoryByMetrics(trimSingleDayHistory(historyQuery.data, effectiveRealtimeRange), liveHistoryMetrics), [historyQuery.data, effectiveRealtimeRange, 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"] }); } }); @@ -486,17 +500,29 @@ export default function App() { setKioskSaveNotice((current) => ({ ...current, [mode]: null })); }; - const canPersistKioskSettings = !publicMode && (authEnabled === false || authQuery.data?.role === "admin"); + 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 (!canPersistKioskSettings || saveKioskSettingsMutation.isPending) return; + if (!canPersistCurrentKioskSettings || saveKioskSettingsMutation.isPending) return; const payload = kioskEditorMode === "public" ? publicKioskDraft : privateKioskDraft; setKioskSaveNotice((current) => ({ ...current, [payload.mode]: null })); saveKioskSettingsMutation.mutate(payload); @@ -508,7 +534,6 @@ export default function App() { 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 publicKioskUrl = `${window.location.origin}/kiosk/public`; const privateKioskUrl = `${window.location.origin}/kiosk/private`; @@ -535,6 +560,7 @@ export default function App() {
{config.app.site_name}
{t(language, "operatorPanel")}
{connected ? t(language, "connected") : t(language, "disconnected")} + {!publicMode && currentRole ? {currentRole} : null} {!publicMode ? : null} @@ -543,15 +569,38 @@ export default function App() {
); + 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 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)}
+
+
+
+
+ {menuTabs.map((item) => ( + + ))} +
+
+ + {t(language, "updatedAt")}: {formatDateTime(lastUpdated, locale)} +
+
+
+
); if (viewMode === "kiosk" || publicMode) { @@ -568,7 +617,7 @@ export default function App() { {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={canPersistKioskSettings} saveNotice={kioskSaveNotice[kioskEditorMode]} onSave={saveCurrentKioskSettings} onReset={() => resetKioskDraft(kioskEditorMode)} />
} + {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} />
} {activeTab === "settings" && <>
diagnosticsQuery.refetch()} />
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}
} @@ -621,8 +670,12 @@ function HistoricalPanel({ status, language, locale, compact = false }: { status 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" ? "3. Section order" : "3. 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 }: { 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; }) { const widgets = toWidgetIds(value.widgets); return

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

{language === "en" ? "First choose who will use the kiosk, then fine-tune what they see." : "Najpierw wybierz odbiorcę kiosku, potem dopracuj to co widzi."}
{dirty ? (language === "en" ? "Unsaved changes" : "Niezapisane zmiany") : (language === "en" ? "Up to date" : "Aktualne")}
onModeChange("private")} />
onModeChange("public")} />
onChange({ ...value, widgets: widgetsValue })} labels={labels} />
; } -function KioskLinkPanel({ language, publicKioskUrl, privateKioskUrl, publicSettings, privateSettings }: { language: Language; publicKioskUrl: string; privateKioskUrl: string; publicSettings: KioskSettingsPayload; privateSettings: KioskSettingsPayload }) { 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

{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" ? "Quick guidance" : "Szybka wskazówka"}

{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."}
; } +function KioskSettingsEditorPanel({ language, value, onChange, onSave, onReset, selectedMode, onModeChange, labels, buckets, compareModes, saving, dirty, canSave, saveNotice, allowPublicMode }: { 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; }) { + const widgets = toWidgetIds(value.widgets); + 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." : "Te ustawienia dotyczą tylko Twojego prywatnego kiosku po zalogowaniu.")}
{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} />
; +} + +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")} subtitle={language === "en" ? "Bright interface for office work." : "Jasny interfejs do pracy w dzień."} onClick={() => setTheme("light")} />
} title={t(language, "dark")} subtitle={language === "en" ? "Better contrast for dashboards and TV." : "Lepszy kontrast dla dashboardów i TV."} onClick={() => setTheme("dark")} />
{t(language, "viewMode")}
} title={t(language, "normalMode")} subtitle={language === "en" ? "Full navigation and controls." : "Pełna nawigacja i sterowanie."} onClick={() => setViewMode("normal")} />
} title={t(language, "kioskMode")} subtitle={language === "en" ? "Simplified screen for wall monitor." : "Uproszczony widok na ekran ścienny."} onClick={() => setViewMode("kiosk")} />
{language === "en" ? "Language" : "Język"}
} title="Polski" subtitle={language === "en" ? "Switch interface to Polish." : "Przełącz interfejs na polski."} onClick={() => setLanguage("pl")} />
} title="English" subtitle={language === "en" ? "Switch interface to English." : "Przełącz interfejs na angielski."} 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) => )}
; } diff --git a/frontend/src/index.css b/frontend/src/index.css index a7b4258..a486e15 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -209,3 +209,56 @@ body { .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-menu-meta { + display: inline-flex; + align-items: center; + gap: 0.45rem; + color: var(--tblr-secondary); + font-size: 0.875rem; + white-space: nowrap; +} + +@media (max-width: 992px) { + .pv-menu-meta { + width: 100%; + white-space: normal; + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 953e9fa..7248892 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -61,7 +61,7 @@ export interface BucketPoint { label: string; start: string; end: string; - value: number; + value: number | null; } export interface AnalyticsSummary {