first commit

This commit is contained in:
Mateusz Gruszczyński
2026-03-23 15:56:18 +01:00
commit c5cc2efbac
106 changed files with 10254 additions and 0 deletions

501
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,501 @@
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<BlockTarget, string[]> = {
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"];
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: "distribution", tab: "analytics", icon: IconChartBar },
{ id: "importStatus", tab: "warehouse", icon: IconDatabaseImport },
];
function readStorage<T>(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<T>(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 <IconTemperature size={18} />;
if (metricId.includes("energy")) return <IconChartBar size={18} />;
return <IconBolt size={18} />;
}
function buildWidgetLabel(language: Language, widgetId: WidgetId): string {
const labels: Record<WidgetId, string> = {
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<ThemeMode>(STORAGE_KEYS.theme, (config?.defaults.theme as ThemeMode) ?? "dark", (raw) => (raw === "light" ? "light" : "dark"));
}
function getInitialLanguage(config?: DashboardConfig): Language {
return normalizeLanguage(readStorage<string>(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<string, { metric_id: string; label: string; unit: string }>();
fromConfig.forEach((item) => map.set(item.metric_id, item));
return [...map.values()];
}
export default function App() {
const queryClient = useQueryClient();
const publicMode = PUBLIC_KIOSK;
const privateKioskRoute = PRIVATE_KIOSK_ROUTE;
const authQuery = useQuery<AuthStatus>({ 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<KioskSettingsPayload>({ queryKey: ["kiosk-settings", "private"], queryFn: () => api.getKioskSettings("private"), enabled: !publicMode && (authenticated || authEnabled === false), staleTime: 30_000 });
const publicKioskSettingsQuery = useQuery<KioskSettingsPayload>({ queryKey: ["kiosk-settings", "public"], queryFn: () => api.getKioskSettings("public"), enabled: publicMode || authenticated || authEnabled === false, staleTime: 30_000 });
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme(undefined));
const [language, setLanguage] = useState<Language>(() => getInitialLanguage(undefined));
const [activeTab, setActiveTab] = useState<TabKey>(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<Array<{ key: string; label: string; start: string; end: string }>>([{ 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<string[]>(() => readStorage(STORAGE_KEYS.liveMetrics, DEFAULT_LIVE_METRICS));
const [archiveMetrics, setArchiveMetrics] = useState<string[]>(() => readStorage(STORAGE_KEYS.archiveMetrics, DEFAULT_LIVE_METRICS));
const [viewMode, setViewMode] = useState<ViewMode>(() => { const fromUrl = parseViewModeFromLocation(); return fromUrl === "kiosk" ? fromUrl : readStorage<ViewMode>(STORAGE_KEYS.viewMode, "normal", (raw) => (raw === "kiosk" ? "kiosk" : "normal")); });
const [kioskWidgets, setKioskWidgets] = useState<WidgetId[]>(() => getVisibleWidgets(readStorage<WidgetId[]>(STORAGE_KEYS.kioskWidgets, DEFAULT_KIOSK_WIDGETS)));
const [kioskEditorMode, setKioskEditorMode] = useState<"private" | "public">("private");
const [privateKioskDraft, setPrivateKioskDraft] = useState<KioskSettingsPayload>({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" });
const [publicKioskDraft, setPublicKioskDraft] = useState<KioskSettingsPayload>({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" });
const [blockConfig, setBlockConfig] = useState<Record<BlockTarget, string[]>>(() => readStorage(STORAGE_KEYS.blockConfig, DEFAULT_BLOCK_CONFIG));
const [loginForm, setLoginForm] = useState({ username: "", password: "" });
const [loginError, setLoginError] = useState<string | null>(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<Record<"public" | "private", string | null>>({ public: null, private: null });
const initializedRef = useRef(false);
const defaultKioskSerializedRef = useRef<Record<"public" | "private", string>>({
public: JSON.stringify({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }),
private: JSON.stringify({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }),
});
const lastSyncedKioskRef = useRef<Record<"public" | "private", string>>({
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 };
lastSyncedKioskRef.current.private = JSON.stringify(normalized);
applyKioskDraftChange("private", normalized);
}, [privateKioskSettingsQuery.data]);
useEffect(() => {
if (!publicKioskSettingsQuery.data) return;
const normalized = { ...publicKioskSettingsQuery.data, mode: "public" as const };
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.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 || 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, { 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<AuthUsersPayload>({ 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 (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 };
if (mode === "public") setPublicKioskDraft(normalized); else setPrivateKioskDraft(normalized);
setKioskSaveNotice((current) => ({ ...current, [mode]: null }));
};
const canPersistKioskSettings = !publicMode && (authEnabled === false || authQuery.data?.role === "admin");
const privateKioskDirty = JSON.stringify(privateKioskDraft) !== lastSyncedKioskRef.current.private;
const publicKioskDirty = JSON.stringify(publicKioskDraft) !== lastSyncedKioskRef.current.public;
const currentKioskDirty = kioskEditorMode === "public" ? publicKioskDirty : privateKioskDirty;
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;
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<WidgetId, string>(); 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 publicKioskUrl = `${window.location.origin}/kiosk/public`;
const privateKioskUrl = `${window.location.origin}/kiosk/private`;
const allWidgets: Record<WidgetId, ReactElement | null> = {
hero: <HeroCards cards={heroCards} locale={locale} language={language} />,
quickMetrics: <QuickMetrics metrics={quickMetrics} locale={locale} language={language} />,
history: <LiveHistoryPanel data={historyQuery.data} language={language} theme={theme} title={t(language, "chartPowerHistory")} subtitle={t(language, "realtimeSubtitle")} />,
status: <StatusPanel metrics={topStatus} locale={locale} language={language} />,
strings: <StringsPanel rows={snapshot.strings} locale={locale} language={language} />,
production: <ProductionPanel data={analyticsQuery.production.data} language={language} theme={theme} />,
comparison: effectiveCompare !== "none" ? <ComparisonPanel data={analyticsQuery.production.data} language={language} theme={theme} /> : null,
distribution: <DistributionPanel data={analyticsQuery.distribution.data} language={language} theme={theme} locale={locale} />,
importStatus: <HistoricalPanel status={historical.status.data} language={language} locale={locale} compact />,
};
const renderWidget = (widgetId: WidgetId) => { const content = allWidgets[widgetId]; if (!content) return null; return <div key={widgetId} className={widgetId === "hero" ? "col-12" : widgetId === "history" ? "col-12 col-xxl-8" : "col-12 col-xxl-4"}>{content}</div>; };
if ((!publicMode && authQuery.isLoading) || (authEnabled && !authenticated && loginMutation.isPending)) return <LoadingScreen language={language} />;
if (authEnabled && !authenticated) return <LoginPage language={language} theme={theme} form={loginForm} onChange={setLoginForm} onSubmit={() => 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 <LoadingScreen language={language} />;
const navbar = (
<header className="navbar navbar-expand-md d-print-none pv-navbar">
<div className="container-xl">
<div className="navbar-brand navbar-brand-autodark d-flex align-items-center gap-2"><span className="avatar avatar-sm bg-primary-lt text-primary border-0"><IconBolt size={18} /></span><div><div className="fw-bold">{config.app.site_name}</div><div className="text-secondary small">{t(language, "operatorPanel")}</div></div></div>
<div className="navbar-nav flex-row order-md-last align-items-center gap-2">
<span className={`badge ${connected ? "bg-green-lt text-green" : "bg-yellow-lt text-yellow"}`}>{connected ? t(language, "connected") : t(language, "disconnected")}</span>
<button className="btn btn-icon btn-ghost-secondary" onClick={() => setTheme((current) => (current === "dark" ? "light" : "dark"))} title={t(language, "theme")}>{theme === "dark" ? <IconSun size={18} /> : <IconMoon size={18} />}</button>
<button className="btn btn-icon btn-ghost-secondary" onClick={() => setLanguage((current) => (current === "pl" ? "en" : "pl"))} title={t(language, "language")}><IconLanguage size={18} /></button>
{!publicMode ? <button className="btn btn-outline-primary" onClick={() => setViewMode((current) => (current === "normal" ? "kiosk" : "normal"))}><IconDeviceDesktop size={18} className="me-1" />{viewMode === "normal" ? t(language, "openKiosk") : t(language, "exitKiosk")}</button> : null}
{!publicMode ? <button className="btn btn-outline-secondary" onClick={() => logoutMutation.mutate()}><IconLogout size={18} className="me-1" />{t(language, "signOut")}</button> : null}
</div>
</div>
</header>
);
const menu = (
<div className="navbar-expand-md pv-subnav border-bottom"><div className="container-xl"><div className="navbar-collapse"><ul className="navbar-nav">
<NavItem icon={<IconLayoutDashboard size={18} />} active={activeTab === "realtime"} onClick={() => setActiveTab("realtime")} label={language === "en" ? "Live" : "Live"} />
<NavItem icon={<IconHistory size={18} />} active={activeTab === "archive"} onClick={() => setActiveTab("archive")} label={language === "en" ? "Historical live" : "Dane chwilowe"} />
<NavItem icon={<IconChartBar size={18} />} active={activeTab === "analytics"} onClick={() => setActiveTab("analytics")} label={t(language, "analytics")} />
<NavItem icon={<IconDatabaseImport size={18} />} active={activeTab === "warehouse"} onClick={() => setActiveTab("warehouse")} label={language === "en" ? "Data warehouse" : "Hurtownia danych"} />
<NavItem icon={<IconDeviceDesktop size={18} />} active={activeTab === "kiosk"} onClick={() => setActiveTab("kiosk")} label={t(language, "kiosk")} />
<NavItem icon={<IconSettings size={18} />} active={activeTab === "settings"} onClick={() => setActiveTab("settings")} label={t(language, "settings")} />
</ul><div className="ms-auto text-secondary small d-none d-md-flex align-items-center gap-2"><IconClockHour4 size={16} />{t(language, "updatedAt")}: {formatDateTime(lastUpdated, locale)}</div></div></div></div>
);
if (viewMode === "kiosk" || publicMode) {
return <div className="page kiosk-shell"><div className="container-fluid py-3 px-3 px-xl-4"><div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3"><div><div className="h2 mb-0">{config.app.site_name}</div><div className="text-secondary">{t(language, "kioskHint")}</div></div><div className="d-flex gap-2"><button className="btn btn-primary" onClick={() => requestFullscreen()}><IconDeviceDesktop size={18} className="me-1" />{t(language, "fullscreen")}</button>{!publicMode ? <button className="btn btn-outline-secondary" onClick={() => setViewMode("normal")}><IconX size={18} className="me-1" />{t(language, "exitKiosk")}</button> : null}</div></div><div className="row row-cards g-3">{effectiveKioskWidgets.map((widgetId) => renderWidget(widgetId))}</div></div></div>;
}
return (
<div className="page">{navbar}{menu}<div className="page-wrapper"><div className="page-body"><div className="container-xl">
{activeTab === "realtime" && <><PageHeader title={t(language, "realtimeOverview")} subtitle={t(language, "realtimeSubtitle")}><SegmentedSelect label={t(language, "liveRange")} value={realtimeRange} onChange={setRealtimeRange} options={liveRangeOptions(language)} /></PageHeader><div className="row row-cards g-3">{renderWidget("hero")}<div className="col-12 col-xl-4">{allWidgets.quickMetrics}</div><div className="col-12 col-xl-8">{allWidgets.history}</div><div className="col-12 col-xl-4">{allWidgets.status}</div><div className="col-12 col-xl-8">{allWidgets.strings}</div></div></>}
{activeTab === "archive" && <><PageHeader title={language === "en" ? "Historical live data" : "Dane chwilowe z historii"} subtitle={language === "en" ? "Browse all instant metrics." : "Podgląd metryk chwilowych dla dowolnego okresu."}><div className="d-flex flex-wrap gap-2 align-items-end"><SegmentedSelect label={language === "en" ? "Range" : "Zakres"} value={archiveStart && archiveEnd ? "custom" : archiveRange} onChange={(value) => { setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={archiveRangeOptions(language)} /><div><label className="form-label small mb-1">{language === "en" ? "From" : "Od"}</label><input className="form-control form-control-sm" type="datetime-local" value={archiveStart} onChange={(e) => { setArchiveRange("custom"); setArchiveStart(e.target.value); }} /></div><div><label className="form-label small mb-1">{language === "en" ? "To" : "Do"}</label><input className="form-control form-control-sm" type="datetime-local" value={archiveEnd} onChange={(e) => { setArchiveRange("custom"); setArchiveEnd(e.target.value); }} /></div></div></PageHeader><div className="row row-cards g-3"><div className="col-12 col-xl-4"><MetricSelectorCard language={language} title={language === "en" ? "Metrics on chart" : "Metryki na wykresie"} items={metricCandidates.filter((item) => item.metric_id !== "energy_total")} selected={archiveMetrics.filter((item) => item !== "energy_total")} onChange={setArchiveMetrics} /></div><div className="col-12 col-xl-8"><LiveHistoryPanel data={archiveQuery.data} language={language} theme={theme} title={language === "en" ? "Historical chart" : "Wykres historyczny"} subtitle={language === "en" ? "Raw instant metrics from InfluxDB only." : "Tylko surowe metryki chwilowe z InfluxDB."} /></div></div></>}
{activeTab === "analytics" && <><PageHeader title={t(language, "analyticsOverview")} subtitle={t(language, "analyticsSubtitle")}><div className="d-flex flex-wrap gap-2 align-items-end"><SegmentedSelect label={t(language, "range")} value={analyticsStart && analyticsEnd ? "custom" : analyticsRange} onChange={(value) => { 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" }]} /><SegmentedSelect label={t(language, "bucket")} value={bucket} onChange={setBucket} options={config.capabilities.buckets.map((item) => ({ key: item.key, label: translateBucket(language, item.key) }))} /><SegmentedSelect label={language === "en" ? "Comparison" : "Porównanie"} value={compare} onChange={setCompare} options={comparisonOptions(language)} /></div></PageHeader>{analyticsStart || analyticsEnd || (analyticsRange === "custom") || compare === "custom_multi" ? <div className="card pv-card mb-3"><div className="card-body d-flex flex-column gap-3"><div className="row g-3"><div className="col-md-3"><label className="form-label">{language === "en" ? "Start" : "Od"}</label><input className="form-control" type="datetime-local" value={analyticsStart} onChange={(e) => setAnalyticsStart(e.target.value)} /></div><div className="col-md-3"><label className="form-label">{language === "en" ? "End" : "Do"}</label><input className="form-control" type="datetime-local" value={analyticsEnd} onChange={(e) => setAnalyticsEnd(e.target.value)} /></div><div className="col-md-6 d-flex align-items-end"><button className="btn btn-outline-secondary" onClick={() => { setAnalyticsStart(""); setAnalyticsEnd(""); }}>{language === "en" ? "Use preset range" : "Wróć do gotowych zakresów"}</button></div></div>{compare === "custom_multi" ? <div className="border rounded-3 p-3"><div className="fw-semibold mb-3">{language === "en" ? "Comparison ranges" : "Zakresy porównawcze"}</div><div className="row g-3">{compareRanges.map((item, index) => <div className="col-12" key={item.key}><div className="row g-2 align-items-end"><div className="col-md-3"><label className="form-label">{language === "en" ? `Range ${index + 1} label` : `Etykieta ${index + 1}`}</label><input className="form-control" value={item.label} onChange={(e) => setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, label: e.target.value } : current))} /></div><div className="col-md-3"><label className="form-label">{language === "en" ? "From" : "Od"}</label><input className="form-control" type="datetime-local" value={item.start} onChange={(e) => setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, start: e.target.value } : current))} /></div><div className="col-md-3"><label className="form-label">{language === "en" ? "To" : "Do"}</label><input className="form-control" type="datetime-local" value={item.end} onChange={(e) => setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, end: e.target.value } : current))} /></div><div className="col-md-3"><button className="btn btn-outline-secondary w-100" onClick={() => setCompareRanges(compareRanges.length > 1 ? compareRanges.filter((current) => current.key !== item.key) : compareRanges)}>{language === "en" ? "Remove range" : "Usuń zakres"}</button></div></div></div>)}<div className="col-12"><button className="btn btn-primary" onClick={() => setCompareRanges([...compareRanges, { key: `cmp_${Date.now()}`, label: `${language === "en" ? "Range" : "Zakres"} ${compareRanges.length + 1}`, start: "", end: "" }])}>{language === "en" ? "Add range" : "Dodaj zakres"}</button></div></div></div> : null}</div></div> : null}<div className="row row-cards g-3"><div className="col-12"><SummaryCards summary={summary} language={language} locale={locale} compareLabel={comparisonOptions(language).find((item) => item.key === compare)?.label ?? compare} /></div><div className="col-12 col-xxl-8">{allWidgets.production}</div><div className="col-12 col-xxl-4">{allWidgets.distribution}</div>{compare !== "none" ? <div className="col-12">{allWidgets.comparison}</div> : null}</div></>}
{activeTab === "warehouse" && <><PageHeader title={language === "en" ? "Data warehouse" : "Hurtownia danych"} subtitle={language === "en" ? "Historical import and coverage." : "Import historyczny i pokrycie danych."} /><div className="row row-cards g-3"><div className="col-12 col-xxl-8"><HistoricalPanel status={historical.status.data} language={language} locale={locale} /></div><div className="col-12 col-xxl-4"><ImportControls status={historical.status.data} language={language} onStart={(payload) => historical.start.mutate(payload)} onSyncNow={() => historical.syncNow.mutate()} onCancel={() => historical.cancel.mutate()} /></div></div></>}
{activeTab === "kiosk" && <><PageHeader title={t(language, "kiosk")} subtitle={language === "en" ? "Kiosk mode configuration" : "Konfiguracja trybu kiosk"} /><div className="row row-cards g-3"><div className="col-12 col-xxl-8"><KioskSettingsEditorPanel language={language} value={kioskEditorMode === "public" ? publicKioskDraft : privateKioskDraft} onChange={(value) => 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)} /></div><div className="col-12 col-xxl-4"><KioskLinkPanel language={language} publicKioskUrl={publicKioskUrl} privateKioskUrl={privateKioskUrl} publicSettings={publicKioskDraft} privateSettings={privateKioskDraft} /></div></div></>}
{activeTab === "settings" && <><PageHeader title={t(language, "settings")} subtitle={language === "en" ? "Appearance, metric blocks and admin users." : "Wygląd, bloki metryk i użytkownicy."} /><div className="row row-cards g-3"><div className="col-12 col-xxl-4"><AppearanceSecurityPanel language={language} theme={theme} setTheme={setTheme} viewMode={viewMode} setViewMode={setViewMode} authEnabled={config.auth?.enabled ?? false} userName={authQuery.data?.display_name ?? authQuery.data?.user ?? ""} /></div><div className="col-12 col-xxl-8"><div className="row g-3"><div className="col-12"><LiveChartMetricsPanel language={language} items={metricCandidates.filter((item) => item.metric_id !== "energy_total")} selected={liveMetrics.filter((item) => item !== "energy_total")} onChange={setLiveMetrics} /></div><div className="col-12"><BlockVisibilityPanel language={language} items={metricCandidates} config={blockConfig} onChange={setBlockConfig} /></div></div></div>{isAdmin ? <div className="col-12"><AdminUsersPanel language={language} users={usersQuery.data?.items ?? []} newUser={newUser} onNewUserChange={setNewUser} onCreate={() => createUserMutation.mutate()} passwordReset={passwordReset} onPasswordResetChange={setPasswordReset} onResetPassword={() => resetPasswordMutation.mutate()} /></div> : null}</div></>}
</div></div></div></div>
);
}
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> | void; msRequestFullscreen?: () => Promise<void> | 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<string, { pl: string; en: string }> = { 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 <div className="page page-center"><div className="container container-tight py-4 text-center"><div className="spinner-border text-primary mb-3" role="status" /><div className="text-secondary">{t(language, "loading")}</div></div></div>; }
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 <div className="page page-center login-page-shell"><div className="container container-tight py-4"><div className="text-center mb-4"><div className="avatar avatar-xl bg-primary-lt text-primary mb-3 border-0 mx-auto"><IconBolt size={28} /></div><h1 className="h2 mb-1">{t(language, "loginTitle")}</h1><div className="text-secondary">{t(language, "loginSubtitle")}</div></div><div className="card card-md login-card"><div className="card-body"><div className="d-flex justify-content-end gap-2 mb-3"><button className="btn btn-icon btn-ghost-secondary" onClick={onThemeToggle}>{theme === "dark" ? <IconSun size={18} /> : <IconMoon size={18} />}</button><button className="btn btn-icon btn-ghost-secondary" onClick={onLanguageToggle}><IconLanguage size={18} /></button></div><div className="mb-3"><label className="form-label">{t(language, "username")}</label><input className="form-control" value={form.username} onChange={(event) => onChange({ ...form, username: event.target.value })} autoComplete="username" /></div><div className="mb-3"><label className="form-label">{t(language, "password")}</label><input className="form-control" type="password" value={form.password} onChange={(event) => onChange({ ...form, password: event.target.value })} autoComplete="current-password" onKeyDown={(event) => event.key === "Enter" && onSubmit()} /></div>{error ? <div className="alert alert-danger py-2">{error}</div> : null}<button className="btn btn-primary w-100" onClick={onSubmit} disabled={loading}><IconLogin2 size={18} className="me-1" />{t(language, "signIn")}</button></div></div></div></div>; }
function NavItem({ icon, active, onClick, label }: { icon: ReactElement; active: boolean; onClick: () => void; label: string }) { return <li className="nav-item"><button className={`nav-link border-0 bg-transparent pv-nav-link ${active ? "active" : ""}`} onClick={onClick}><span className="pv-nav-icon">{icon}</span><span className="pv-nav-title">{label}</span></button></li>; }
function PageHeader({ title, subtitle, children }: { title: string; subtitle: string; children?: ReactNode }) { return <div className="page-header d-print-none mb-3"><div className="row align-items-center"><div className="col"><div className="page-pretitle">PV Insight</div><h2 className="page-title mb-1">{title}</h2><div className="text-secondary">{subtitle}</div></div>{children ? <div className="col-auto ms-auto">{children}</div> : null}</div></div>; }
function SegmentedSelect({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return <div className="btn-list align-items-center flex-nowrap"><span className="text-secondary small me-2 d-none d-md-inline">{label}</span><div className="btn-group">{options.map((option) => <button key={option.key} className={`btn btn-sm ${value === option.key ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => onChange(option.key)}>{option.label}</button>)}</div></div>; }
function HeroCards({ cards, locale, language }: { cards: SnapshotPayload["hero_cards"]; locale: string; language: Language }) { return <div className="row row-cards g-3">{cards.map((card) => <div key={card.metric_id} className="col-12 col-sm-6 col-xl-3"><div className="card pv-card pv-hero-card h-100"><div className="card-body"><div className="d-flex align-items-center justify-content-between mb-3"><span className="avatar avatar-sm bg-primary-lt text-primary border-0">{iconForMetric(card.metric_id)}</span><span className="badge bg-primary-lt text-primary text-uppercase">{card.unit || "live"}</span></div><div className="text-secondary text-uppercase small mb-1">{labelForMetric(language, card.metric_id, card.label)}</div><div className="display-6 fw-bold mb-1">{formatValue(card.value, card.unit, card.unit === "kWh" ? 2 : 2, locale)}</div><div className="text-secondary small">{card.subtitle}</div></div></div></div>)}</div>; }
function QuickMetrics({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "quickMetrics")}</h3></div><div className="list-group list-group-flush list-group-hoverable">{metrics.map((metric) => <div className="list-group-item" key={metric.metric_id}><div className="row align-items-center"><div className="col-auto text-primary">{iconForMetric(metric.metric_id)}</div><div className="col text-truncate"><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit || t(language, "status")}</div></div><div className="col-auto fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div></div>)}</div></div>; }
function LiveHistoryPanel({ data, language, theme, title, subtitle }: { data?: HistoryPayload; language: Language; theme: ThemeMode; title: string; subtitle: string }) { return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{title}</h3><div className="text-secondary small">{subtitle}</div></div></div><div className="card-body"><EChart option={buildLiveHistoryOption(data, theme, language)} className="pv-chart" /></div></div>; }
function StatusPanel({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "systemStatus")}</h3></div><div className="card-body d-flex flex-column gap-3">{metrics.map((metric) => <div key={metric.metric_id} className="d-flex justify-content-between align-items-center border rounded-3 px-3 py-2 status-row"><div><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit || t(language, "status")}</div></div><div className="fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div>)}</div></div>; }
function StringsPanel({ rows, locale, language }: { rows: SnapshotGroupRow[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "strings")}</h3></div><div className="card-body"><div className="row g-3">{rows.length === 0 ? <div className="col-12 text-secondary">{t(language, "noDataDescription")}</div> : rows.map((row) => <div className="col-12 col-md-6" key={row.id}><div className="border rounded-3 p-3 h-100 string-panel"><div className="d-flex align-items-center justify-content-between mb-3"><div className="fw-semibold">{row.label}</div><span className="badge bg-azure-lt text-azure">DC</span></div><div className="d-flex flex-column gap-2">{Object.values(row.values).map((metric) => <div key={metric.metric_id} className="d-flex justify-content-between small"><span className="text-secondary">{labelForMetric(language, metric.metric_id, metric.label)}</span><span className="fw-medium">{formatValue(metric.value, metric.unit, 2, locale)}</span></div>)}</div></div></div>)}</div></div></div>; }
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 <div className="row row-cards g-3">{items.map((item) => <div className="col-12 col-sm-6 col-xl-3" key={item.key}><div className="card pv-card h-100"><div className="card-body"><div className="text-secondary text-uppercase small mb-1">{item.key}</div><div className="h2 mb-0">{item.value}</div>{item.badge ? <div className="mt-2"><span className="badge bg-primary-lt text-primary">{item.badge}</span></div> : null}</div></div></div>)}</div>; }
function ProductionPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) { return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{t(language, "chartProduction")}</h3><div className="text-secondary small">{t(language, "chartProductionSubtitle")}</div></div></div><div className="card-body"><EChart option={buildBarOption(data?.current ?? [], data?.unit ?? "kWh", theme, language)} className="pv-chart" /></div></div>; }
function ComparisonPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "chartComparison")}</h3></div><div className="card-body"><EChart option={buildComparisonOption(data, theme, language)} className="pv-chart" /></div></div>; }
function DistributionPanel({ data, language, theme, locale }: { data?: DistributionPayload; language: Language; theme: ThemeMode; locale: string }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "chartDistribution")}</h3></div><div className="card-body"><div className="mb-3 fw-semibold">{formatValue(data?.total, data?.unit ?? "kWh", 2, locale)}</div><EChart option={buildPieOption(data, theme)} className="pv-chart-sm" /></div></div>; }
function HistoricalPanel({ status, language, locale, compact = false }: { status?: HistoricalStatus; language: Language; locale: string; compact?: boolean }) { if (!status) return <div className="card pv-card h-100"><div className="card-body text-secondary">{t(language, "noDataDescription")}</div></div>; return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{language === "en" ? "Data warehouse" : "Hurtownia danych"}</h3><div className="text-secondary small">{t(language, "importArchiveSubtitle")}</div></div></div><div className="card-body d-flex flex-column gap-4"><div className="row g-3"><StatusStat label={t(language, "status")} value={status.message || status.state} /><StatusStat label={t(language, "coverage")} value={formatPercent(status.coverage.coverage_pct ?? 0, 1, locale)} /><StatusStat label={t(language, "importedDays")} value={formatValue(status.coverage.imported_days, "", 0, locale)} /><StatusStat label={t(language, "missingDays")} value={formatValue(status.coverage.missing_days, "", 0, locale)} /><StatusStat label={t(language, "throughput")} value={`${formatValue(status.avg_days_per_minute ?? 0, "", 1, locale)} / min`} /><StatusStat label={t(language, "eta")} value={formatDurationShort(status.estimated_remaining_seconds, locale)} /></div>{!compact ? <><div><div className="d-flex justify-content-between small mb-2"><span className="text-secondary">{t(language, "activeChunk")}</span><span className="fw-medium">{status.active_chunk_index}/{status.total_chunks}</span></div><div className="progress progress-sm"><div className="progress-bar" style={{ width: `${Math.min((status.processed_days / Math.max(status.total_days, 1)) * 100, 100)}%` }} /></div></div><div className="row g-3"><div className="col-12 col-xl-6"><div className="table-responsive"><table className="table table-vcenter card-table table-sm"><thead><tr><th>{t(language, "recentChunks")}</th><th>{t(language, "status")}</th><th className="text-end">kWh</th></tr></thead><tbody>{status.recent_chunks.map((chunk) => <tr key={`${chunk.chunk_index}-${chunk.start_date}`}><td><div className="fw-medium">#{chunk.chunk_index}</div><div className="text-secondary small">{chunk.start_date} {chunk.end_date}</div></td><td>{chunk.state}</td><td className="text-end">{formatValue(chunk.energy_kwh, "kWh", 2, locale)}</td></tr>)}</tbody></table></div></div><div className="col-12 col-xl-6"><div className="list-group list-group-flush">{status.recent_events.map((event, index) => <div className="list-group-item px-0" key={`${event.timestamp}-${index}`}><div className="d-flex justify-content-between gap-2"><div><div className="fw-medium">{event.title}</div><div className="text-secondary small">{event.message}</div></div><div className="text-secondary small text-nowrap">{formatShortTime(event.timestamp, locale)}</div></div></div>)}</div></div></div></> : null}</div></div>; }
function StatusStat({ label, value }: { label: string; value: string }) { return <div className="col-6 col-md-4 col-xl-2"><div className="border rounded-3 px-3 py-2 h-100 bg-body-tertiary"><div className="text-secondary small mb-1">{label}</div><div className="fw-semibold">{value}</div></div></div>; }
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 <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{language === "en" ? "Import controls" : "Sterowanie importem"}</h3></div><div className="card-body d-flex flex-column gap-3"><div><label className="form-label">{t(language, "startDate")}</label><input className="form-control" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} /></div><div><label className="form-label">{t(language, "endDate")}</label><input className="form-control" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} /></div><div><label className="form-label">{t(language, "chunkDays")}</label><input className="form-control" type="number" min={1} max={31} value={chunkDays} onChange={(event) => setChunkDays(event.target.value)} /></div><div className="d-grid gap-2"><button className="btn btn-primary" onClick={() => onStart({ start_date: startDate || undefined, end_date: endDate || undefined, chunk_days: Number(chunkDays) || undefined, force: true })}><IconPlayerPlay size={18} className="me-1" />{t(language, "startImport")}</button><button className="btn btn-outline-secondary" onClick={onSyncNow}><IconRefresh size={18} className="me-1" />{t(language, "syncMissing")}</button><button className="btn btn-outline-danger" onClick={onCancel} disabled={!status?.running}><IconX size={18} className="me-1" />{t(language, "cancel")}</button></div></div></div>; }
function KioskLayoutPanel({ language, widgets, onChange, labels }: { language: Language; widgets: WidgetId[]; onChange: (value: WidgetId[]) => void; labels: Map<WidgetId, string>; }) { 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 <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{t(language, "kioskLayout")}</h3><div className="text-secondary small">{t(language, "kioskLayoutSubtitle")}</div></div></div><div className="card-body"><div className="alert alert-info py-2">{t(language, "saveLayout")}</div><div className="row g-3"><div className="col-12 col-lg-7"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{t(language, "selected")}</div><div className="d-flex flex-column gap-2">{selected.map((id) => <div key={id} className="d-flex align-items-center justify-content-between gap-2 border rounded-3 px-3 py-2 bg-body-tertiary"><span>{labels.get(id)}</span><div className="btn-list"><button className="btn btn-sm btn-outline-secondary" onClick={() => move(id, -1)}>{t(language, "moveUp")}</button><button className="btn btn-sm btn-outline-secondary" onClick={() => move(id, 1)}>{t(language, "moveDown")}</button><button className="btn btn-sm btn-outline-danger" onClick={() => toggle(id)}><IconX size={16} /></button></div></div>)}</div></div></div><div className="col-12 col-lg-5"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{t(language, "available")}</div><div className="d-flex flex-wrap gap-2">{unselected.map((id) => <button key={id} className="btn btn-outline-primary" onClick={() => toggle(id)}>{labels.get(id)}</button>)}</div></div></div></div></div></div>; }
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<WidgetId, string>; buckets: Array<{ key: string; label: string }>; compareModes: string[]; saving: boolean; dirty: boolean; canSave: boolean; saveNotice: string | null; }) { const widgets = toWidgetIds(value.widgets); return <div className="d-flex flex-column gap-3"><div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Kiosk settings" : "Ustawienia kiosku"}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="btn-group w-100"><button className={`btn ${selectedMode === "private" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => onModeChange("private")}>{language === "en" ? "Logged-in kiosk" : "Kiosk prywatny"}</button><button className={`btn ${selectedMode === "public" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => onModeChange("public")}>{language === "en" ? "Public kiosk" : "Kiosk publiczny"}</button></div><div className={`alert py-2 mb-0 ${dirty ? "alert-warning" : "alert-success"}`}>{dirty ? (language === "en" ? "You have local changes." : "Masz lokalne zmiany.") : (language === "en" ? "No unsaved changes." : "Brak niezapisanych zmian.")}{saveNotice ? <span className="d-block mt-1">{saveNotice}</span> : null}</div><div className="row g-3"><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Live chart range" : "Zakres wykresu live"}</label><select className="form-select" value={value.realtime_range} onChange={(e) => onChange({ ...value, realtime_range: e.target.value })}>{liveRangeOptions(language).map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Analytics range" : "Zakres analityki"}</label><select className="form-select" value={value.analytics_range} onChange={(e) => onChange({ ...value, analytics_range: e.target.value })}>{analyticsRangeOptions(language).map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">Bucket</label><select className="form-select" value={value.analytics_bucket} onChange={(e) => onChange({ ...value, analytics_bucket: e.target.value })}>{buckets.map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Comparison" : "Porównanie"}</label><select className="form-select" value={value.compare_mode} onChange={(e) => onChange({ ...value, compare_mode: e.target.value })}><option value="none">{translateCompareMode(language, "none")}</option>{compareModes.filter((item) => item !== "none").map((item) => <option key={item} value={item}>{translateCompareMode(language, item)}</option>)}</select></div></div><div className="d-flex justify-content-end gap-2"><button className="btn btn-outline-secondary" onClick={onReset} disabled={saving || !dirty}>{language === "en" ? "Discard changes" : "Odrzuć zmiany"}</button><button className="btn btn-primary" onClick={onSave} disabled={saving || !dirty || !canSave}>{saving ? (language === "en" ? "Saving..." : "Zapisywanie...") : (language === "en" ? "Save kiosk settings" : "Zapisz ustawienia kiosku")}</button></div></div></div><KioskLayoutPanel language={language} widgets={widgets} onChange={(widgetsValue) => onChange({ ...value, widgets: widgetsValue })} labels={labels} /></div>; }
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 <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{language === "en" ? "Kiosk links" : "Linki kiosku"}</h3></div><div className="card-body d-flex flex-column gap-4"><div className="d-flex flex-column gap-2"><div className="fw-semibold">{language === "en" ? "Public kiosk" : "Kiosk publiczny"}</div><div className="text-secondary small">{language === "en" ? "Read-only access without login." : "Podgląd bez logowania, tylko odczyt."}</div><input className="form-control" value={publicKioskUrl} readOnly /><div className="small text-secondary">{language === "en" ? "Ranges:" : "Zakresy:"} live {publicSettings.realtime_range}, analytics {publicSettings.analytics_range}</div><button className="btn btn-primary" onClick={() => copy(publicKioskUrl, "public")}>{copied === "public" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy public link" : "Kopiuj link publiczny")}</button></div><div className="d-flex flex-column gap-2 border-top pt-3"><div className="fw-semibold">{language === "en" ? "Private kiosk" : "Kiosk prywatny"}</div><div className="text-secondary small">{language === "en" ? "Requires login and uses private kiosk settings." : "Wymaga logowania i używa prywatnych ustawień kiosku."}</div><input className="form-control" value={privateKioskUrl} readOnly /><div className="small text-secondary">{language === "en" ? "Ranges:" : "Zakresy:"} live {privateSettings.realtime_range}, analytics {privateSettings.analytics_range}</div><button className="btn btn-outline-primary" onClick={() => copy(privateKioskUrl, "private")}>{copied === "private" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy private link" : "Kopiuj link prywatny")}</button></div></div></div>; }
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 <div className="d-flex flex-column gap-3"><div className="card pv-card"><div className="card-header"><h3 className="card-title">{t(language, "theme")}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="btn-group w-100"><button className={`btn ${theme === "light" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setTheme("light")}>{t(language, "light")}</button><button className={`btn ${theme === "dark" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setTheme("dark")}>{t(language, "dark")}</button></div></div></div><div className="card pv-card"><div className="card-header"><h3 className="card-title">{t(language, "viewMode")}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="btn-group w-100"><button className={`btn ${viewMode === "normal" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setViewMode("normal")}>{t(language, "normalMode")}</button><button className={`btn ${viewMode === "kiosk" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setViewMode("kiosk")}>{t(language, "kioskMode")}</button></div></div></div><div className="card pv-card"><div className="card-header"><h3 className="card-title">{t(language, "security")}</h3></div><div className="card-body d-flex flex-column gap-2"><div className="d-flex align-items-center gap-2 fw-medium"><IconLock size={18} /> {authEnabled ? t(language, "authEnabled") : t(language, "authDisabled")}</div><div className="text-secondary small">{language === "en" ? "Admin user management is available below." : "Zarządzanie użytkownikami admina jest dostępne niżej."}</div>{userName ? <div className="badge bg-primary-lt text-primary align-self-start">{userName}</div> : null}</div></div></div>; }
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 <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{title}</h3></div><div className="card-body d-flex flex-column gap-2">{items.map((item) => <label key={item.metric_id} className="form-check"><input className="form-check-input" type="checkbox" checked={selected.includes(item.metric_id)} onChange={() => toggle(item.metric_id)} /><span className="form-check-label">{item.label} <span className="text-secondary small">{item.unit}</span></span></label>)}</div></div>; }
function LiveChartMetricsPanel({ language, items, selected, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; }) { return <MetricSelectorCard language={language} title={language === "en" ? "Live chart metrics" : "Metryki wykresu live"} items={items} selected={selected} onChange={onChange} />; }
function BlockVisibilityPanel({ language, items, config, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; config: Record<BlockTarget, string[]>; onChange: (value: Record<BlockTarget, string[]>) => 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 <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{language === "en" ? "Metric visibility in blocks" : "Widoczność metryk w blokach"}</h3></div><div className="card-body"><div className="row g-3"><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">Hero metrics</div>{items.map((item) => <label key={`hero-${item.metric_id}`} className="form-check d-block mb-2"><input className="form-check-input" type="checkbox" checked={config.hero.includes(item.metric_id)} onChange={() => toggle("hero", item.metric_id)} /><span className="form-check-label">{item.label}</span></label>)}</div></div><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">Quick metrics</div>{items.map((item) => <label key={`quick-${item.metric_id}`} className="form-check d-block mb-2"><input className="form-check-input" type="checkbox" checked={config.quick.includes(item.metric_id)} onChange={() => toggle("quick", item.metric_id)} /><span className="form-check-label">{item.label}</span></label>)}</div></div></div></div></div>; }
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 <div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Admin user management" : "Zarządzanie użytkownikami"}</h3></div><div className="card-body"><div className="row g-3"><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{language === "en" ? "Create user" : "Dodaj użytkownika"}</div><div className="row g-2"><div className="col-md-6"><input className="form-control" placeholder={language === "en" ? "Username" : "Login"} value={newUser.username} onChange={(e) => onNewUserChange({ ...newUser, username: e.target.value })} /></div><div className="col-md-6"><input className="form-control" placeholder={language === "en" ? "Display name" : "Nazwa"} value={newUser.display_name} onChange={(e) => onNewUserChange({ ...newUser, display_name: e.target.value })} /></div><div className="col-md-6"><input className="form-control" type="password" placeholder={language === "en" ? "Password" : "Hasło"} value={newUser.password} onChange={(e) => onNewUserChange({ ...newUser, password: e.target.value })} /></div><div className="col-md-6"><select className="form-select" value={newUser.role} onChange={(e) => onNewUserChange({ ...newUser, role: e.target.value })}><option value="user">user</option><option value="admin">admin</option></select></div><div className="col-12"><button className="btn btn-primary" onClick={onCreate}>{language === "en" ? "Create user" : "Dodaj użytkownika"}</button></div></div></div></div><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{language === "en" ? "Reset password" : "Zmiana hasła"}</div><div className="row g-2"><div className="col-md-6"><select className="form-select" value={passwordReset.username} onChange={(e) => onPasswordResetChange({ ...passwordReset, username: e.target.value })}><option value="">{language === "en" ? "Select user" : "Wybierz użytkownika"}</option>{users.map((user) => <option key={user.username} value={user.username}>{user.username}</option>)}</select></div><div className="col-md-6"><input className="form-control" type="password" placeholder={language === "en" ? "New password" : "Nowe hasło"} value={passwordReset.password} onChange={(e) => onPasswordResetChange({ ...passwordReset, password: e.target.value })} /></div><div className="col-12"><button className="btn btn-outline-primary" onClick={onResetPassword}>{language === "en" ? "Reset password" : "Zmień hasło"}</button></div></div></div></div><div className="col-12"><div className="table-responsive"><table className="table table-vcenter card-table"><thead><tr><th>{language === "en" ? "Username" : "Login"}</th><th>{language === "en" ? "Display name" : "Nazwa"}</th><th>Role</th><th>{language === "en" ? "Updated" : "Aktualizacja"}</th></tr></thead><tbody>{users.map((user) => <tr key={user.username}><td>{user.username}</td><td>{user.display_name}</td><td><span className="badge bg-primary-lt text-primary">{user.role}</span></td><td>{formatDateTime(user.updated_at, language === "en" ? "en-GB" : "pl-PL")}</td></tr>)}</tbody></table></div></div></div></div></div>; }

433
frontend/src/App.tsx.bak Normal file
View File

@@ -0,0 +1,433 @@
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<BlockTarget, string[]> = {
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<T>(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<T>(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 <IconTemperature size={18} />;
if (metricId.includes("energy")) return <IconChartBar size={18} />;
return <IconBolt size={18} />;
}
function buildWidgetLabel(language: Language, widgetId: WidgetId): string {
const labels: Record<WidgetId, string> = {
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<ThemeMode>(STORAGE_KEYS.theme, (config?.defaults.theme as ThemeMode) ?? "dark", (raw) => (raw === "light" ? "light" : "dark"));
}
function getInitialLanguage(config?: DashboardConfig): Language {
return normalizeLanguage(readStorage<string>(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<string, { metric_id: string; label: string; unit: string }>();
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<AuthStatus>({ 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<KioskSettingsPayload>({ queryKey: ["kiosk-settings", "private"], queryFn: () => api.getKioskSettings("private"), enabled: authenticated || authEnabled === false, staleTime: 30_000 });
const publicKioskSettingsQuery = useQuery<KioskSettingsPayload>({ queryKey: ["kiosk-settings", "public"], queryFn: () => api.getKioskSettings("public"), enabled: authenticated || authEnabled === false || publicMode, staleTime: 30_000 });
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme(undefined));
const [language, setLanguage] = useState<Language>(() => getInitialLanguage(undefined));
const [activeTab, setActiveTab] = useState<TabKey>(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<Array<{ key: string; label: string; start: string; end: string }>>([{ 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<string[]>(() => readStorage(STORAGE_KEYS.liveMetrics, DEFAULT_LIVE_METRICS));
const [archiveMetrics, setArchiveMetrics] = useState<string[]>(() => readStorage(STORAGE_KEYS.archiveMetrics, DEFAULT_LIVE_METRICS));
const [viewMode, setViewMode] = useState<ViewMode>(() => { const fromUrl = parseViewModeFromLocation(); return fromUrl === "kiosk" ? fromUrl : readStorage<ViewMode>(STORAGE_KEYS.viewMode, "normal", (raw) => (raw === "kiosk" ? "kiosk" : "normal")); });
const [kioskWidgets, setKioskWidgets] = useState<WidgetId[]>(() => getVisibleWidgets(readStorage<WidgetId[]>(STORAGE_KEYS.kioskWidgets, DEFAULT_KIOSK_WIDGETS)));
const [kioskEditorMode, setKioskEditorMode] = useState<"private" | "public">("private");
const [privateKioskDraft, setPrivateKioskDraft] = useState<KioskSettingsPayload>({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" });
const [publicKioskDraft, setPublicKioskDraft] = useState<KioskSettingsPayload>({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" });
const [blockConfig, setBlockConfig] = useState<Record<BlockTarget, string[]>>(() => readStorage(STORAGE_KEYS.blockConfig, DEFAULT_BLOCK_CONFIG));
const [loginForm, setLoginForm] = useState({ username: "", password: "" });
const [loginError, setLoginError] = useState<string | null>(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<AuthUsersPayload>({ 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<WidgetId, string>(); 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<WidgetId, ReactElement | null> = {
hero: <HeroCards cards={heroCards} locale={locale} language={language} />,
quickMetrics: <QuickMetrics metrics={quickMetrics} locale={locale} language={language} />,
history: <LiveHistoryPanel data={historyQuery.data} language={language} theme={theme} title={t(language, "chartPowerHistory")} subtitle={t(language, "realtimeSubtitle")} />,
status: <StatusPanel metrics={topStatus} locale={locale} language={language} />,
strings: <StringsPanel rows={snapshot.strings} locale={locale} language={language} />,
production: <ProductionPanel data={analyticsQuery.production.data} language={language} theme={theme} />,
comparison: compare !== "none" ? <ComparisonPanel data={analyticsQuery.production.data} language={language} theme={theme} /> : null,
distribution: <DistributionPanel data={analyticsQuery.distribution.data} language={language} theme={theme} locale={locale} />,
importStatus: <HistoricalPanel status={historical.status.data} language={language} locale={locale} compact />,
};
const renderWidget = (widgetId: WidgetId) => { const content = allWidgets[widgetId]; if (!content) return null; return <div key={widgetId} className={widgetId === "hero" ? "col-12" : widgetId === "history" ? "col-12 col-xxl-8" : "col-12 col-xxl-4"}>{content}</div>; };
if ((!publicMode && authQuery.isLoading) || (authEnabled && !authenticated && loginMutation.isPending)) return <LoadingScreen language={language} />;
if (authEnabled && !authenticated) return <LoginPage language={language} theme={theme} form={loginForm} onChange={setLoginForm} onSubmit={() => 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 <LoadingScreen language={language} />;
const navbar = (
<header className="navbar navbar-expand-md d-print-none pv-navbar">
<div className="container-xl">
<div className="navbar-brand navbar-brand-autodark d-flex align-items-center gap-2"><span className="avatar avatar-sm bg-primary-lt text-primary border-0"><IconBolt size={18} /></span><div><div className="fw-bold">{config.app.site_name}</div><div className="text-secondary small">{t(language, "operatorPanel")}</div></div></div>
<div className="navbar-nav flex-row order-md-last align-items-center gap-2">
<span className={`badge ${connected ? "bg-green-lt text-green" : "bg-yellow-lt text-yellow"}`}>{connected ? t(language, "connected") : t(language, "disconnected")}</span>
<button className="btn btn-icon btn-ghost-secondary" onClick={() => setTheme((current) => (current === "dark" ? "light" : "dark"))} title={t(language, "theme")}>{theme === "dark" ? <IconSun size={18} /> : <IconMoon size={18} />}</button>
<button className="btn btn-icon btn-ghost-secondary" onClick={() => setLanguage((current) => (current === "pl" ? "en" : "pl"))} title={t(language, "language")}><IconLanguage size={18} /></button>
{!publicMode ? <button className="btn btn-outline-primary" onClick={() => setViewMode((current) => (current === "normal" ? "kiosk" : "normal"))}><IconDeviceDesktop size={18} className="me-1" />{viewMode === "normal" ? t(language, "openKiosk") : t(language, "exitKiosk")}</button> : null}
{!publicMode ? <button className="btn btn-outline-secondary" onClick={() => logoutMutation.mutate()}><IconLogout size={18} className="me-1" />{t(language, "signOut")}</button> : null}
</div>
</div>
</header>
);
const menu = (
<div className="navbar-expand-md pv-subnav border-bottom"><div className="container-xl"><div className="navbar-collapse"><ul className="navbar-nav">
<NavItem icon={<IconLayoutDashboard size={18} />} active={activeTab === "realtime"} onClick={() => setActiveTab("realtime")} label={language === "en" ? "Live" : "Live"} />
<NavItem icon={<IconHistory size={18} />} active={activeTab === "archive"} onClick={() => setActiveTab("archive")} label={language === "en" ? "Historical live" : "Dane chwilowe"} />
<NavItem icon={<IconChartBar size={18} />} active={activeTab === "analytics"} onClick={() => setActiveTab("analytics")} label={t(language, "analytics")} />
<NavItem icon={<IconDatabaseImport size={18} />} active={activeTab === "warehouse"} onClick={() => setActiveTab("warehouse")} label={language === "en" ? "Data warehouse" : "Hurtownia danych"} />
<NavItem icon={<IconDeviceDesktop size={18} />} active={activeTab === "kiosk"} onClick={() => setActiveTab("kiosk")} label={t(language, "kiosk")} />
<NavItem icon={<IconSettings size={18} />} active={activeTab === "settings"} onClick={() => setActiveTab("settings")} label={t(language, "settings")} />
</ul><div className="ms-auto text-secondary small d-none d-md-flex align-items-center gap-2"><IconClockHour4 size={16} />{t(language, "updatedAt")}: {formatDateTime(lastUpdated, locale)}</div></div></div></div>
);
if (viewMode === "kiosk" || publicMode) {
return <div className="page kiosk-shell"><div className="container-fluid py-3 px-3 px-xl-4"><div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3"><div><div className="h2 mb-0">{config.app.site_name}</div><div className="text-secondary">{t(language, "kioskHint")}</div></div><div className="d-flex gap-2"><button className="btn btn-primary" onClick={() => requestFullscreen()}><IconDeviceDesktop size={18} className="me-1" />{t(language, "fullscreen")}</button>{!publicMode ? <button className="btn btn-outline-secondary" onClick={() => setViewMode("normal")}><IconX size={18} className="me-1" />{t(language, "exitKiosk")}</button> : null}</div></div><div className="row row-cards g-3">{effectiveKioskWidgets.map((widgetId) => renderWidget(widgetId))}</div></div></div>;
}
return (
<div className="page">{navbar}{menu}<div className="page-wrapper"><div className="page-body"><div className="container-xl">
{activeTab === "realtime" && <><PageHeader title={t(language, "realtimeOverview")} subtitle={t(language, "realtimeSubtitle")}><SegmentedSelect label={t(language, "liveRange")} value={realtimeRange} onChange={setRealtimeRange} options={liveRangeOptions(language)} /></PageHeader><div className="row row-cards g-3">{renderWidget("hero")}<div className="col-12 col-xl-4">{allWidgets.quickMetrics}</div><div className="col-12 col-xl-8">{allWidgets.history}</div><div className="col-12 col-xl-4">{allWidgets.status}</div><div className="col-12 col-xl-8">{allWidgets.strings}</div></div></>}
{activeTab === "archive" && <><PageHeader title={language === "en" ? "Historical live data" : "Dane chwilowe z historii"} subtitle={language === "en" ? "Browse all instant metrics for any past period." : "Podgląd metryk chwilowych dla dowolnego okresu, nie tylko live."}><div className="d-flex flex-wrap gap-2 align-items-end"><SegmentedSelect label={language === "en" ? "Range" : "Zakres"} value={archiveStart && archiveEnd ? "custom" : archiveRange} onChange={(value) => { setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={archiveRangeOptions(language)} /><div><label className="form-label small mb-1">{language === "en" ? "From" : "Od"}</label><input className="form-control form-control-sm" type="datetime-local" value={archiveStart} onChange={(e) => { setArchiveRange("custom"); setArchiveStart(e.target.value); }} /></div><div><label className="form-label small mb-1">{language === "en" ? "To" : "Do"}</label><input className="form-control form-control-sm" type="datetime-local" value={archiveEnd} onChange={(e) => { setArchiveRange("custom"); setArchiveEnd(e.target.value); }} /></div></div></PageHeader><div className="row row-cards g-3"><div className="col-12 col-xl-4"><MetricSelectorCard language={language} title={language === "en" ? "Metrics on chart" : "Metryki na wykresie"} items={metricCandidates.filter((item) => item.metric_id !== "energy_total")} selected={archiveMetrics.filter((item) => item !== "energy_total")} onChange={setArchiveMetrics} /></div><div className="col-12 col-xl-8"><LiveHistoryPanel data={archiveQuery.data} language={language} theme={theme} title={language === "en" ? "Historical chart" : "Wykres historyczny"} subtitle={language === "en" ? "Raw instant metrics from InfluxDB only." : "Tylko surowe metryki chwilowe z InfluxDB."} /></div></div></>}
{activeTab === "analytics" && <><PageHeader title={t(language, "analyticsOverview")} subtitle={t(language, "analyticsSubtitle")}><div className="d-flex flex-wrap gap-2 align-items-end"><SegmentedSelect label={t(language, "range")} value={analyticsStart && analyticsEnd ? "custom" : analyticsRange} onChange={(value) => { 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" }]} /><SegmentedSelect label={t(language, "bucket")} value={bucket} onChange={setBucket} options={config.capabilities.buckets.map((item) => ({ key: item.key, label: translateBucket(language, item.key) }))} /><SegmentedSelect label={language === "en" ? "Comparison" : "Porównanie"} value={compare} onChange={setCompare} options={comparisonOptions(language)} /></div></PageHeader>{analyticsStart || analyticsEnd || (analyticsRange === "custom") || compare === "custom_multi" ? <div className="card pv-card mb-3"><div className="card-body d-flex flex-column gap-3"><div className="row g-3"><div className="col-md-3"><label className="form-label">{language === "en" ? "Start" : "Od"}</label><input className="form-control" type="datetime-local" value={analyticsStart} onChange={(e) => setAnalyticsStart(e.target.value)} /></div><div className="col-md-3"><label className="form-label">{language === "en" ? "End" : "Do"}</label><input className="form-control" type="datetime-local" value={analyticsEnd} onChange={(e) => setAnalyticsEnd(e.target.value)} /></div><div className="col-md-6 d-flex align-items-end"><button className="btn btn-outline-secondary" onClick={() => { setAnalyticsStart(""); setAnalyticsEnd(""); }}>{language === "en" ? "Use preset range" : "Wróć do gotowych zakresów"}</button></div></div>{compare === "custom_multi" ? <div className="border rounded-3 p-3"><div className="fw-semibold mb-3">{language === "en" ? "Comparison ranges" : "Zakresy porównawcze"}</div><div className="row g-3">{compareRanges.map((item, index) => <div className="col-12" key={item.key}><div className="row g-2 align-items-end"><div className="col-md-3"><label className="form-label">{language === "en" ? `Range ${index + 1} label` : `Etykieta ${index + 1}`}</label><input className="form-control" value={item.label} onChange={(e) => setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, label: e.target.value } : current))} /></div><div className="col-md-3"><label className="form-label">{language === "en" ? "From" : "Od"}</label><input className="form-control" type="datetime-local" value={item.start} onChange={(e) => setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, start: e.target.value } : current))} /></div><div className="col-md-3"><label className="form-label">{language === "en" ? "To" : "Do"}</label><input className="form-control" type="datetime-local" value={item.end} onChange={(e) => setCompareRanges(compareRanges.map((current) => current.key === item.key ? { ...current, end: e.target.value } : current))} /></div><div className="col-md-3"><button className="btn btn-outline-secondary w-100" onClick={() => setCompareRanges(compareRanges.length > 1 ? compareRanges.filter((current) => current.key !== item.key) : compareRanges)}>{language === "en" ? "Remove range" : "Usuń zakres"}</button></div></div></div>)}<div className="col-12"><button className="btn btn-primary" onClick={() => setCompareRanges([...compareRanges, { key: `cmp_${Date.now()}`, label: `${language === "en" ? "Range" : "Zakres"} ${compareRanges.length + 1}`, start: "", end: "" }])}>{language === "en" ? "Add range" : "Dodaj zakres"}</button></div></div></div> : null}</div></div> : null}<div className="row row-cards g-3"><div className="col-12"><SummaryCards summary={summary} language={language} locale={locale} compareLabel={comparisonOptions(language).find((item) => item.key === compare)?.label ?? compare} /></div><div className="col-12 col-xxl-8">{allWidgets.production}</div><div className="col-12 col-xxl-4">{allWidgets.distribution}</div>{compare !== "none" ? <div className="col-12">{allWidgets.comparison}</div> : null}</div></>}
{activeTab === "warehouse" && <><PageHeader title={language === "en" ? "Data warehouse" : "Hurtownia danych"} subtitle={language === "en" ? "Historical import and coverage." : "Import historyczny i pokrycie danych."} /><div className="row row-cards g-3"><div className="col-12 col-xxl-8"><HistoricalPanel status={historical.status.data} language={language} locale={locale} /></div><div className="col-12 col-xxl-4"><ImportControls status={historical.status.data} language={language} onStart={(payload) => historical.start.mutate(payload)} onSyncNow={() => historical.syncNow.mutate()} onCancel={() => historical.cancel.mutate()} /></div></div></>}
{activeTab === "kiosk" && <><PageHeader title={t(language, "kiosk")} subtitle={language === "en" ? "Kiosk layout and public access." : "Układ kiosku i dostęp publiczny."} /><div className="row row-cards g-3"><div className="col-12 col-xxl-8"><KioskSettingsEditorPanel language={language} value={kioskEditorMode === "public" ? publicKioskDraft : privateKioskDraft} onChange={(value) => 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)} /></div><div className="col-12 col-xxl-4"><KioskLinkPanel language={language} kioskUrl={kioskUrl} settings={publicKioskDraft} /></div></div></>}
{activeTab === "settings" && <><PageHeader title={t(language, "settings")} subtitle={language === "en" ? "Appearance, metric blocks and admin users." : "Wygląd, bloki metryk i użytkownicy."} /><div className="row row-cards g-3"><div className="col-12 col-xxl-4"><AppearanceSecurityPanel language={language} theme={theme} setTheme={setTheme} viewMode={viewMode} setViewMode={setViewMode} authEnabled={config.auth?.enabled ?? false} userName={authQuery.data?.display_name ?? authQuery.data?.user ?? ""} /></div><div className="col-12 col-xxl-8"><div className="row g-3"><div className="col-12"><LiveChartMetricsPanel language={language} items={metricCandidates.filter((item) => item.metric_id !== "energy_total")} selected={liveMetrics.filter((item) => item !== "energy_total")} onChange={setLiveMetrics} /></div><div className="col-12"><BlockVisibilityPanel language={language} items={metricCandidates} config={blockConfig} onChange={setBlockConfig} /></div></div></div>{isAdmin ? <div className="col-12"><AdminUsersPanel language={language} users={usersQuery.data?.items ?? []} newUser={newUser} onNewUserChange={setNewUser} onCreate={() => createUserMutation.mutate()} passwordReset={passwordReset} onPasswordResetChange={setPasswordReset} onResetPassword={() => resetPasswordMutation.mutate()} /></div> : null}</div></>}
</div></div></div></div>
);
}
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> | void; msRequestFullscreen?: () => Promise<void> | 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<string, { pl: string; en: string }> = { 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 <div className="page page-center"><div className="container container-tight py-4 text-center"><div className="spinner-border text-primary mb-3" role="status" /><div className="text-secondary">{t(language, "loading")}</div></div></div>; }
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 <div className="page page-center login-page-shell"><div className="container container-tight py-4"><div className="text-center mb-4"><div className="avatar avatar-xl bg-primary-lt text-primary mb-3 border-0 mx-auto"><IconBolt size={28} /></div><h1 className="h2 mb-1">{t(language, "loginTitle")}</h1><div className="text-secondary">{t(language, "loginSubtitle")}</div></div><div className="card card-md login-card"><div className="card-body"><div className="d-flex justify-content-end gap-2 mb-3"><button className="btn btn-icon btn-ghost-secondary" onClick={onThemeToggle}>{theme === "dark" ? <IconSun size={18} /> : <IconMoon size={18} />}</button><button className="btn btn-icon btn-ghost-secondary" onClick={onLanguageToggle}><IconLanguage size={18} /></button></div><div className="mb-3"><label className="form-label">{t(language, "username")}</label><input className="form-control" value={form.username} onChange={(event) => onChange({ ...form, username: event.target.value })} autoComplete="username" /></div><div className="mb-3"><label className="form-label">{t(language, "password")}</label><input className="form-control" type="password" value={form.password} onChange={(event) => onChange({ ...form, password: event.target.value })} autoComplete="current-password" onKeyDown={(event) => event.key === "Enter" && onSubmit()} /></div>{error ? <div className="alert alert-danger py-2">{error}</div> : null}<button className="btn btn-primary w-100" onClick={onSubmit} disabled={loading}><IconLogin2 size={18} className="me-1" />{t(language, "signIn")}</button></div></div></div></div>; }
function NavItem({ icon, active, onClick, label }: { icon: ReactElement; active: boolean; onClick: () => void; label: string }) { return <li className="nav-item"><button className={`nav-link border-0 bg-transparent pv-nav-link ${active ? "active" : ""}`} onClick={onClick}><span className="pv-nav-icon">{icon}</span><span className="pv-nav-title">{label}</span></button></li>; }
function PageHeader({ title, subtitle, children }: { title: string; subtitle: string; children?: ReactNode }) { return <div className="page-header d-print-none mb-3"><div className="row align-items-center"><div className="col"><div className="page-pretitle">PV Insight</div><h2 className="page-title mb-1">{title}</h2><div className="text-secondary">{subtitle}</div></div>{children ? <div className="col-auto ms-auto">{children}</div> : null}</div></div>; }
function SegmentedSelect({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return <div className="btn-list align-items-center flex-nowrap"><span className="text-secondary small me-2 d-none d-md-inline">{label}</span><div className="btn-group">{options.map((option) => <button key={option.key} className={`btn btn-sm ${value === option.key ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => onChange(option.key)}>{option.label}</button>)}</div></div>; }
function HeroCards({ cards, locale, language }: { cards: SnapshotPayload["hero_cards"]; locale: string; language: Language }) { return <div className="row row-cards g-3">{cards.map((card) => <div key={card.metric_id} className="col-12 col-sm-6 col-xl-3"><div className="card pv-card pv-hero-card h-100"><div className="card-body"><div className="d-flex align-items-center justify-content-between mb-3"><span className="avatar avatar-sm bg-primary-lt text-primary border-0">{iconForMetric(card.metric_id)}</span><span className="badge bg-primary-lt text-primary text-uppercase">{card.unit || "live"}</span></div><div className="text-secondary text-uppercase small mb-1">{labelForMetric(language, card.metric_id, card.label)}</div><div className="display-6 fw-bold mb-1">{formatValue(card.value, card.unit, card.unit === "kWh" ? 2 : 2, locale)}</div><div className="text-secondary small">{card.subtitle}</div></div></div></div>)}</div>; }
function QuickMetrics({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "quickMetrics")}</h3></div><div className="list-group list-group-flush list-group-hoverable">{metrics.map((metric) => <div className="list-group-item" key={metric.metric_id}><div className="row align-items-center"><div className="col-auto text-primary">{iconForMetric(metric.metric_id)}</div><div className="col text-truncate"><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit || t(language, "status")}</div></div><div className="col-auto fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div></div>)}</div></div>; }
function LiveHistoryPanel({ data, language, theme, title, subtitle }: { data?: HistoryPayload; language: Language; theme: ThemeMode; title: string; subtitle: string }) { return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{title}</h3><div className="text-secondary small">{subtitle}</div></div></div><div className="card-body"><EChart option={buildLiveHistoryOption(data, theme, language)} className="pv-chart" /></div></div>; }
function StatusPanel({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "systemStatus")}</h3></div><div className="card-body d-flex flex-column gap-3">{metrics.map((metric) => <div key={metric.metric_id} className="d-flex justify-content-between align-items-center border rounded-3 px-3 py-2 status-row"><div><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit || t(language, "status")}</div></div><div className="fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div>)}</div></div>; }
function StringsPanel({ rows, locale, language }: { rows: SnapshotGroupRow[]; locale: string; language: Language }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "strings")}</h3></div><div className="card-body"><div className="row g-3">{rows.length === 0 ? <div className="col-12 text-secondary">{t(language, "noDataDescription")}</div> : rows.map((row) => <div className="col-12 col-md-6" key={row.id}><div className="border rounded-3 p-3 h-100 string-panel"><div className="d-flex align-items-center justify-content-between mb-3"><div className="fw-semibold">{row.label}</div><span className="badge bg-azure-lt text-azure">DC</span></div><div className="d-flex flex-column gap-2">{Object.values(row.values).map((metric) => <div key={metric.metric_id} className="d-flex justify-content-between small"><span className="text-secondary">{labelForMetric(language, metric.metric_id, metric.label)}</span><span className="fw-medium">{formatValue(metric.value, metric.unit, 2, locale)}</span></div>)}</div></div></div>)}</div></div></div>; }
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 <div className="row row-cards g-3">{items.map((item) => <div className="col-12 col-sm-6 col-xl-3" key={item.key}><div className="card pv-card h-100"><div className="card-body"><div className="text-secondary text-uppercase small mb-1">{item.key}</div><div className="h2 mb-0">{item.value}</div>{item.badge ? <div className="mt-2"><span className="badge bg-primary-lt text-primary">{item.badge}</span></div> : null}</div></div></div>)}</div>; }
function ProductionPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) { return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{t(language, "chartProduction")}</h3><div className="text-secondary small">{t(language, "chartProductionSubtitle")}</div></div></div><div className="card-body"><EChart option={buildBarOption(data?.current ?? [], data?.unit ?? "kWh", theme, language)} className="pv-chart" /></div></div>; }
function ComparisonPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "chartComparison")}</h3></div><div className="card-body"><EChart option={buildComparisonOption(data, theme, language)} className="pv-chart" /></div></div>; }
function DistributionPanel({ data, language, theme, locale }: { data?: DistributionPayload; language: Language; theme: ThemeMode; locale: string }) { return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{t(language, "chartDistribution")}</h3></div><div className="card-body"><div className="mb-3 fw-semibold">{formatValue(data?.total, data?.unit ?? "kWh", 2, locale)}</div><EChart option={buildPieOption(data, theme)} className="pv-chart-sm" /></div></div>; }
function HistoricalPanel({ status, language, locale, compact = false }: { status?: HistoricalStatus; language: Language; locale: string; compact?: boolean }) { if (!status) return <div className="card pv-card h-100"><div className="card-body text-secondary">{t(language, "noDataDescription")}</div></div>; return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{language === "en" ? "Data warehouse" : "Hurtownia danych"}</h3><div className="text-secondary small">{t(language, "importArchiveSubtitle")}</div></div></div><div className="card-body d-flex flex-column gap-4"><div className="row g-3"><StatusStat label={t(language, "status")} value={status.message || status.state} /><StatusStat label={t(language, "coverage")} value={formatPercent(status.coverage.coverage_pct ?? 0, 1, locale)} /><StatusStat label={t(language, "importedDays")} value={formatValue(status.coverage.imported_days, "", 0, locale)} /><StatusStat label={t(language, "missingDays")} value={formatValue(status.coverage.missing_days, "", 0, locale)} /><StatusStat label={t(language, "throughput")} value={`${formatValue(status.avg_days_per_minute ?? 0, "", 1, locale)} / min`} /><StatusStat label={t(language, "eta")} value={formatDurationShort(status.estimated_remaining_seconds, locale)} /></div>{!compact ? <><div><div className="d-flex justify-content-between small mb-2"><span className="text-secondary">{t(language, "activeChunk")}</span><span className="fw-medium">{status.active_chunk_index}/{status.total_chunks}</span></div><div className="progress progress-sm"><div className="progress-bar" style={{ width: `${Math.min((status.processed_days / Math.max(status.total_days, 1)) * 100, 100)}%` }} /></div></div><div className="row g-3"><div className="col-12 col-xl-6"><div className="table-responsive"><table className="table table-vcenter card-table table-sm"><thead><tr><th>{t(language, "recentChunks")}</th><th>{t(language, "status")}</th><th className="text-end">kWh</th></tr></thead><tbody>{status.recent_chunks.map((chunk) => <tr key={`${chunk.chunk_index}-${chunk.start_date}`}><td><div className="fw-medium">#{chunk.chunk_index}</div><div className="text-secondary small">{chunk.start_date} {chunk.end_date}</div></td><td>{chunk.state}</td><td className="text-end">{formatValue(chunk.energy_kwh, "kWh", 2, locale)}</td></tr>)}</tbody></table></div></div><div className="col-12 col-xl-6"><div className="list-group list-group-flush">{status.recent_events.map((event, index) => <div className="list-group-item px-0" key={`${event.timestamp}-${index}`}><div className="d-flex justify-content-between gap-2"><div><div className="fw-medium">{event.title}</div><div className="text-secondary small">{event.message}</div></div><div className="text-secondary small text-nowrap">{formatShortTime(event.timestamp, locale)}</div></div></div>)}</div></div></div></> : null}</div></div>; }
function StatusStat({ label, value }: { label: string; value: string }) { return <div className="col-6 col-md-4 col-xl-2"><div className="border rounded-3 px-3 py-2 h-100 bg-body-tertiary"><div className="text-secondary small mb-1">{label}</div><div className="fw-semibold">{value}</div></div></div>; }
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 <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{language === "en" ? "Import controls" : "Sterowanie importem"}</h3></div><div className="card-body d-flex flex-column gap-3"><div><label className="form-label">{t(language, "startDate")}</label><input className="form-control" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} /></div><div><label className="form-label">{t(language, "endDate")}</label><input className="form-control" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} /></div><div><label className="form-label">{t(language, "chunkDays")}</label><input className="form-control" type="number" min={1} max={31} value={chunkDays} onChange={(event) => setChunkDays(event.target.value)} /></div><div className="d-grid gap-2"><button className="btn btn-primary" onClick={() => onStart({ start_date: startDate || undefined, end_date: endDate || undefined, chunk_days: Number(chunkDays) || undefined, force: true })}><IconPlayerPlay size={18} className="me-1" />{t(language, "startImport")}</button><button className="btn btn-outline-secondary" onClick={onSyncNow}><IconRefresh size={18} className="me-1" />{t(language, "syncMissing")}</button><button className="btn btn-outline-danger" onClick={onCancel} disabled={!status?.running}><IconX size={18} className="me-1" />{t(language, "cancel")}</button></div></div></div>; }
function KioskLayoutPanel({ language, widgets, onChange, labels }: { language: Language; widgets: WidgetId[]; onChange: (value: WidgetId[]) => void; labels: Map<WidgetId, string>; }) { 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 <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{t(language, "kioskLayout")}</h3><div className="text-secondary small">{t(language, "kioskLayoutSubtitle")}</div></div></div><div className="card-body"><div className="alert alert-info py-2">{t(language, "saveLayout")}</div><div className="row g-3"><div className="col-12 col-lg-7"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{t(language, "selected")}</div><div className="d-flex flex-column gap-2">{selected.map((id) => <div key={id} className="d-flex align-items-center justify-content-between gap-2 border rounded-3 px-3 py-2 bg-body-tertiary"><span>{labels.get(id)}</span><div className="btn-list"><button className="btn btn-sm btn-outline-secondary" onClick={() => move(id, -1)}>{t(language, "moveUp")}</button><button className="btn btn-sm btn-outline-secondary" onClick={() => move(id, 1)}>{t(language, "moveDown")}</button><button className="btn btn-sm btn-outline-danger" onClick={() => toggle(id)}><IconX size={16} /></button></div></div>)}</div></div></div><div className="col-12 col-lg-5"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{t(language, "available")}</div><div className="d-flex flex-wrap gap-2">{unselected.map((id) => <button key={id} className="btn btn-outline-primary" onClick={() => toggle(id)}>{labels.get(id)}</button>)}</div></div></div></div></div></div>; }
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<WidgetId, string>; buckets: Array<{ key: string; label: string }>; compareModes: string[]; saving: boolean; }) { const widgets = toWidgetIds(value.widgets); return <div className="d-flex flex-column gap-3"><div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Kiosk settings" : "Ustawienia kiosku"}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="btn-group w-100"><button className={`btn ${selectedMode === "private" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => onModeChange("private")}>{language === "en" ? "Logged-in kiosk" : "Kiosk prywatny"}</button><button className={`btn ${selectedMode === "public" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => onModeChange("public")}>{language === "en" ? "Public kiosk" : "Kiosk publiczny"}</button></div><div className="row g-3"><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Live chart range" : "Zakres wykresu live"}</label><select className="form-select" value={value.realtime_range} onChange={(e) => onChange({ ...value, realtime_range: e.target.value })}>{liveRangeOptions(language).map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Analytics range" : "Zakres analityki"}</label><select className="form-select" value={value.analytics_range} onChange={(e) => onChange({ ...value, analytics_range: e.target.value })}>{analyticsRangeOptions(language).map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">Bucket</label><select className="form-select" value={value.analytics_bucket} onChange={(e) => onChange({ ...value, analytics_bucket: e.target.value })}>{buckets.map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Comparison" : "Porównanie"}</label><select className="form-select" value={value.compare_mode} onChange={(e) => onChange({ ...value, compare_mode: e.target.value })}><option value="none">{translateCompareMode(language, "none")}</option>{compareModes.filter((item) => item !== "none").map((item) => <option key={item} value={item}>{translateCompareMode(language, item)}</option>)}</select></div></div><div className="d-flex justify-content-end"><button className="btn btn-primary" onClick={onSave} disabled={saving}>{saving ? (language === "en" ? "Saving..." : "Zapisywanie...") : (language === "en" ? "Save kiosk settings" : "Zapisz ustawienia kiosku")}</button></div></div></div><KioskLayoutPanel language={language} widgets={widgets} onChange={(widgetsValue) => onChange({ ...value, widgets: widgetsValue })} labels={labels} /></div>; }
function KioskLinkPanel({ language, kioskUrl, settings }: { language: Language; kioskUrl: string; settings: KioskSettingsPayload }) { const [copied, setCopied] = useState(false); return <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{language === "en" ? "Public kiosk link" : "Publiczny link kiosku"}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="text-secondary small">{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."}</div><input className="form-control" value={kioskUrl} readOnly /><div className="small text-secondary">{language === "en" ? "Current public ranges:" : "Aktualne zakresy publiczne:"} live {settings.realtime_range}, analytics {settings.analytics_range}</div><button className="btn btn-primary" onClick={async () => { await navigator.clipboard.writeText(kioskUrl); setCopied(true); window.setTimeout(() => setCopied(false), 1500); }}>{copied ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy link" : "Kopiuj link")}</button></div></div>; }
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 <div className="d-flex flex-column gap-3"><div className="card pv-card"><div className="card-header"><h3 className="card-title">{t(language, "theme")}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="btn-group w-100"><button className={`btn ${theme === "light" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setTheme("light")}>{t(language, "light")}</button><button className={`btn ${theme === "dark" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setTheme("dark")}>{t(language, "dark")}</button></div></div></div><div className="card pv-card"><div className="card-header"><h3 className="card-title">{t(language, "viewMode")}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="btn-group w-100"><button className={`btn ${viewMode === "normal" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setViewMode("normal")}>{t(language, "normalMode")}</button><button className={`btn ${viewMode === "kiosk" ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => setViewMode("kiosk")}>{t(language, "kioskMode")}</button></div></div></div><div className="card pv-card"><div className="card-header"><h3 className="card-title">{t(language, "security")}</h3></div><div className="card-body d-flex flex-column gap-2"><div className="d-flex align-items-center gap-2 fw-medium"><IconLock size={18} /> {authEnabled ? t(language, "authEnabled") : t(language, "authDisabled")}</div><div className="text-secondary small">{language === "en" ? "Admin user management is available below." : "Zarządzanie użytkownikami admina jest dostępne niżej."}</div>{userName ? <div className="badge bg-primary-lt text-primary align-self-start">{userName}</div> : null}</div></div></div>; }
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 <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{title}</h3></div><div className="card-body d-flex flex-column gap-2">{items.map((item) => <label key={item.metric_id} className="form-check"><input className="form-check-input" type="checkbox" checked={selected.includes(item.metric_id)} onChange={() => toggle(item.metric_id)} /><span className="form-check-label">{item.label} <span className="text-secondary small">{item.unit}</span></span></label>)}</div></div>; }
function LiveChartMetricsPanel({ language, items, selected, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; }) { return <MetricSelectorCard language={language} title={language === "en" ? "Live chart metrics" : "Metryki wykresu live"} items={items} selected={selected} onChange={onChange} />; }
function BlockVisibilityPanel({ language, items, config, onChange }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; config: Record<BlockTarget, string[]>; onChange: (value: Record<BlockTarget, string[]>) => 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 <div className="card pv-card h-100"><div className="card-header"><h3 className="card-title">{language === "en" ? "Metric visibility in blocks" : "Widoczność metryk w blokach"}</h3></div><div className="card-body"><div className="row g-3"><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">Hero metrics</div>{items.map((item) => <label key={`hero-${item.metric_id}`} className="form-check d-block mb-2"><input className="form-check-input" type="checkbox" checked={config.hero.includes(item.metric_id)} onChange={() => toggle("hero", item.metric_id)} /><span className="form-check-label">{item.label}</span></label>)}</div></div><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">Quick metrics</div>{items.map((item) => <label key={`quick-${item.metric_id}`} className="form-check d-block mb-2"><input className="form-check-input" type="checkbox" checked={config.quick.includes(item.metric_id)} onChange={() => toggle("quick", item.metric_id)} /><span className="form-check-label">{item.label}</span></label>)}</div></div></div></div></div>; }
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 <div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Admin user management" : "Zarządzanie użytkownikami"}</h3></div><div className="card-body"><div className="row g-3"><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{language === "en" ? "Create user" : "Dodaj użytkownika"}</div><div className="row g-2"><div className="col-md-6"><input className="form-control" placeholder={language === "en" ? "Username" : "Login"} value={newUser.username} onChange={(e) => onNewUserChange({ ...newUser, username: e.target.value })} /></div><div className="col-md-6"><input className="form-control" placeholder={language === "en" ? "Display name" : "Nazwa"} value={newUser.display_name} onChange={(e) => onNewUserChange({ ...newUser, display_name: e.target.value })} /></div><div className="col-md-6"><input className="form-control" type="password" placeholder={language === "en" ? "Password" : "Hasło"} value={newUser.password} onChange={(e) => onNewUserChange({ ...newUser, password: e.target.value })} /></div><div className="col-md-6"><select className="form-select" value={newUser.role} onChange={(e) => onNewUserChange({ ...newUser, role: e.target.value })}><option value="user">user</option><option value="admin">admin</option></select></div><div className="col-12"><button className="btn btn-primary" onClick={onCreate}>{language === "en" ? "Create user" : "Dodaj użytkownika"}</button></div></div></div></div><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{language === "en" ? "Reset password" : "Zmiana hasła"}</div><div className="row g-2"><div className="col-md-6"><select className="form-select" value={passwordReset.username} onChange={(e) => onPasswordResetChange({ ...passwordReset, username: e.target.value })}><option value="">{language === "en" ? "Select user" : "Wybierz użytkownika"}</option>{users.map((user) => <option key={user.username} value={user.username}>{user.username}</option>)}</select></div><div className="col-md-6"><input className="form-control" type="password" placeholder={language === "en" ? "New password" : "Nowe hasło"} value={passwordReset.password} onChange={(e) => onPasswordResetChange({ ...passwordReset, password: e.target.value })} /></div><div className="col-12"><button className="btn btn-outline-primary" onClick={onResetPassword}>{language === "en" ? "Reset password" : "Zmień hasło"}</button></div></div></div></div><div className="col-12"><div className="table-responsive"><table className="table table-vcenter card-table"><thead><tr><th>{language === "en" ? "Username" : "Login"}</th><th>{language === "en" ? "Display name" : "Nazwa"}</th><th>Role</th><th>{language === "en" ? "Updated" : "Aktualizacja"}</th></tr></thead><tbody>{users.map((user) => <tr key={user.username}><td>{user.username}</td><td>{user.display_name}</td><td><span className="badge bg-primary-lt text-primary">{user.role}</span></td><td>{formatDateTime(user.updated_at, language === "en" ? "en-GB" : "pl-PL")}</td></tr>)}</tbody></table></div></div></div></div></div>; }

145
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,145 @@
import type {
AnalyticsPayload,
AuthStatus,
AuthUsersPayload,
DashboardConfig,
KioskSettingsPayload,
DistributionPayload,
HistoryPayload,
HistoricalStartPayload,
HistoricalStatus,
SnapshotPayload,
} from "../types";
import {
demoAnalytics,
demoAuthStatus,
demoConfig,
demoDistribution,
demoHistory,
demoHistoricalStatus,
demoSnapshot,
} from "../demo/data";
function defaultApiBase(): string {
const explicit = import.meta.env.VITE_API_BASE_URL;
if (explicit) return explicit;
const { protocol, hostname, port } = window.location;
if (port === "5173" || port === "4173") {
return `${protocol}//${hostname}:8105/api/v1`;
}
return "/api/v1";
}
const API_BASE = defaultApiBase();
const DEMO_MODE = String(import.meta.env.VITE_DEMO_MODE ?? "").toLowerCase() === "true";
const locationUrl = new URL(window.location.href);
const PUBLIC_KIOSK = locationUrl.searchParams.get("publicKiosk") === "1" || locationUrl.pathname.endsWith("/kiosk/public");
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const method = (init?.method || "GET").toUpperCase();
const resolvedPath = PUBLIC_KIOSK && method === "GET" ? `${path}${path.includes("?") ? "&" : "?"}publicKiosk=1` : path;
const response = await fetch(`${API_BASE}${resolvedPath}`, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return {} as T;
}
return response.json() as Promise<T>;
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
async function demoResponse<T>(factory: () => T): Promise<T> {
await new Promise((resolve) => window.setTimeout(resolve, 120));
return clone(factory());
}
export const api = {
getConfig: () => (DEMO_MODE ? demoResponse(() => demoConfig) : request<DashboardConfig>("/dashboard/config")),
getKioskSettings: (mode: "public" | "private") => (DEMO_MODE ? demoResponse(() => ({ mode, widgets: ["hero", "history", "strings", "status", "production", "comparison", "importStatus"], realtime_range: "12h", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" })) : request<KioskSettingsPayload>(`/dashboard/kiosk-settings?mode=${mode}`)),
saveKioskSettings: (payload: KioskSettingsPayload) => (DEMO_MODE ? demoResponse(() => payload) : request<KioskSettingsPayload>("/dashboard/kiosk-settings", { method: "PUT", body: JSON.stringify(payload) })),
getAuthStatus: () => (DEMO_MODE ? demoResponse(() => demoAuthStatus) : request<AuthStatus>("/auth/status")),
login: (username: string, password: string) =>
DEMO_MODE
? demoResponse(() => ({ ...demoAuthStatus, authenticated: true, user: username || "demo", display_name: username || "demo" }))
: request<AuthStatus>("/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }),
logout: () => (DEMO_MODE ? demoResponse(() => ({ ...demoAuthStatus, authenticated: false })) : request<AuthStatus>("/auth/logout", { method: "POST", body: JSON.stringify({}) })),
getRealtimeSnapshot: () => (DEMO_MODE ? demoResponse(() => demoSnapshot()) : request<SnapshotPayload>("/realtime/snapshot")),
getRealtimeHistory: (range: string, options?: { start?: string; end?: string; metrics?: string[]; publicKiosk?: boolean }) => {
const params = new URLSearchParams();
params.set("range", range);
if (options?.start) params.set("start", options.start);
if (options?.end) params.set("end", options.end);
if (options?.metrics?.length) params.set("metrics", options.metrics.join(","));
if (options?.publicKiosk) params.set("publicKiosk", "1");
return DEMO_MODE ? demoResponse(() => ({ ...demoHistory, range_key: range })) : request<HistoryPayload>(`/realtime/history?${params.toString()}`);
},
getAnalytics: (range: string, bucket: string, compare: string, options?: { start?: string; end?: string; publicKiosk?: boolean; compareRanges?: Array<{ start: string; end: string; label: string; key?: string }> }) => {
const params = new URLSearchParams();
params.set("range", range);
params.set("bucket", bucket);
params.set("compare", compare);
if (options?.start) params.set("start", options.start);
if (options?.end) params.set("end", options.end);
if (options?.publicKiosk) params.set("publicKiosk", "1");
if (options?.compareRanges?.length) params.set("compare_ranges", JSON.stringify(options.compareRanges));
return DEMO_MODE
? demoResponse(() => ({
...demoAnalytics,
bucket,
compare_mode: compare,
meta: { ...demoAnalytics.meta, window: { ...demoAnalytics.meta.window, range_key: range } },
}))
: request<AnalyticsPayload>(`/analytics/production?${params.toString()}`);
},
getDistribution: (range: string, bucket: string, options?: { start?: string; end?: string; publicKiosk?: boolean }) => {
const params = new URLSearchParams();
params.set("range", range);
params.set("bucket", bucket);
if (options?.start) params.set("start", options.start);
if (options?.end) params.set("end", options.end);
if (options?.publicKiosk) params.set("publicKiosk", "1");
return DEMO_MODE ? demoResponse(() => ({ ...demoDistribution, bucket })) : request<DistributionPayload>(`/analytics/distribution?${params.toString()}`);
},
getHistoricalStatus: () => (DEMO_MODE ? demoResponse(() => demoHistoricalStatus) : request<HistoricalStatus>("/historical/status")),
startHistoricalImport: (payload: HistoricalStartPayload) =>
DEMO_MODE
? demoResponse(() => ({ ...demoHistoricalStatus, ...payload, message: "Tryb demo: import uruchomiony", running: true }))
: request<HistoricalStatus>("/historical/start", { method: "POST", body: JSON.stringify(payload) }),
syncHistoricalNow: () =>
DEMO_MODE
? demoResponse(() => ({ ...demoHistoricalStatus, message: "Tryb demo: synchronizacja brakujacych dni" }))
: request<HistoricalStatus>("/historical/sync-now", { method: "POST", body: JSON.stringify({}) }),
cancelHistoricalImport: () =>
DEMO_MODE
? demoResponse(() => ({ ...demoHistoricalStatus, running: false, state: "cancelled", message: "Tryb demo: anulowano" }))
: request<HistoricalStatus>("/historical/cancel", { method: "POST", body: JSON.stringify({}) }),
getUsers: () => (DEMO_MODE ? demoResponse(() => ({ items: [] })) : request<AuthUsersPayload>("/auth/users")),
createUser: (payload: { username: string; password: string; role: string; display_name?: string }) =>
DEMO_MODE ? demoResponse(() => payload) : request("/auth/users", { method: "POST", body: JSON.stringify(payload) }),
resetUserPassword: (username: string, password: string) =>
DEMO_MODE ? demoResponse(() => ({ username })) : request(`/auth/users/${encodeURIComponent(username)}/reset-password`, { method: "POST", body: JSON.stringify({ password }) }),
};
export const wsBaseUrl = (): string => {
const explicit = import.meta.env.VITE_WS_BASE_URL;
if (explicit) return explicit.endsWith("/") ? explicit.slice(0, -1) : explicit;
const { protocol, hostname, port } = window.location;
const wsProtocol = protocol === "https:" ? "wss:" : "ws:";
const raw = (port === "5173" || port === "4173") ? `${wsProtocol}//${hostname}:8105` : `${wsProtocol}//${window.location.host}`;
return raw.endsWith("/") ? raw.slice(0, -1) : raw;
};

View File

@@ -0,0 +1,66 @@
import type { EChartsOption } from "echarts";
import { Card } from "../common/Card";
import { EChart } from "../common/EChart";
import type { BucketPoint } from "../../types";
interface ComparisonChartProps {
current: BucketPoint[];
comparison: BucketPoint[];
unit: string;
compareMode: string;
}
export function ComparisonChart({ current, comparison, unit, compareMode }: ComparisonChartProps) {
const option: EChartsOption = {
tooltip: {
trigger: "axis",
backgroundColor: "rgba(2, 6, 23, 0.95)",
borderColor: "rgba(255,255,255,0.08)",
textStyle: { color: "#e2e8f0" },
},
legend: {
top: 0,
textStyle: { color: "#cbd5e1" },
},
grid: {
left: 18,
right: 16,
top: 46,
bottom: 24,
containLabel: true,
},
xAxis: {
type: "category",
axisLabel: { color: "#94a3b8", rotate: current.length > 12 ? 35 : 0 },
axisLine: { lineStyle: { color: "rgba(255,255,255,0.08)" } },
data: current.map((item) => item.label),
},
yAxis: {
type: "value",
name: unit,
axisLabel: { color: "#94a3b8" },
splitLine: { lineStyle: { color: "rgba(255,255,255,0.06)" } },
},
series: [
{
name: "Aktualny okres",
type: "bar",
barGap: "20%",
itemStyle: { color: "#60a5fa", borderRadius: [10, 10, 0, 0] },
data: current.map((item) => item.value),
},
{
name: compareMode === "previous_year" ? "Poprzedni rok" : "Poprzedni okres",
type: "bar",
itemStyle: { color: "#f59e0b", borderRadius: [10, 10, 0, 0] },
data: current.map((_, index) => comparison[index]?.value ?? 0),
},
],
};
return (
<Card title="Porownanie okresow" subtitle="Wspolne slupki dla aktualnego i porownawczego okresu">
<EChart option={option} className="h-[340px] w-full" />
</Card>
);
}

View File

@@ -0,0 +1,51 @@
import type { EChartsOption } from "echarts";
import { Card } from "../common/Card";
import { EChart } from "../common/EChart";
import type { DistributionPayload } from "../../types";
interface DistributionPieChartProps {
distribution?: DistributionPayload;
}
export function DistributionPieChart({ distribution }: DistributionPieChartProps) {
const option: EChartsOption = {
tooltip: {
trigger: "item",
backgroundColor: "rgba(2, 6, 23, 0.95)",
borderColor: "rgba(255,255,255,0.08)",
textStyle: { color: "#e2e8f0" },
},
legend: {
orient: "vertical",
right: 0,
top: "center",
textStyle: { color: "#cbd5e1" },
},
series: [
{
type: "pie",
radius: ["42%", "68%"],
center: ["38%", "50%"],
padAngle: 2,
itemStyle: {
borderColor: "#020617",
borderWidth: 4,
},
label: {
color: "#e2e8f0",
formatter: "{b}: {d}%",
},
data: distribution?.slices.map((item) => ({
name: item.label,
value: item.value,
})) ?? [],
},
],
};
return (
<Card title="Udzial produkcji" subtitle="Wykres kolowy z rozkladem produkcji w wybranym okresie">
<EChart option={option} className="h-[340px] w-full" />
</Card>
);
}

View File

@@ -0,0 +1,76 @@
import { Card } from "../common/Card";
interface PeriodControlsProps {
rangeKey: string;
bucket: string;
compare: string;
ranges: Array<{ key: string; label: string }>;
buckets: Array<{ key: string; label: string }>;
compareModes: Array<{ key: string; label: string }>;
onRangeChange: (value: string) => void;
onBucketChange: (value: string) => void;
onCompareChange: (value: string) => void;
}
export function PeriodControls({
rangeKey,
bucket,
compare,
ranges,
buckets,
compareModes,
onRangeChange,
onBucketChange,
onCompareChange,
}: PeriodControlsProps) {
return (
<Card title="Porownanie okresow" subtitle="Dzien / tydzien / miesiac + poprzedni okres lub poprzedni rok">
<div className="grid gap-4 lg:grid-cols-3">
<label className="space-y-2">
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">Zakres</span>
<select
value={rangeKey}
onChange={(event) => onRangeChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3 text-sm text-white outline-none"
>
{ranges.map((item) => (
<option key={item.key} value={item.key}>
{item.label}
</option>
))}
</select>
</label>
<label className="space-y-2">
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">Bucket</span>
<select
value={bucket}
onChange={(event) => onBucketChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3 text-sm text-white outline-none"
>
{buckets.map((item) => (
<option key={item.key} value={item.key}>
{item.label}
</option>
))}
</select>
</label>
<label className="space-y-2">
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">Porownanie</span>
<select
value={compare}
onChange={(event) => onCompareChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3 text-sm text-white outline-none"
>
{compareModes.map((item) => (
<option key={item.key} value={item.key}>
{item.label}
</option>
))}
</select>
</label>
</div>
</Card>
);
}

View File

@@ -0,0 +1,56 @@
import type { EChartsOption } from "echarts";
import { Card } from "../common/Card";
import { EChart } from "../common/EChart";
import type { BucketPoint } from "../../types";
interface ProductionBarChartProps {
current: BucketPoint[];
unit: string;
}
export function ProductionBarChart({ current, unit }: ProductionBarChartProps) {
const option: EChartsOption = {
tooltip: {
trigger: "axis",
backgroundColor: "rgba(2, 6, 23, 0.95)",
borderColor: "rgba(255,255,255,0.08)",
textStyle: { color: "#e2e8f0" },
},
grid: {
left: 18,
right: 16,
top: 30,
bottom: 24,
containLabel: true,
},
xAxis: {
type: "category",
axisLabel: { color: "#94a3b8", rotate: current.length > 12 ? 35 : 0 },
axisLine: { lineStyle: { color: "rgba(255,255,255,0.08)" } },
data: current.map((item) => item.label),
},
yAxis: {
type: "value",
name: unit,
axisLabel: { color: "#94a3b8" },
splitLine: { lineStyle: { color: "rgba(255,255,255,0.06)" } },
},
series: [
{
type: "bar",
barMaxWidth: 26,
itemStyle: {
borderRadius: [10, 10, 0, 0],
color: "#34d399",
},
data: current.map((item) => item.value),
},
],
};
return (
<Card title="Produkcja dlugoterminowa" subtitle="Wykres slupkowy agregowany wedlug wybranego bucketu">
<EChart option={option} className="h-[340px] w-full" />
</Card>
);
}

View File

@@ -0,0 +1,34 @@
import { Card } from "../common/Card";
import { formatValue } from "../../lib/format";
import type { AnalyticsSummary } from "../../types";
interface SummaryCardsProps {
summary: AnalyticsSummary;
}
export function SummaryCards({ summary }: SummaryCardsProps) {
const tiles = [
{ label: "Produkcja", value: formatValue(summary.total, summary.unit, 2) },
{ label: "Srednio / bucket", value: formatValue(summary.average_bucket, summary.unit, 2) },
{ label: "Najlepszy bucket", value: `${summary.best_bucket_label || "--"} / ${formatValue(summary.best_bucket_value, summary.unit, 2)}` },
{ label: "CO2 mniej", value: formatValue(summary.co2_saved_kg, "kg", 2) },
{
label: "Delta vs porownanie",
value:
summary.comparison_delta_pct === null || summary.comparison_delta_pct === undefined
? "--"
: formatValue(summary.comparison_delta_pct, "%", 2),
},
];
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
{tiles.map((tile) => (
<Card key={tile.label} className="bg-slate-950/35">
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">{tile.label}</div>
<div className="mt-3 text-2xl font-semibold text-white">{tile.value}</div>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,21 @@
import clsx from "clsx";
import type { PropsWithChildren } from "react";
interface BadgeProps extends PropsWithChildren {
tone?: "ok" | "warn" | "critical" | "neutral";
}
export function Badge({ tone = "neutral", children }: BadgeProps) {
const palette = {
ok: "border-emerald-400/30 bg-emerald-500/10 text-emerald-200",
warn: "border-amber-400/30 bg-amber-500/10 text-amber-200",
critical: "border-rose-400/30 bg-rose-500/10 text-rose-200",
neutral: "border-white/10 bg-white/5 text-slate-200",
};
return (
<span className={clsx("inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium", palette[tone])}>
{children}
</span>
);
}

View File

@@ -0,0 +1,31 @@
import clsx from "clsx";
import type { PropsWithChildren, ReactNode } from "react";
interface CardProps extends PropsWithChildren {
title?: string;
subtitle?: string;
action?: ReactNode;
className?: string;
}
export function Card({ title, subtitle, action, className, children }: CardProps) {
return (
<section
className={clsx(
"rounded-3xl border border-white/10 bg-white/5 p-5 shadow-[0_24px_80px_rgba(15,23,42,0.35)] backdrop-blur",
className
)}
>
{(title || subtitle || action) && (
<header className="mb-4 flex items-start justify-between gap-4">
<div>
{title && <h3 className="text-base font-semibold text-white">{title}</h3>}
{subtitle && <p className="mt-1 text-sm text-slate-400">{subtitle}</p>}
</div>
{action}
</header>
)}
{children}
</section>
);
}

View File

@@ -0,0 +1,31 @@
import { useEffect, useRef } from "react";
import * as echarts from "echarts";
import type { EChartsOption } from "echarts";
interface EChartProps {
option: EChartsOption;
className?: string;
}
export function EChart({ option, className = "h-80 w-full" }: EChartProps) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!ref.current) {
return;
}
const chart = echarts.init(ref.current);
chart.setOption(option);
const observer = new ResizeObserver(() => chart.resize());
observer.observe(ref.current);
return () => {
observer.disconnect();
chart.dispose();
};
}, [option]);
return <div ref={ref} className={className} />;
}

View File

@@ -0,0 +1,13 @@
interface EmptyStateProps {
title: string;
description: string;
}
export function EmptyState({ title, description }: EmptyStateProps) {
return (
<div className="rounded-3xl border border-dashed border-white/10 bg-white/3 p-10 text-center">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<p className="mt-2 text-sm text-slate-400">{description}</p>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import type { SVGProps } from "react";
type IconProps = SVGProps<SVGSVGElement> & { size?: number };
function BaseIcon({ size = 18, children, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
{children}
</svg>
);
}
export function IconBolt(props: IconProps) {
return <BaseIcon {...props}><path d="M13 2L5 14h6l-1 8 8-12h-6l1-8z" /></BaseIcon>;
}
export function IconChartBar(props: IconProps) {
return <BaseIcon {...props}><path d="M4 20h16" /><path d="M7 16V8" /><path d="M12 16V4" /><path d="M17 16v-6" /></BaseIcon>;
}
export function IconChecklist(props: IconProps) {
return <BaseIcon {...props}><path d="M9 6h11" /><path d="M9 12h11" /><path d="M9 18h11" /><path d="M4 6l1.5 1.5L7.5 5" /><path d="M4 12l1.5 1.5L7.5 11" /><path d="M4 18l1.5 1.5L7.5 17" /></BaseIcon>;
}
export function IconClockHour4(props: IconProps) {
return <BaseIcon {...props}><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></BaseIcon>;
}
export function IconDatabaseImport(props: IconProps) {
return <BaseIcon {...props}><ellipse cx="12" cy="5" rx="7" ry="3" /><path d="M5 5v6c0 1.7 3.1 3 7 3s7-1.3 7-3V5" /><path d="M12 14v8" /><path d="M9 19l3 3 3-3" /></BaseIcon>;
}
export function IconDeviceDesktop(props: IconProps) {
return <BaseIcon {...props}><rect x="3" y="4" width="18" height="12" rx="2" /><path d="M8 20h8" /><path d="M12 16v4" /></BaseIcon>;
}
export function IconHistory(props: IconProps) {
return <BaseIcon {...props}><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v5h5" /><path d="M12 7v5l3 2" /></BaseIcon>;
}
export function IconLanguage(props: IconProps) {
return <BaseIcon {...props}><path d="M4 5h10" /><path d="M9 3c0 6-2 10-5 12" /><path d="M7 13c1.5 2.5 3.5 4.5 6 6" /><path d="M14 10h6" /><path d="M17 7l3 10" /><path d="M14 17h6" /></BaseIcon>;
}
export function IconLayoutDashboard(props: IconProps) {
return <BaseIcon {...props}><rect x="3" y="3" width="8" height="8" rx="1" /><rect x="13" y="3" width="8" height="5" rx="1" /><rect x="13" y="10" width="8" height="11" rx="1" /><rect x="3" y="13" width="8" height="8" rx="1" /></BaseIcon>;
}
export function IconLock(props: IconProps) {
return <BaseIcon {...props}><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V8a4 4 0 0 1 8 0v3" /></BaseIcon>;
}
export function IconLogin2(props: IconProps) {
return <BaseIcon {...props}><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><path d="M10 17l5-5-5-5" /><path d="M15 12H3" /></BaseIcon>;
}
export function IconLogout(props: IconProps) {
return <BaseIcon {...props}><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><path d="M16 17l5-5-5-5" /><path d="M21 12H9" /></BaseIcon>;
}
export function IconMoon(props: IconProps) {
return <BaseIcon {...props}><path d="M12 3a7 7 0 1 0 9 9 9 9 0 1 1-9-9z" /></BaseIcon>;
}
export function IconPlayerPlay(props: IconProps) {
return <BaseIcon {...props}><path d="M8 5v14l11-7z" /></BaseIcon>;
}
export function IconRefresh(props: IconProps) {
return <BaseIcon {...props}><path d="M20 11a8 8 0 0 0-14-5l-2 2" /><path d="M4 3v5h5" /><path d="M4 13a8 8 0 0 0 14 5l2-2" /><path d="M20 21v-5h-5" /></BaseIcon>;
}
export function IconSettings(props: IconProps) {
return <BaseIcon {...props}><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-1.8-.3 1.6 1.6 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.2a1.6 1.6 0 0 0-1-1.5 1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0 .3-1.8 1.6 1.6 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.2a1.6 1.6 0 0 0 1.5-1 1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 1.8.3h0A1.6 1.6 0 0 0 10 3.2V3a2 2 0 1 1 4 0v.2a1.6 1.6 0 0 0 1 1.5h0a1.6 1.6 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8v0a1.6 1.6 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.2a1.6 1.6 0 0 0-1.4 1z" /></BaseIcon>;
}
export function IconSun(props: IconProps) {
return <BaseIcon {...props}><circle cx="12" cy="12" r="4" /><path d="M12 2v2" /><path d="M12 20v2" /><path d="M4.9 4.9l1.4 1.4" /><path d="M17.7 17.7l1.4 1.4" /><path d="M2 12h2" /><path d="M20 12h2" /><path d="M4.9 19.1l1.4-1.4" /><path d="M17.7 6.3l1.4-1.4" /></BaseIcon>;
}
export function IconTemperature(props: IconProps) {
return <BaseIcon {...props}><path d="M14 14.76V5a2 2 0 0 0-4 0v9.76a4 4 0 1 0 4 0z" /></BaseIcon>;
}
export function IconX(props: IconProps) {
return <BaseIcon {...props}><path d="M18 6L6 18" /><path d="M6 6l12 12" /></BaseIcon>;
}
export function IconArrowsMove(props: IconProps) {
return <BaseIcon {...props}><path d="M12 2v20" /><path d="M2 12h20" /><path d="M7 7l5-5 5 5" /><path d="M7 17l5 5 5-5" /></BaseIcon>;
}

View File

@@ -0,0 +1,17 @@
import { formatValue } from "../../lib/format";
import type { MetricValue } from "../../types";
interface ValuePairProps {
metric?: MetricValue;
}
export function ValuePair({ metric }: ValuePairProps) {
return (
<div className="rounded-2xl border border-white/8 bg-slate-950/40 p-3">
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">{metric?.label ?? "--"}</div>
<div className="mt-2 text-lg font-semibold text-white">
{metric ? formatValue(metric.value, metric.unit, metric.precision) : "--"}
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import type { PropsWithChildren, ReactNode } from "react";
interface AppShellProps extends PropsWithChildren {
header: ReactNode;
}
export function AppShell({ header, children }: AppShellProps) {
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(52,211,153,0.14),_transparent_30%),radial-gradient(circle_at_top_right,_rgba(96,165,250,0.12),_transparent_28%),linear-gradient(180deg,_#020617,_#0f172a_55%,_#020617)] text-slate-100">
<div className="mx-auto flex min-h-screen w-full max-w-7xl flex-col px-4 pb-10 pt-4 sm:px-6 lg:px-8">
{header}
<main className="mt-6 flex-1">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import clsx from "clsx";
import { Badge } from "../common/Badge";
import { formatDateTime } from "../../lib/format";
interface TopNavProps {
siteName: string;
connected: boolean;
lastUpdated?: string | null;
activeTab: string;
onTabChange: (tab: "realtime" | "analytics" | "settings") => void;
}
const tabs = [
{ id: "realtime", label: "Na zywo" },
{ id: "analytics", label: "Analityka" },
{ id: "settings", label: "Konfiguracja" },
] as const;
export function TopNav({ siteName, connected, lastUpdated, activeTab, onTabChange }: TopNavProps) {
return (
<header className="sticky top-4 z-20 rounded-[28px] border border-white/10 bg-slate-950/70 p-4 shadow-2xl backdrop-blur">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.28em] text-emerald-300/80">PV Insight</p>
<h1 className="mt-2 text-2xl font-semibold text-white">{siteName}</h1>
<p className="mt-1 text-sm text-slate-400">Panel live + analityka liczona z surowych danych InfluxDB</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Badge tone={connected ? "ok" : "warn"}>{connected ? "Live polling" : "Brak odpowiedzi API"}</Badge>
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-300">
Ostatnia aktualizacja: <span className="text-white">{formatDateTime(lastUpdated)}</span>
</div>
</div>
</div>
<nav className="mt-4 flex flex-wrap gap-2">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onTabChange(tab.id)}
className={clsx(
"rounded-full px-4 py-2 text-sm font-medium transition",
activeTab === tab.id
? "bg-white text-slate-950 shadow-lg"
: "bg-white/5 text-slate-300 hover:bg-white/10 hover:text-white"
)}
>
{tab.label}
</button>
))}
</nav>
</header>
);
}

View File

@@ -0,0 +1,32 @@
import clsx from "clsx";
import { Card } from "../common/Card";
import { formatValue } from "../../lib/format";
import type { HeroCard } from "../../types";
interface HeroKpiGridProps {
cards: HeroCard[];
}
const accents: Record<string, string> = {
emerald: "from-emerald-400/15 to-emerald-500/5 ring-emerald-300/20",
amber: "from-amber-400/15 to-amber-500/5 ring-amber-300/20",
rose: "from-rose-400/15 to-rose-500/5 ring-rose-300/20",
slate: "from-white/10 to-white/5 ring-white/10",
};
export function HeroKpiGrid({ cards }: HeroKpiGridProps) {
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
{cards.map((card) => (
<Card
key={card.metric_id}
className={clsx("overflow-hidden bg-gradient-to-br", accents[card.accent] ?? accents.slate)}
>
<div className="text-xs uppercase tracking-[0.22em] text-slate-400">{card.label}</div>
<div className="mt-3 text-3xl font-semibold text-white">{formatValue(card.value, card.unit, 2)}</div>
<div className="mt-3 text-sm text-slate-400">{card.subtitle}</div>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { Card } from "../common/Card";
import { formatValue } from "../../lib/format";
import type { MetricValue } from "../../types";
interface KpiStripProps {
items: Record<string, MetricValue>;
}
const order = [
"energy_today",
"energy_yesterday",
"today_vs_yesterday",
"dc_power_total",
"energy_total",
];
export function KpiStrip({ items }: KpiStripProps) {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
{order
.filter((metricId) => items[metricId])
.map((metricId) => {
const metric = items[metricId];
return (
<Card key={metric.metric_id} className="bg-slate-950/35">
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">{metric.label}</div>
<div className="mt-3 text-2xl font-semibold text-white">
{formatValue(metric.value, metric.unit, metric.precision)}
</div>
</Card>
);
})}
</div>
);
}

View File

@@ -0,0 +1,64 @@
import type { EChartsOption } from "echarts";
import { Card } from "../common/Card";
import { EChart } from "../common/EChart";
import type { HistoryPayload } from "../../types";
interface LiveHistoryChartProps {
history?: HistoryPayload;
title?: string;
}
export function LiveHistoryChart({ history, title = "Dane chwilowe" }: LiveHistoryChartProps) {
const option: EChartsOption = {
tooltip: {
trigger: "axis",
backgroundColor: "rgba(2, 6, 23, 0.95)",
borderColor: "rgba(255,255,255,0.08)",
textStyle: { color: "#e2e8f0" },
},
legend: {
top: 0,
textStyle: { color: "#cbd5e1" },
},
grid: {
left: 18,
right: 16,
top: 46,
bottom: 24,
containLabel: true,
},
xAxis: {
type: "category",
boundaryGap: false,
axisLabel: { color: "#94a3b8" },
axisLine: { lineStyle: { color: "rgba(255,255,255,0.08)" } },
data: history?.series[0]?.points.map((point) =>
new Date(point.timestamp).toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit" })
) ?? [],
},
yAxis: {
type: "value",
axisLabel: { color: "#94a3b8" },
splitLine: { lineStyle: { color: "rgba(255,255,255,0.06)" } },
},
series:
history?.series.map((item) => ({
name: `${item.label} (${item.unit})`,
type: "line",
smooth: true,
showSymbol: false,
lineStyle: { width: 3 },
areaStyle: { opacity: 0.08 },
data: item.points.map((point) => point.value),
})) ?? [],
};
return (
<Card
title={title}
subtitle="Moc AC, moce stringow DC i opcjonalnie temperatura falownika w jednym widoku live"
>
<EChart option={option} className="h-[340px] w-full" />
</Card>
);
}

View File

@@ -0,0 +1,30 @@
import { Badge } from "../common/Badge";
import { Card } from "../common/Card";
import { formatValue } from "../../lib/format";
import type { MetricValue } from "../../types";
interface LiveStatusBoardProps {
status: MetricValue[];
}
export function LiveStatusBoard({ status }: LiveStatusBoardProps) {
if (!status.length) {
return null;
}
return (
<Card title="Stan systemu" subtitle="Temperatura falownika i kontrola swiezosci odczytu">
<div className="grid gap-3 sm:grid-cols-2">
{status.map((metric) => (
<div key={metric.metric_id} className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-medium text-white">{metric.label}</div>
<Badge tone={metric.status}>{metric.status}</Badge>
</div>
<div className="text-sm text-slate-300">{formatValue(metric.value, metric.unit, metric.precision)}</div>
</div>
))}
</div>
</Card>
);
}

View File

@@ -0,0 +1,26 @@
import { Card } from "../common/Card";
import { ValuePair } from "../common/ValuePair";
import type { SnapshotGroupRow } from "../../types";
interface PhaseGridProps {
rows: SnapshotGroupRow[];
}
export function PhaseGrid({ rows }: PhaseGridProps) {
return (
<Card title="Fazy AC" subtitle="Napiece, prady i moce pozorne na falowniku">
<div className="grid gap-4 md:grid-cols-3">
{rows.map((row) => (
<div key={row.id} className="rounded-3xl border border-white/10 bg-slate-950/40 p-4">
<div className="mb-4 text-sm font-semibold text-white">{row.label}</div>
<div className="grid gap-3">
<ValuePair metric={row.values.voltage} />
<ValuePair metric={row.values.current} />
<ValuePair metric={row.values.apparent_power} />
</div>
</div>
))}
</div>
</Card>
);
}

View File

@@ -0,0 +1,34 @@
import { Card } from "../common/Card";
import { ValuePair } from "../common/ValuePair";
import type { SnapshotGroupRow } from "../../types";
interface StringGridProps {
rows: SnapshotGroupRow[];
}
const slotOrder = ["power", "voltage"] as const;
export function StringGrid({ rows }: StringGridProps) {
return (
<Card title="Stringi DC" subtitle="Widok automatycznie skaluje sie do liczby stringow i dostepnych metryk z config.py">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{rows.map((row) => {
const visibleSlots = slotOrder.filter((slot) => row.values[slot]);
return (
<div key={row.id} className="rounded-3xl border border-white/10 bg-slate-950/40 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="text-sm font-semibold text-white">{row.label}</div>
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">{row.id}</div>
</div>
<div className={`grid gap-3 ${visibleSlots.length > 1 ? "sm:grid-cols-2" : "sm:grid-cols-1"}`}>
{visibleSlots.map((slot) => (
<ValuePair key={slot} metric={row.values[slot]} />
))}
</div>
</div>
);
})}
</div>
</Card>
);
}

View File

@@ -0,0 +1,82 @@
import { Card } from "../common/Card";
import type { DashboardConfig } from "../../types";
import { HistoricalImportPanel } from "./HistoricalImportPanel";
interface ConfigPanelProps {
config: DashboardConfig;
}
export function ConfigPanel({ config }: ConfigPanelProps) {
return (
<div className="grid gap-6 xl:grid-cols-[1.1fr_1fr]">
<Card title="Architektura i UX" subtitle="Co zostalo przygotowane w tej wersji">
<div className="grid gap-4 text-sm text-slate-300">
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<div className="font-medium text-white">Backend</div>
<p className="mt-2">Flask + modularne serwisy, bez uvicorna i bez pydantic-core. Odczyt z InfluxDB idzie po HTTP API, a agregaty historyczne trafiaja do lokalnego cache SQLite.</p>
</div>
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<div className="font-medium text-white">Frontend</div>
<p className="mt-2">React + TypeScript + Vite + Tailwind, responsywne karty, live charts i widok mobilny bez osobnej wersji aplikacji.</p>
</div>
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<div className="font-medium text-white">Logika danych</div>
<p className="mt-2">Produkcja dzienna, tygodniowa, miesieczna i roczna jest liczona z surowych danych Influxa na podstawie licznika energii calkowitej, a gdy go brak z mocy AC. Pelne dni sa cache'owane lokalnie.</p>
</div>
</div>
</Card>
<Card title="Konfiguracja deploymentu" subtitle="Najwazniejsze ustawienia od razu widoczne">
<dl className="grid gap-4 text-sm">
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<dt className="text-slate-500">Site name</dt>
<dd className="mt-2 text-white">{config.app.site_name}</dd>
</div>
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<dt className="text-slate-500">Installed power</dt>
<dd className="mt-2 text-white">{config.app.installed_power_kwp} kWp</dd>
</div>
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<dt className="text-slate-500">Timezone</dt>
<dd className="mt-2 text-white">{config.app.timezone}</dd>
</div>
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<dt className="text-slate-500">Moduly live / analytics / history</dt>
<dd className="mt-2 text-white">
live: {String(config.capabilities.realtime_enabled)} / analytics: {String(config.capabilities.analytics_enabled)} / history: {String(config.capabilities.historical_import_enabled)}
</dd>
</div>
</dl>
</Card>
{config.capabilities.historical_import_enabled ? <HistoricalImportPanel config={config} /> : null}
<Card title="Mapowanie encji" subtitle="Tabela pomocnicza do dalszego doprecyzowania integracji" className="xl:col-span-2">
<div className="overflow-auto">
<table className="min-w-full text-left text-sm text-slate-300">
<thead>
<tr className="border-b border-white/10 text-slate-500">
<th className="px-3 py-3 font-medium">Metric</th>
<th className="px-3 py-3 font-medium">Label</th>
<th className="px-3 py-3 font-medium">Entity ID</th>
<th className="px-3 py-3 font-medium">Measurement</th>
<th className="px-3 py-3 font-medium">Unit</th>
</tr>
</thead>
<tbody>
{config.visible_entities.map((item) => (
<tr key={item.metric_id} className="border-b border-white/6 last:border-none">
<td className="px-3 py-3 text-white">{item.metric_id}</td>
<td className="px-3 py-3">{item.label}</td>
<td className="px-3 py-3 font-mono text-xs text-emerald-200">{item.entity_id}</td>
<td className="px-3 py-3">{item.measurement}</td>
<td className="px-3 py-3">{item.unit}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { useEffect, useMemo, useState } from "react";
import { useHistoricalImport } from "../../hooks";
import type { DashboardConfig, HistoricalActivityEvent, HistoricalChunkProgress } from "../../types";
import { Badge } from "../common/Badge";
import { Card } from "../common/Card";
import { formatDate, formatDateTime, formatDurationShort, formatPercent, formatValue } from "../../lib/format";
interface HistoricalImportPanelProps { config: DashboardConfig; }
function eventTone(level?: string): "ok" | "warn" | "critical" | "neutral" { if (level === "success") return "ok"; if (level === "warn") return "warn"; if (level === "error") return "critical"; return "neutral"; }
function chunkTone(state?: string): "ok" | "warn" | "critical" | "neutral" { if (state === "completed") return "ok"; if (state === "running") return "warn"; if (state === "failed") return "critical"; return "neutral"; }
function StatCard({ label, value, helper }: { label: string; value: string; helper: string }) {
return <div className="rounded-2xl border border-white/10 bg-slate-950/35 p-4"><div className="text-xs uppercase tracking-[0.2em] text-slate-500">{label}</div><div className="mt-3 text-2xl font-semibold text-white">{value}</div><div className="mt-2 text-xs text-slate-500">{helper}</div></div>;
}
function ChunkRow({ chunk, activeChunkIndex }: { chunk: HistoricalChunkProgress; activeChunkIndex: number }) {
const isActive = chunk.chunk_index === activeChunkIndex || chunk.state === "running";
return (
<div className="rounded-2xl border border-white/10 bg-slate-950/35 p-4">
<div className="flex flex-wrap items-center justify-between gap-3"><div><div className="text-sm font-medium text-white">Chunk {chunk.chunk_index}/{chunk.total_chunks}</div><div className="mt-1 text-xs text-slate-500">{formatDate(chunk.start_date)} - {formatDate(chunk.end_date)}</div></div><div className="flex items-center gap-2">{isActive ? <Badge tone="warn">aktywny</Badge> : null}<Badge tone={chunkTone(chunk.state)}>{chunk.state}</Badge></div></div>
<div className="mt-4 grid gap-3 text-sm sm:grid-cols-4"><div><div className="text-slate-500">Przetworzone</div><div className="mt-1 text-white">{chunk.processed_days}</div></div><div><div className="text-slate-500">Import</div><div className="mt-1 text-white">{chunk.imported_days}</div></div><div><div className="text-slate-500">Pominiete</div><div className="mt-1 text-white">{chunk.skipped_days}</div></div><div><div className="text-slate-500">Energia</div><div className="mt-1 text-white">{formatValue(chunk.energy_kwh, "kWh", 2)}</div></div></div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 text-xs text-slate-500"><span>{chunk.note}</span><span>{chunk.duration_seconds ? `czas ${formatDurationShort(chunk.duration_seconds)}` : "w toku"}</span></div>
</div>
);
}
function EventRow({ event }: { event: HistoricalActivityEvent }) {
return (
<div className="relative pl-5"><div className="absolute left-0 top-2 h-2.5 w-2.5 rounded-full bg-white/30" /><div className="rounded-2xl border border-white/10 bg-slate-950/35 p-4"><div className="flex flex-wrap items-center justify-between gap-3"><div className="flex items-center gap-2"><Badge tone={eventTone(event.level)}>{event.level}</Badge><div className="text-sm font-medium text-white">{event.title}</div></div><div className="text-xs text-slate-500">{formatDateTime(event.timestamp)}</div></div><div className="mt-2 text-sm text-slate-300">{event.message}</div><div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-500">{event.day ? <span>Dzien: {formatDate(event.day)}</span> : null}{event.chunk_index ? <span>Chunk: #{event.chunk_index}</span> : null}</div></div></div>
);
}
export function HistoricalImportPanel({ config }: HistoricalImportPanelProps) {
const { status, start, syncNow, cancel } = useHistoricalImport();
const payload = status.data;
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [chunkDays, setChunkDays] = useState(String(config.capabilities.history.default_chunk_days || 7));
const [force, setForce] = useState(false);
useEffect(() => { setChunkDays(String(config.capabilities.history.default_chunk_days || 7)); }, [config.capabilities.history.default_chunk_days]);
const progress = useMemo(() => { if (!payload || payload.total_days <= 0) return 0; return Math.min(100, Math.round((payload.processed_days / payload.total_days) * 100)); }, [payload]);
const visibleChunks = useMemo(() => [...(payload?.recent_chunks ?? [])].sort((l, r) => r.chunk_index - l.chunk_index), [payload?.recent_chunks]);
const visibleEvents = useMemo(() => [...(payload?.recent_events ?? [])].sort((l, r) => new Date(r.timestamp).getTime() - new Date(l.timestamp).getTime()), [payload?.recent_events]);
const busy = start.isPending || syncNow.isPending || cancel.isPending;
const mutationError = start.error?.message || syncNow.error?.message || cancel.error?.message || null;
const availableRangeReady = Boolean(payload?.available_start_date && payload?.available_end_date);
return (
<Card title="Import archiwalny z InfluxDB" subtitle="Mechanizm backfillu dzien po dniu z lokalnym cache SQLite, kontrola chunkow, ETA i lista ostatnich operacji" className="xl:col-span-2">
<div className="grid gap-6 2xl:grid-cols-[1.15fr_0.85fr]"><div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard label="Postep" value={`${progress}%`} helper={`${payload?.processed_days ?? 0} / ${payload?.total_days ?? 0} dni`} />
<StatCard label="Przepustowosc" value={formatValue(payload?.avg_days_per_minute ?? null, "dni/min", 1)} helper="Srednia szybkosc importu" />
<StatCard label="ETA" value={formatDurationShort(payload?.estimated_remaining_seconds)} helper="Szacowany czas do konca" />
<StatCard label="Pokrycie" value={formatPercent(payload?.coverage?.coverage_pct ?? null)} helper={`${payload?.coverage?.missing_days ?? 0} brakujacych dni`} />
</div>
<div className="grid gap-4 lg:grid-cols-[0.9fr_1.1fr]"><div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2"><label className="block rounded-2xl border border-white/10 bg-slate-950/30 p-4 text-sm text-slate-300"><span className="mb-2 block text-slate-500">Data od</span><input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950 px-3 py-2 text-white outline-none" /></label><label className="block rounded-2xl border border-white/10 bg-slate-950/30 p-4 text-sm text-slate-300"><span className="mb-2 block text-slate-500">Data do</span><input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950 px-3 py-2 text-white outline-none" /></label></div>
<div className="grid gap-4 md:grid-cols-[220px_1fr]"><label className="block rounded-2xl border border-white/10 bg-slate-950/30 p-4 text-sm text-slate-300"><span className="mb-2 block text-slate-500">Chunk (dni)</span><input type="number" min={1} max={31} value={chunkDays} onChange={(e) => setChunkDays(e.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950 px-3 py-2 text-white outline-none" /></label><div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4 text-sm text-slate-300"><label className="flex items-center gap-3"><input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} /><span>Nadpisz dni juz obecne w cache historycznym</span></label><p className="mt-3 text-slate-500">Gdy pola dat sa puste, backend sam wykryje zakres do importu na podstawie pierwszej probki w InfluxDB i ostatniego dnia juz zapisanego w SQLite.</p></div></div>
<div className="flex flex-wrap gap-3"><button type="button" disabled={busy} onClick={() => start.mutate({ start_date: startDate || undefined, end_date: endDate || undefined, chunk_days: Number(chunkDays || 7), force })} className="rounded-full bg-white px-5 py-2.5 text-sm font-medium text-slate-950 transition hover:bg-slate-200 disabled:cursor-not-allowed disabled:opacity-50">Start importu</button><button type="button" disabled={busy} onClick={() => syncNow.mutate()} className="rounded-full bg-emerald-500/20 px-5 py-2.5 text-sm font-medium text-emerald-200 transition hover:bg-emerald-500/30 disabled:cursor-not-allowed disabled:opacity-50">Synchronizuj brakujace dni</button><button type="button" disabled={!payload?.running || busy} onClick={() => cancel.mutate()} className="rounded-full bg-rose-500/20 px-5 py-2.5 text-sm font-medium text-rose-200 transition hover:bg-rose-500/30 disabled:cursor-not-allowed disabled:opacity-50">Anuluj</button>{availableRangeReady ? <button type="button" disabled={busy} onClick={() => { setStartDate(payload?.available_start_date ?? ""); setEndDate(payload?.available_end_date ?? ""); }} className="rounded-full bg-sky-500/15 px-5 py-2.5 text-sm font-medium text-sky-200 transition hover:bg-sky-500/25 disabled:cursor-not-allowed disabled:opacity-50">Ustaw pelna historie</button> : null}</div>
{mutationError ? <div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 p-4 text-sm text-rose-200">{mutationError}</div> : null}
{status.error ? <div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 p-4 text-sm text-rose-200">{status.error.message}</div> : null}
</div>
<div className="rounded-3xl border border-white/10 bg-slate-950/35 p-5"><div className="flex flex-wrap items-start justify-between gap-3"><div><div className="text-sm font-medium text-white">Operacyjny status zadania</div><div className="mt-1 text-xs text-slate-500">{payload?.message || "Brak aktywnego zadania"}</div></div><div className="flex items-center gap-2">{payload?.job_id ? <Badge tone="neutral">job {payload.job_id}</Badge> : null}<Badge tone={payload?.running ? "warn" : payload?.state === "failed" ? "critical" : "neutral"}>{payload?.running ? "w trakcie" : payload?.state || "idle"}</Badge></div></div><div className="mt-5 h-3 overflow-hidden rounded-full bg-white/8"><div className="h-full rounded-full bg-gradient-to-r from-emerald-300 via-sky-400 to-cyan-400 transition-all" style={{ width: `${progress}%` }} /></div><div className="mt-2 flex items-center justify-between text-xs text-slate-500"><span>Chunk {payload?.active_chunk_index ?? 0} / {payload?.total_chunks ?? 0}</span><span>{progress}%</span></div>
<div className="mt-5 grid gap-4 text-sm text-slate-300 md:grid-cols-2"><div className="rounded-2xl border border-white/10 bg-slate-900/70 p-4"><div className="text-slate-500">Dostepny zakres w InfluxDB</div><div className="mt-2 text-white">{formatDate(payload?.available_start_date)} - {formatDate(payload?.available_end_date)}</div><div className="mt-2 text-xs text-slate-500">{payload?.coverage?.available_days ?? 0} dni wykrytego archiwum</div></div><div className="rounded-2xl border border-white/10 bg-slate-900/70 p-4"><div className="text-slate-500">Zakres zapisany lokalnie</div><div className="mt-2 text-white">{formatDate(payload?.coverage?.first_day)} - {formatDate(payload?.coverage?.last_day)}</div><div className="mt-2 text-xs text-slate-500">{payload?.coverage?.imported_days ?? 0} dni w cache, {formatValue(payload?.coverage?.total_energy_kwh ?? 0, "kWh", 1)}</div></div><div className="rounded-2xl border border-white/10 bg-slate-900/70 p-4"><div className="text-slate-500">Aktualny chunk</div><div className="mt-2 text-white">{formatDate(payload?.current_chunk_start)} - {formatDate(payload?.current_chunk_end)}</div><div className="mt-2 text-xs text-slate-500">Ostatni dzien: {formatDate(payload?.current_date)}</div></div><div className="rounded-2xl border border-white/10 bg-slate-900/70 p-4"><div className="text-slate-500">Czasy i opoznienia</div><div className="mt-2 text-white">elapsed {formatDurationShort(payload?.elapsed_seconds)}</div><div className="mt-2 text-xs text-slate-500">start {formatDateTime(payload?.started_at)} / koniec {formatDateTime(payload?.finished_at)}</div>{payload?.last_error ? <div className="mt-3 text-xs text-rose-200">Blad: {payload.last_error}</div> : null}</div></div></div></div>
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]"><Card title="Lista chunkow" subtitle="Najswiezsze zakresy z liczba zaimportowanych dni i energia na chunk"><div className="space-y-3">{visibleChunks.length ? visibleChunks.map((chunk) => <ChunkRow key={chunk.chunk_index} chunk={chunk} activeChunkIndex={payload?.active_chunk_index ?? 0} />) : <div className="rounded-2xl border border-dashed border-white/10 bg-slate-950/20 p-6 text-sm text-slate-400">Lista chunkow pojawi sie po uruchomieniu pierwszego backfillu.</div>}</div></Card><Card title="Ostatnie operacje" subtitle="Operator widzi ostatnie dni, chunki i ewentualne ostrzezenia bez wchodzenia do logow backendu"><div className="space-y-3 border-l border-white/10 pl-4">{visibleEvents.length ? visibleEvents.map((event, index) => <EventRow key={`${event.timestamp}-${index}`} event={event} />) : <div className="rounded-2xl border border-dashed border-white/10 bg-slate-950/20 p-6 text-sm text-slate-400">Historia operacji pojawi sie po starcie zadania.</div>}</div></Card></div>
</div></div>
</Card>
);
}

View File

@@ -0,0 +1,24 @@
import { Card } from "../common/Card";
interface FaultBannerProps {
faults: string[];
}
export function FaultBanner({ faults }: FaultBannerProps) {
if (!faults.length) {
return null;
}
return (
<Card className="border-rose-400/30 bg-rose-500/10">
<div className="flex flex-col gap-2">
<div className="text-xs uppercase tracking-[0.22em] text-rose-200">Alarm falownika</div>
{faults.map((fault) => (
<div key={fault} className="text-sm text-rose-100">
{fault}
</div>
))}
</div>
</Card>
);
}

187
frontend/src/demo/data.ts Normal file
View File

@@ -0,0 +1,187 @@
import type {
AnalyticsPayload,
DashboardConfig,
DistributionPayload,
HistoryPayload,
HistoricalStatus,
SnapshotPayload,
} from "../types";
function isoAt(offsetMinutes: number): string {
return new Date(Date.now() + offsetMinutes * 60 * 1000).toISOString();
}
export const demoConfig: DashboardConfig = {
app: {
name: "pv-insight",
version: "1.3.0",
site_name: "PV Insight / Sofar Demo",
timezone: "Europe/Warsaw",
installed_power_kwp: 9.86,
},
defaults: {
realtime_range: "6h",
analytics_range: "30d",
analytics_bucket: "day",
tab: "realtime",
theme: "dark",
language: "pl",
},
auth: { enabled: false },
i18n: { default_language: "pl", supported_languages: ["pl", "en"] },
capabilities: {
modules: { realtime: true, analytics: true, history: true },
strings_enabled: true,
strings_count: 2,
phases_enabled: false,
phases_count: 0,
analytics_enabled: true,
realtime_enabled: true,
comparison_modes: ["none", "previous_period", "previous_year"],
ranges: [
{ key: "6h", label: "6h" },
{ key: "24h", label: "24h" },
{ key: "1d", label: "1 dzień" },
{ key: "3d", label: "3 dni" },
{ key: "7d", label: "7 dni" },
{ key: "14d", label: "14 dni" },
{ key: "30d", label: "30 dni" },
{ key: "60d", label: "60 dni" },
{ key: "365d", label: "365 dni" },
{ key: "ytd", label: "YTD" },
],
buckets: [
{ key: "day", label: "Dzien" },
{ key: "week", label: "Tydzien" },
{ key: "month", label: "Miesiac" },
{ key: "year", label: "Rok" },
],
historical_import_enabled: true,
history: { enabled: true, default_chunk_days: 7, auto_sync_enabled: true, auto_sync_interval_minutes: 30 },
},
visible_entities: [
{ metric_id: "ac_power", label: "Moc AC calkowita", entity_id: "sofarsolar_ac_power", measurement: "W", unit: "W", kind: "gauge" },
{ metric_id: "energy_total", label: "Energia total", entity_id: "sofarsolar_energy_total", measurement: "kWh", unit: "kWh", kind: "counter" },
{ metric_id: "string_1_power", label: "Moc stringu DC1", entity_id: "sofarsolar_dc1_power", measurement: "W", unit: "W", kind: "gauge" },
{ metric_id: "string_1_voltage", label: "Napiecie stringu DC1", entity_id: "sofarsolar_dc1_voltage", measurement: "V", unit: "V", kind: "gauge" },
{ metric_id: "string_2_power", label: "Moc stringu DC2", entity_id: "sofarsolar_dc2_power", measurement: "W", unit: "W", kind: "gauge" },
{ metric_id: "string_2_voltage", label: "Napiecie stringu DC2", entity_id: "sofarsolar_dc2_voltage", measurement: "V", unit: "V", kind: "gauge" },
{ metric_id: "inverter_temperature", label: "Temperatura falownika", entity_id: "sofarsolar_temprature_inverter", measurement: "°C", unit: "°C", kind: "gauge" },
],
};
export const demoSnapshot = (): SnapshotPayload => ({
updated_at: new Date().toISOString(),
hero_cards: [
{ metric_id: "ac_power", label: "Produkcja AC", value: 6840, unit: "W", accent: "emerald", subtitle: "Aktualna moc oddawana przez falownik" },
{ metric_id: "energy_today", label: "Dzisiaj", value: 31.8, unit: "kWh", accent: "amber", subtitle: "Liczone z energy_total / fallback z AC power" },
{ metric_id: "dc1_power", label: "String DC1", value: 3450, unit: "W", accent: "emerald", subtitle: "Wschod" },
{ metric_id: "dc2_power", label: "String DC2", value: 3310, unit: "W", accent: "emerald", subtitle: "Zachod" },
{ metric_id: "inverter_temperature", label: "Temp. falownika", value: 47.3, unit: "°C", accent: "rose", subtitle: "Live status termiczny" },
],
kpis: {
energy_today: { metric_id: "energy_today", label: "Energia dzis", unit: "kWh", value: 31.8, precision: 2, kind: "counter", status: "ok" },
energy_yesterday: { metric_id: "energy_yesterday", label: "Energia wczoraj", unit: "kWh", value: 28.4, precision: 2, kind: "counter", status: "ok" },
today_vs_yesterday: { metric_id: "today_vs_yesterday", label: "Dzis vs wczoraj", unit: "%", value: 12, precision: 0, kind: "gauge", status: "ok" },
dc_power_total: { metric_id: "dc_power_total", label: "Moc DC total", unit: "W", value: 6760, precision: 0, kind: "gauge", status: "ok" },
energy_total: { metric_id: "energy_total", label: "Energia total", unit: "kWh", value: 18264.3, precision: 1, kind: "counter", status: "ok" },
},
strings: [
{ id: "dc1", label: "String 1 / Wschod", meta: {}, values: { power: { metric_id: "string_1_power", label: "Moc", unit: "W", value: 3450, precision: 0, kind: "gauge", status: "ok" }, voltage: { metric_id: "string_1_voltage", label: "Napiecie", unit: "V", value: 382, precision: 0, kind: "gauge", status: "ok" } } },
{ id: "dc2", label: "String 2 / Zachod", meta: {}, values: { power: { metric_id: "string_2_power", label: "Moc", unit: "W", value: 3310, precision: 0, kind: "gauge", status: "ok" }, voltage: { metric_id: "string_2_voltage", label: "Napiecie", unit: "V", value: 375, precision: 0, kind: "gauge", status: "ok" } } },
],
phases: [],
status: [
{ metric_id: "inverter_temperature", label: "Temperatura falownika", unit: "°C", value: 47.3, precision: 1, kind: "gauge", status: "ok" },
{ metric_id: "data_freshness", label: "Swiezosc danych", unit: "", value: "3 s temu", precision: 0, kind: "text", status: "ok" },
],
faults: [],
});
function historyPoints(values: number[]) {
return values.map((value, index) => ({ timestamp: isoAt(-(values.length - index) * 5), value }));
}
export const demoHistory: HistoryPayload = {
range_key: "6h",
start: isoAt(-360),
end: isoAt(0),
series: [
{ metric_id: "ac_power", label: "Moc AC", unit: "W", points: historyPoints([0, 120, 860, 1840, 2760, 3920, 5180, 6020, 6840, 6500, 5710, 4980]) },
{ metric_id: "dc1_power", label: "DC1", unit: "W", points: historyPoints([0, 80, 620, 1320, 2140, 2860, 3250, 3490, 3450, 3300, 2920, 2480]) },
{ metric_id: "dc2_power", label: "DC2", unit: "W", points: historyPoints([0, 40, 240, 520, 880, 1260, 1930, 2530, 3310, 3200, 2790, 2410]) },
{ metric_id: "inverter_temperature", label: "Temp. falownika", unit: "°C", points: historyPoints([22, 24, 27, 31, 35, 39, 42, 45, 47.3, 46.8, 44.1, 41.2]) },
],
};
const currentBars = [18.3, 22.1, 25.7, 21.9, 23.2, 27.6, 30.1, 28.4, 19.8, 16.2, 24.4, 31.8].map((value, index) => ({ label: `${index + 1} mar`, start: isoAt(-(12 - index) * 1440), end: isoAt(-(11 - index) * 1440), value }));
const comparisonBars = [16.1, 19.2, 22.4, 19.5, 20.2, 23.4, 26.8, 25.1, 17.9, 15.4, 21.6, 27.3].map((value, index) => ({ label: `${index + 1} mar`, start: isoAt(-(377 - index) * 1440), end: isoAt(-(376 - index) * 1440), value }));
export const demoAnalytics: AnalyticsPayload = {
unit: "kWh",
bucket: "day",
compare_mode: "previous_year",
current: currentBars,
comparison: comparisonBars,
summary: { total: 289.5, unit: "kWh", average_bucket: 24.1, best_bucket_label: "12 mar", best_bucket_value: 31.8, co2_saved_kg: 230.1, comparison_total: 255, comparison_delta_pct: 13.5 },
meta: { window: { start: isoAt(-30 * 1440), end: isoAt(0), range_key: "30d" }, source: "sqlite+influx" },
};
export const demoDistribution: DistributionPayload = {
unit: "kWh",
bucket: "day",
total: 289.5,
slices: [ { label: "DC1 / Wschod", value: 154.2, share: 53.3 }, { label: "DC2 / Zachod", value: 135.3, share: 46.7 } ],
};
export const demoHistoricalStatus: HistoricalStatus = {
enabled: true,
running: true,
state: "running",
job_id: "hist-9f31ab",
started_at: isoAt(-18),
finished_at: null,
requested_start_date: "2022-01-01",
requested_end_date: "2025-12-31",
total_days: 1461,
processed_days: 1096,
imported_days: 1028,
skipped_days: 68,
chunk_days: 7,
total_chunks: 209,
active_chunk_index: 157,
current_date: "2025-01-08",
current_chunk_start: "2025-01-05",
current_chunk_end: "2025-01-11",
elapsed_seconds: 1080,
estimated_remaining_seconds: 360,
avg_days_per_minute: 60.89,
last_error: null,
message: "Przetwarzanie zakresu 2025-01-05 -> 2025-01-11",
coverage: { imported_days: 1028, first_day: "2022-01-01", last_day: "2025-01-04", total_energy_kwh: 18264.3, available_days: 1461, missing_days: 433, coverage_pct: 70.4 },
available_start_date: "2022-01-01",
available_end_date: "2025-12-31",
default_chunk_days: 7,
recent_chunks: [
{ chunk_index: 154, total_chunks: 209, start_date: "2024-12-15", end_date: "2024-12-21", processed_days: 7, imported_days: 7, skipped_days: 0, energy_kwh: 96.3, state: "completed", started_at: isoAt(-9.8), finished_at: isoAt(-9.1), duration_seconds: 42, note: "Chunk zakonczony: import 7, pominiete 0" },
{ chunk_index: 155, total_chunks: 209, start_date: "2024-12-22", end_date: "2024-12-28", processed_days: 7, imported_days: 7, skipped_days: 0, energy_kwh: 88.7, state: "completed", started_at: isoAt(-9.0), finished_at: isoAt(-8.2), duration_seconds: 46, note: "Chunk zakonczony: import 7, pominiete 0" },
{ chunk_index: 156, total_chunks: 209, start_date: "2024-12-29", end_date: "2025-01-04", processed_days: 7, imported_days: 7, skipped_days: 0, energy_kwh: 92.6, state: "completed", started_at: isoAt(-8.1), finished_at: isoAt(-7.4), duration_seconds: 44, note: "Chunk zakonczony: import 7, pominiete 0" },
{ chunk_index: 157, total_chunks: 209, start_date: "2025-01-05", end_date: "2025-01-11", processed_days: 4, imported_days: 4, skipped_days: 0, energy_kwh: 51.4, state: "running", started_at: isoAt(-6.5), finished_at: null, duration_seconds: null, note: "Aktywny chunk 2025-01-05 -> 2025-01-11" },
],
recent_events: [
{ timestamp: isoAt(-1.1), level: "success", title: "Zaimportowano dzien", message: "Zaimportowano 2025-01-08 (13.10 kWh). Energia: 13.10 kWh.", day: "2025-01-08", chunk_index: 157 },
{ timestamp: isoAt(-1.9), level: "success", title: "Zaimportowano dzien", message: "Zaimportowano 2025-01-07 (12.84 kWh). Energia: 12.84 kWh.", day: "2025-01-07", chunk_index: 157 },
{ timestamp: isoAt(-2.5), level: "success", title: "Zaimportowano dzien", message: "Zaimportowano 2025-01-06 (12.56 kWh). Energia: 12.56 kWh.", day: "2025-01-06", chunk_index: 157 },
{ timestamp: isoAt(-3.2), level: "success", title: "Zaimportowano dzien", message: "Zaimportowano 2025-01-05 (12.90 kWh). Energia: 12.90 kWh.", day: "2025-01-05", chunk_index: 157 },
{ timestamp: isoAt(-3.7), level: "info", title: "Chunk 157/209", message: "Start zakresu 2025-01-05 -> 2025-01-11", day: null, chunk_index: 157 },
{ timestamp: isoAt(-6.8), level: "success", title: "Chunk 156/209 zakonczony", message: "Zakres 2024-12-29 -> 2025-01-04, import 7, pominiete 0, energia 92.60 kWh", day: null, chunk_index: 156 },
],
};
export const demoAuthStatus = {
enabled: false,
authenticated: true,
user: "demo",
display_name: "Demo Operator",
};

View File

@@ -0,0 +1,5 @@
export * from "./useAnalytics";
export * from "./useDashboardConfig";
export * from "./useHistoricalImport";
export * from "./useRealtimeHistory";
export * from "./useRealtimeSocket";

View File

@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../api/client";
export function useAnalytics(
rangeKey: string,
bucket: string,
compare: string,
enabled = true,
options?: { start?: string; end?: string; publicKiosk?: boolean; compareRanges?: Array<{ start: string; end: string; label: string; key?: string }> },
) {
const production = useQuery({
queryKey: ["analytics", rangeKey, bucket, compare, options?.start, options?.end, options?.publicKiosk, JSON.stringify(options?.compareRanges ?? [])],
queryFn: () => api.getAnalytics(rangeKey, bucket, compare, options),
staleTime: 60 * 1000,
enabled,
});
const distribution = useQuery({
queryKey: ["distribution", rangeKey, bucket, options?.start, options?.end, options?.publicKiosk],
queryFn: () => api.getDistribution(rangeKey, bucket, options),
staleTime: 60 * 1000,
enabled,
});
return { production, distribution };
}

View File

@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../api/client";
export function useDashboardConfig(enabled = true) {
return useQuery({
queryKey: ["dashboard-config"],
queryFn: api.getConfig,
staleTime: 5 * 60 * 1000,
enabled,
});
}

View File

@@ -0,0 +1,40 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../api/client";
import type { HistoricalStartPayload } from "../types";
export function useHistoricalImport(enabled = true) {
const queryClient = useQueryClient();
const status = useQuery({
queryKey: ["historical-status"],
queryFn: api.getHistoricalStatus,
staleTime: 5 * 1000,
enabled,
refetchInterval: (query) => (query.state.data?.running ? 3000 : 15000),
});
const start = useMutation({
mutationFn: (payload: HistoricalStartPayload) => api.startHistoricalImport(payload),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["historical-status"] });
await queryClient.invalidateQueries({ queryKey: ["analytics"] });
await queryClient.invalidateQueries({ queryKey: ["distribution"] });
},
});
const syncNow = useMutation({
mutationFn: api.syncHistoricalNow,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["historical-status"] });
},
});
const cancel = useMutation({
mutationFn: api.cancelHistoricalImport,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["historical-status"] });
},
});
return { status, start, syncNow, cancel };
}

View File

@@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../api/client";
export function useRealtimeHistory(
rangeKey: string,
enabled = true,
options?: { start?: string; end?: string; metrics?: string[]; publicKiosk?: boolean },
) {
return useQuery({
queryKey: ["realtime-history", rangeKey, options?.start, options?.end, options?.metrics?.join(","), options?.publicKiosk],
queryFn: () => api.getRealtimeHistory(rangeKey, options),
staleTime: 20 * 1000,
refetchInterval: options?.start || options?.end ? false : 30 * 1000,
enabled,
});
}

View File

@@ -0,0 +1,66 @@
import { useEffect, useMemo, useState } from "react";
import { api } from "../api/client";
import type { SnapshotPayload } from "../types";
const EMPTY_SNAPSHOT: SnapshotPayload = {
hero_cards: [],
kpis: {},
strings: [],
phases: [],
status: [],
faults: [],
};
const POLL_MS = Number(import.meta.env.VITE_LIVE_POLL_MS ?? 8000);
export function useRealtimeSocket(enabled = true) {
const [snapshot, setSnapshot] = useState<SnapshotPayload>(EMPTY_SNAPSHOT);
const [connected, setConnected] = useState(false);
useEffect(() => {
if (!enabled) {
setSnapshot(EMPTY_SNAPSHOT);
setConnected(false);
return;
}
let isActive = true;
let timer: number | null = null;
const poll = async () => {
try {
const data = await api.getRealtimeSnapshot();
if (!isActive) {
return;
}
setSnapshot(data);
setConnected(true);
} catch {
if (isActive) {
setConnected(false);
}
} finally {
if (isActive) {
timer = window.setTimeout(poll, POLL_MS);
}
}
};
void poll();
return () => {
isActive = false;
if (timer !== null) {
window.clearTimeout(timer);
}
};
}, [enabled]);
const lastUpdated = useMemo(() => snapshot.updated_at ?? null, [snapshot.updated_at]);
return {
snapshot,
connected,
lastUpdated,
};
}

270
frontend/src/i18n.ts Normal file
View File

@@ -0,0 +1,270 @@
export type Language = "pl" | "en";
const messages = {
pl: {
live: "Live",
analytics: "Analityka",
settings: "Ustawienia",
kiosk: "Kiosk",
operatorPanel: "Panel operatora",
poweredBy: "Tabler UI",
connected: "Połączono",
disconnected: "Brak połączenia",
updatedAt: "Aktualizacja",
loginTitle: "Logowanie",
loginSubtitle: "Minitoring fotowoltaiki",
username: "Login",
password: "Hasło",
signIn: "Zaloguj",
signOut: "Wyloguj",
theme: "Motyw",
light: "Jasny",
dark: "Ciemny",
language: "Język",
polish: "Polski",
english: "English",
openKiosk: "Otwórz kiosk",
exitKiosk: "Wyjdź z kiosku",
fullscreen: "Pełny ekran",
demoMode: "Tryb demo",
realtimeOverview: "Przegląd na żywo",
realtimeSubtitle: "Najważniejsze parametry falownika i stringów",
analyticsOverview: "Produkcja długoterminowa",
analyticsSubtitle: "Dzień / tydzień / miesiąc / rok i porównania okresów",
settingsSubtitle: "Import archiwum, wygląd, kiosk, bezpieczeństwo",
noData: "Brak danych",
noDataDescription: "Brak odpowiedzi z backendu lub InfluxDB.",
chartPowerHistory: "Historia mocy i temperatury",
chartProduction: "Produkcja",
chartProductionSubtitle: "Agregacja w wybranym bucketcie",
chartComparison: "Porównanie okresów",
chartDistribution: "Rozkład produkcji",
currentPeriod: "Bieżący okres",
comparisonPeriod: "Porównanie",
summaryTotal: "Suma",
summaryAverage: "Średnia / bucket",
summaryBest: "Najlepszy bucket",
summaryCo2: "Oszczędzone CO₂",
compareNone: "Bez porównania",
comparePreviousPeriod: "Poprzedni okres",
comparePreviousYear: "Poprzedni rok",
comparePreviousYear2: "2 lata wstecz",
comparePreviousYear3: "3 lata wstecz",
comparePreviousMonth12: "12 miesięcy wstecz",
comparePreviousMonth24: "24 miesiące wstecz",
compareCustomMulti: "Własne zakresy",
range: "Zakres",
bucket: "Bucket",
liveRange: "Zakres live",
systemStatus: "Status systemu",
strings: "Stringi DC",
quickMetrics: "Szybkie metryki",
importArchive: "Import archiwalny z InfluxDB",
importArchiveSubtitle: "Backfill chunkami i auto-sync brakujących dni",
startDate: "Data od",
endDate: "Data do",
chunkDays: "Chunk (dni)",
startImport: "Start importu",
syncMissing: "Synchronizuj brakujące",
cancel: "Anuluj",
status: "Status",
coverage: "Pokrycie",
importedDays: "Zaimportowane dni",
missingDays: "Brakujące dni",
throughput: "Przepustowość",
eta: "ETA",
activeChunk: "Aktywny chunk",
recentChunks: "Ostatnie chunki",
recentEvents: "Ostatnie zdarzenia",
kioskLayout: "Układ kiosku",
kioskLayoutSubtitle: "Wybierz widżety i kolejność widoku kiosku",
saveLayout: "Układ zapisuje się automatycznie",
kioskHint: "Tryb kiosku.",
widgetSelect: "Widżety",
moveUp: "W górę",
moveDown: "W dół",
selected: "Wybrane",
available: "Dostępne",
security: "Bezpieczeństwo",
authEnabled: "Logowanie aktywne",
authDisabled: "Logowanie wyłączone",
changePasswordHint: "Zmiana loginu i hasła odbywa się przez .env backendu.",
authRequired: "Wymagane logowanie",
loginError: "Nie udało się zalogować.",
loading: "Ładowanie",
inverterTemp: "Temperatura falownika",
acPower: "Moc AC",
energyTotal: "Energia łączna",
energyToday: "Energia dziś",
energyYesterday: "Energia wczoraj",
todayVsYesterday: "Dziś vs wczoraj",
dcPowerTotal: "Moc DC łącznie",
unknown: "Nieznane",
yes: "Tak",
no: "Nie",
setFullHistory: "Ustaw pełną historię",
viewMode: "Tryb widoku",
normalMode: "Normalny",
kioskMode: "Kiosk",
simplifiedCards: "Karty uproszczone",
},
en: {
live: "Live",
analytics: "Analytics",
settings: "Settings",
kiosk: "Kiosk",
operatorPanel: "Operator panel",
poweredBy: "Tabler UI",
connected: "Connected",
disconnected: "Disconnected",
updatedAt: "Updated",
loginTitle: "Sign in",
loginSubtitle: "PV monitoring",
username: "Username",
password: "Password",
signIn: "Sign in",
signOut: "Sign out",
theme: "Theme",
light: "Light",
dark: "Dark",
language: "Language",
polish: "Polski",
english: "English",
openKiosk: "Open kiosk",
exitKiosk: "Exit kiosk",
fullscreen: "Fullscreen",
demoMode: "Demo mode",
realtimeOverview: "Live overview",
realtimeSubtitle: "Key inverter and string metrics",
analyticsOverview: "Long-term production",
analyticsSubtitle: "Day / week / month / year and period comparisons",
settingsSubtitle: "Archive import, appearance, kiosk, security",
noData: "No data",
noDataDescription: "No response from backend or InfluxDB.",
chartPowerHistory: "Power and temperature history",
chartProduction: "Production",
chartProductionSubtitle: "Aggregated by selected bucket",
chartComparison: "Period comparison",
chartDistribution: "Production distribution",
currentPeriod: "Current period",
comparisonPeriod: "Comparison",
summaryTotal: "Total",
summaryAverage: "Average / bucket",
summaryBest: "Best bucket",
summaryCo2: "CO₂ saved",
compareNone: "No comparison",
comparePreviousPeriod: "Previous period",
comparePreviousYear: "Previous year",
comparePreviousYear2: "2 years back",
comparePreviousYear3: "3 years back",
comparePreviousMonth12: "12 months back",
comparePreviousMonth24: "24 months back",
compareCustomMulti: "Custom ranges",
range: "Range",
bucket: "Bucket",
liveRange: "Live range",
systemStatus: "System status",
strings: "DC strings",
quickMetrics: "Quick metrics",
importArchive: "Historical import from InfluxDB",
importArchiveSubtitle: "Chunked backfill and auto-sync for missing days",
startDate: "Start date",
endDate: "End date",
chunkDays: "Chunk (days)",
startImport: "Start import",
syncMissing: "Sync missing",
cancel: "Cancel",
status: "Status",
coverage: "Coverage",
importedDays: "Imported days",
missingDays: "Missing days",
throughput: "Throughput",
eta: "ETA",
activeChunk: "Active chunk",
recentChunks: "Recent chunks",
recentEvents: "Recent events",
kioskLayout: "Kiosk layout",
kioskLayoutSubtitle: "Choose widgets and their order for kiosk view",
saveLayout: "Layout is saved automatically",
kioskHint: "Kiosk mode.",
widgetSelect: "Widgets",
moveUp: "Move up",
moveDown: "Move down",
selected: "Selected",
available: "Available",
security: "Security",
authEnabled: "Login enabled",
authDisabled: "Login disabled",
changePasswordHint: "Change username and password in backend .env.",
authRequired: "Authentication required",
loginError: "Sign in failed.",
loading: "Loading",
inverterTemp: "Inverter temperature",
acPower: "AC power",
energyTotal: "Total energy",
energyToday: "Energy today",
energyYesterday: "Energy yesterday",
todayVsYesterday: "Today vs yesterday",
dcPowerTotal: "Total DC power",
unknown: "Unknown",
yes: "Yes",
no: "No",
setFullHistory: "Use full history",
viewMode: "View mode",
normalMode: "Normal",
kioskMode: "Kiosk",
simplifiedCards: "Simplified cards",
},
} as const;
export type MessageKey = keyof typeof messages.pl;
const metricLabels: Record<string, { pl: string; en: string }> = {
ac_power: { pl: "Moc AC", en: "AC power" },
energy_total: { pl: "Energia łączna", en: "Total energy" },
energy_today: { pl: "Energia dziś", en: "Energy today" },
energy_yesterday: { pl: "Energia wczoraj", en: "Energy yesterday" },
today_vs_yesterday: { pl: "Dziś vs wczoraj", en: "Today vs yesterday" },
dc_power_total: { pl: "Moc DC łącznie", en: "Total DC power" },
inverter_temp: { pl: "Temperatura falownika", en: "Inverter temperature" },
};
export function t(language: Language, key: MessageKey): string {
return messages[language][key] ?? messages.pl[key];
}
export function labelForMetric(language: Language, metricId: string, fallback: string): string {
return metricLabels[metricId]?.[language] ?? fallback;
}
export function localeForLanguage(language: Language): string {
return language === "en" ? "en-GB" : "pl-PL";
}
export function normalizeLanguage(value: string | null | undefined): Language {
return value === "en" ? "en" : "pl";
}
export function translateCompareMode(language: Language, mode: string): string {
switch (mode) {
case "none":
return language === "en" ? "Comparison" : "Porównanie";
case "previous_period":
return t(language, "comparePreviousPeriod");
case "previous_year":
return t(language, "comparePreviousYear");
case "previous_year_2":
return t(language, "comparePreviousYear2");
case "previous_year_3":
return t(language, "comparePreviousYear3");
case "previous_month_12":
return t(language, "comparePreviousMonth12");
case "previous_month_24":
return t(language, "comparePreviousMonth24");
case "custom_multi":
return t(language, "compareCustomMulti");
default:
return mode;
}
}

148
frontend/src/index.css Normal file
View File

@@ -0,0 +1,148 @@
: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.6rem;
min-height: 2.75rem;
padding: 0.7rem 1rem !important;
}
.pv-nav-icon {
width: 1.25rem;
min-width: 1.25rem;
height: 1.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 1.25rem;
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;
}

View File

@@ -0,0 +1,81 @@
export function formatValue(
value: number | string | null | undefined,
unit = "",
precision = 2,
locale = "pl-PL",
): string {
if (value === null || value === undefined || value === "") {
return "--";
}
if (typeof value === "number") {
return `${value.toLocaleString(locale, {
minimumFractionDigits: 0,
maximumFractionDigits: precision,
})}${unit ? ` ${unit}` : ""}`;
}
return unit ? `${value} ${unit}` : String(value);
}
export function formatDateTime(value?: string | null, locale = "pl-PL"): string {
if (!value) {
return "--";
}
return new Date(value).toLocaleString(locale, {
dateStyle: "short",
timeStyle: "short",
});
}
export function formatDate(value?: string | null, locale = "pl-PL"): string {
if (!value) {
return "--";
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return parsed.toLocaleDateString(locale);
}
export function formatShortTime(value?: string | null, locale = "pl-PL"): string {
if (!value) {
return "--";
}
return new Date(value).toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
});
}
export function formatDurationShort(value?: number | null, locale = "pl-PL"): string {
if (value === null || value === undefined || Number.isNaN(value)) {
return "--";
}
const totalSeconds = Math.max(Math.round(value), 0);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (locale.startsWith("pl")) {
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
export function formatPercent(value?: number | null, precision = 1, locale = "pl-PL"): string {
if (value === null || value === undefined || Number.isNaN(value)) {
return "--";
}
return `${value.toLocaleString(locale, {
minimumFractionDigits: 0,
maximumFractionDigits: precision,
})}%`;
}

15
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);

275
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,275 @@
export type StatusTone = "ok" | "warn" | "critical" | "neutral";
export interface HeroCard {
metric_id: string;
label: string;
value: number | string | null;
unit: string;
accent: string;
subtitle: string;
}
export interface MetricValue {
metric_id: string;
label: string;
unit: string;
value: number | string | null;
timestamp?: string | null;
precision: number;
kind: "gauge" | "counter" | "text";
status: StatusTone;
}
export interface SnapshotGroupRow {
id: string;
label: string;
values: Record<string, MetricValue>;
meta: Record<string, number | string>;
}
export interface SnapshotPayload {
updated_at?: string | null;
hero_cards: HeroCard[];
kpis: Record<string, MetricValue>;
strings: SnapshotGroupRow[];
phases: SnapshotGroupRow[];
status: MetricValue[];
faults: string[];
}
export interface SeriesPoint {
timestamp: string;
value: number | null;
}
export interface SeriesPayload {
metric_id: string;
label: string;
unit: string;
color?: string | null;
points: SeriesPoint[];
}
export interface HistoryPayload {
range_key: string;
start: string;
end: string;
series: SeriesPayload[];
}
export interface BucketPoint {
label: string;
start: string;
end: string;
value: number;
}
export interface AnalyticsSummary {
total: number;
unit: string;
average_bucket: number;
best_bucket_label: string;
best_bucket_value: number;
co2_saved_kg: number;
comparison_total?: number | null;
comparison_delta_pct?: number | null;
}
export interface AnalyticsPayload {
unit: string;
bucket: string;
compare_mode: string;
current: BucketPoint[];
comparison: BucketPoint[];
comparisons?: Array<{
key: string;
label: string;
start: string;
end: string;
total: number;
delta_pct?: number | null;
points: BucketPoint[];
}>;
summary: AnalyticsSummary;
meta: {
window: {
start: string;
end: string;
range_key: string;
};
source?: string;
};
}
export interface DistributionSlice {
label: string;
value: number;
share: number;
}
export interface DistributionPayload {
unit: string;
bucket: string;
total: number;
slices: DistributionSlice[];
}
export interface HistoricalCoverage {
imported_days: number;
first_day?: string | null;
last_day?: string | null;
total_energy_kwh: number;
available_days: number;
missing_days: number;
coverage_pct?: number | null;
}
export interface HistoricalChunkProgress {
chunk_index: number;
total_chunks: number;
start_date: string;
end_date: string;
processed_days: number;
imported_days: number;
skipped_days: number;
energy_kwh: number;
state: string;
started_at?: string | null;
finished_at?: string | null;
duration_seconds?: number | null;
note: string;
}
export interface HistoricalActivityEvent {
timestamp: string;
level: string;
title: string;
message: string;
day?: string | null;
chunk_index?: number | null;
}
export interface HistoricalStatus {
enabled: boolean;
running: boolean;
state: string;
job_id?: string | null;
started_at?: string | null;
finished_at?: string | null;
requested_start_date?: string | null;
requested_end_date?: string | null;
total_days: number;
processed_days: number;
imported_days: number;
skipped_days: number;
chunk_days: number;
total_chunks: number;
active_chunk_index: number;
current_date?: string | null;
current_chunk_start?: string | null;
current_chunk_end?: string | null;
elapsed_seconds?: number | null;
estimated_remaining_seconds?: number | null;
avg_days_per_minute?: number | null;
last_error?: string | null;
message: string;
coverage: HistoricalCoverage;
available_start_date?: string | null;
available_end_date?: string | null;
default_chunk_days: number;
recent_chunks: HistoricalChunkProgress[];
recent_events: HistoricalActivityEvent[];
}
export interface HistoricalStartPayload {
start_date?: string;
end_date?: string;
chunk_days?: number;
force?: boolean;
}
export interface AuthStatus {
enabled: boolean;
authenticated: boolean;
user?: string | null;
display_name?: string | null;
role?: string | null;
}
export interface DashboardConfig {
app: {
name: string;
version: string;
site_name: string;
timezone: string;
installed_power_kwp: number;
};
defaults: {
realtime_range: string;
analytics_range: string;
analytics_bucket: string;
tab: string;
theme: string;
language: string;
};
auth?: {
enabled: boolean;
};
i18n?: {
default_language: string;
supported_languages: string[];
};
capabilities: {
modules: Record<string, boolean>;
strings_enabled: boolean;
strings_count: number;
phases_enabled: boolean;
phases_count: number;
analytics_enabled: boolean;
realtime_enabled: boolean;
comparison_modes: string[];
ranges: Array<{ key: string; label: string }>;
buckets: Array<{ key: string; label: string }>;
historical_import_enabled: boolean;
history: {
enabled: boolean;
default_chunk_days: number;
auto_sync_enabled: boolean;
auto_sync_interval_minutes: number;
};
};
visible_entities: Array<{
metric_id: string;
label: string;
entity_id: string;
measurement: string;
unit: string;
kind: string;
}>;
}
export interface AuthUserItem {
username: string;
display_name: string;
role: string;
is_active: boolean;
created_at?: string | null;
updated_at?: string | null;
}
export interface AuthUsersPayload {
items: AuthUserItem[];
}
export interface KioskSettingsPayload {
mode: "public" | "private";
widgets: string[];
realtime_range: string;
analytics_range: string;
analytics_bucket: string;
compare_mode: string;
updated_at?: string | null;
updated_by?: string | null;
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />