This commit is contained in:
Mateusz Gruszczyński
2026-03-24 07:32:07 +01:00
parent c5cc2efbac
commit db26938791
7 changed files with 180 additions and 112 deletions

View File

@@ -12,7 +12,6 @@ import {
IconHistory,
IconLanguage,
IconLayoutDashboard,
IconLock,
IconLogin2,
IconLogout,
IconMoon,
@@ -86,7 +85,6 @@ const widgetOrder: Array<{ id: WidgetId; tab: TabKey; icon: typeof IconLayoutDas
{ 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 },
];
@@ -161,23 +159,31 @@ function buildBarOption(points: BucketPoint[], unit: string, theme: ThemeMode, l
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 {
function buildComparisonOption(data: AnalyticsPayload | undefined, theme: ThemeMode, language: Language, comparisonDisplayMode: "line" | "bar"): 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) }));
const comparisonSeries = (data?.comparisons?.length ? data.comparisons : [{ key: data?.compare_mode ?? "comparison", label: t(language, "comparisonPeriod"), points: data?.comparison ?? [] }])
.filter((item) => item.points?.length)
.map((item) => ({ ...item, label: translateCompareMode(language, item.label || item.key) }));
const verticalLegend = comparisonSeries.length > 2;
return {
color: palette.series,
tooltip: { trigger: "axis", 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 },
tooltip: { trigger: "axis", backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text } },
legend: verticalLegend
? { type: "scroll", orient: "vertical", top: 12, right: 0, bottom: 12, textStyle: { color: palette.text }, pageTextStyle: { color: palette.text } }
: { type: "scroll", top: 0, left: 0, right: 0, textStyle: { color: palette.text }, pageTextStyle: { color: palette.text } },
grid: { left: 16, right: verticalLegend ? 180 : 20, top: verticalLegend ? 20 : 48, bottom: 18, containLabel: true },
xAxis: { type: "category", axisLabel: { color: palette.text, interval: 0, rotate: current.length > 12 ? 35 : 0 }, axisLine: { lineStyle: { color: palette.grid } }, data: current.map((point) => point.label) },
yAxis: { type: "value", 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 } })),
{ name: t(language, "currentPeriod"), type: "bar", barMaxWidth: comparisonDisplayMode === "bar" ? 18 : 22, emphasis: { focus: "series" }, data: current.map((point) => point.value) },
...comparisonSeries.map((seriesItem) => comparisonDisplayMode === "bar"
? { name: seriesItem.label, type: "bar" as const, barMaxWidth: 18, emphasis: { focus: "series" as const }, data: current.map((_, pointIndex) => seriesItem.points[pointIndex]?.value ?? null) }
: { name: seriesItem.label, type: "line" as const, smooth: true, showSymbol: false, emphasis: { focus: "series" as const }, data: current.map((_, pointIndex) => seriesItem.points[pointIndex]?.value ?? null) }),
],
};
}
function buildPieOption(data: DistributionPayload | undefined, theme: ThemeMode): EChartsOption {
const palette = buildTablerChartTheme(theme);
const slices = [...(data?.slices ?? [])].sort((a, b) => b.value - a.value).slice(0, 12);
@@ -212,17 +218,70 @@ function analyticsRangeOptions(language: Language) {
{ key: "365d", label: language === "en" ? "365 days" : "365 dni" },
];
}
function archiveRangeOptions(language: Language) {
function archiveQuickRangeOptions(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" },
{ key: "today", label: language === "en" ? "Today" : "Dziś" },
{ key: "yesterday", label: language === "en" ? "Yesterday" : "Wczoraj" },
{ key: "day_before_yesterday", label: language === "en" ? "2 days ago" : "Przedwczoraj" },
];
}
function archiveListRangeOptions(language: Language) {
return [
{ key: "3d", label: language === "en" ? "3 days" : "3 dni" },
{ key: "7d", label: language === "en" ? "7 days" : "7 dni" },
{ key: "14d", label: language === "en" ? "14 days" : "14 dni" },
{ key: "30d", label: language === "en" ? "30 days" : "30 dni" },
{ key: "60d", label: language === "en" ? "60 days" : "60 dni" },
];
}
const SINGLE_DAY_ARCHIVE_KEYS = new Set(["today", "yesterday", "day_before_yesterday"]);
const ONE_HOUR_MS = 60 * 60 * 1000;
function toDateTimeLocalValue(value: Date): string {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
const hours = String(value.getHours()).padStart(2, "0");
const minutes = String(value.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function startOfOffsetDay(daysOffset: number): Date {
const value = new Date();
value.setHours(0, 0, 0, 0);
value.setDate(value.getDate() + daysOffset);
return value;
}
function archivePresetWindow(rangeKey: string): { start: string; end: string } | null {
if (rangeKey === "today") return { start: toDateTimeLocalValue(startOfOffsetDay(0)), end: toDateTimeLocalValue(new Date()) };
if (rangeKey === "yesterday") return { start: toDateTimeLocalValue(startOfOffsetDay(-1)), end: toDateTimeLocalValue(startOfOffsetDay(0)) };
if (rangeKey === "day_before_yesterday") return { start: toDateTimeLocalValue(startOfOffsetDay(-2)), end: toDateTimeLocalValue(startOfOffsetDay(-1)) };
return null;
}
function trimSingleDayHistory(history: HistoryPayload | undefined, mode: string): HistoryPayload | undefined {
if (!history || !SINGLE_DAY_ARCHIVE_KEYS.has(mode)) return history;
const timestamps = history.series
.flatMap((series) => series.points)
.filter((point) => point.value !== null)
.map((point) => new Date(point.timestamp).getTime())
.filter((value) => Number.isFinite(value));
if (!timestamps.length) return history;
const historyStart = new Date(history.start).getTime();
const historyEnd = new Date(history.end).getTime();
const trimmedStart = Math.max(historyStart, Math.min(...timestamps) - ONE_HOUR_MS);
const trimmedEnd = Math.min(historyEnd, Math.max(...timestamps) + ONE_HOUR_MS);
return {
...history,
start: new Date(trimmedStart).toISOString(),
end: new Date(trimmedEnd).toISOString(),
series: history.series.map((series) => ({
...series,
points: series.points.filter((point) => {
const timestamp = new Date(point.timestamp).getTime();
return timestamp >= trimmedStart && timestamp <= trimmedEnd;
}),
})),
};
}
function getInitialTheme(config?: DashboardConfig): ThemeMode {
return readStorage<ThemeMode>(STORAGE_KEYS.theme, (config?.defaults.theme as ThemeMode) ?? "dark", (raw) => (raw === "light" ? "light" : "dark"));
}
@@ -255,23 +314,24 @@ export default function App() {
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 [realtimeRange, setRealtimeRange] = useState("today");
const [analyticsRange, setAnalyticsRange] = useState("30d");
const [bucket, setBucket] = useState("day");
const [compare, setCompare] = useState("previous_year");
const [analyticsStart, setAnalyticsStart] = useState("");
const [analyticsEnd, setAnalyticsEnd] = useState("");
const [compareRanges, setCompareRanges] = useState<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 initialArchiveWindow = archivePresetWindow("today");
const [archiveStart, setArchiveStart] = useState(initialArchiveWindow?.start ?? "");
const [archiveEnd, setArchiveEnd] = useState(initialArchiveWindow?.end ?? "");
const [archiveRange, setArchiveRange] = useState("today");
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 [privateKioskDraft, setPrivateKioskDraft] = useState<KioskSettingsPayload>({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" });
const [publicKioskDraft, setPublicKioskDraft] = useState<KioskSettingsPayload>({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "today", 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);
@@ -280,8 +340,8 @@ export default function App() {
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" }),
public: JSON.stringify({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }),
private: JSON.stringify({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }),
});
const lastSyncedKioskRef = useRef<Record<"public" | "private", string>>({
public: defaultKioskSerializedRef.current.public,
@@ -347,6 +407,8 @@ export default function App() {
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 liveHistoryData = useMemo(() => trimSingleDayHistory(historyQuery.data, effectiveRealtimeRange), [historyQuery.data, effectiveRealtimeRange]);
const archiveHistoryData = useMemo(() => trimSingleDayHistory(archiveQuery.data, archiveRange), [archiveQuery.data, archiveRange]);
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")) });
@@ -403,12 +465,12 @@ export default function App() {
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")} />,
history: <LiveHistoryPanel data={liveHistoryData} 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} />,
distribution: null,
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>; };
@@ -425,8 +487,8 @@ export default function App() {
<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}
{!publicMode ? <button className="btn btn-sm pv-navbar-action" onClick={() => setViewMode((current) => (current === "normal" ? "kiosk" : "normal"))}><IconDeviceDesktop size={16} className="me-1" />{viewMode === "normal" ? t(language, "openKiosk") : t(language, "exitKiosk")}</button> : null}
{!publicMode ? <button className="btn btn-sm pv-navbar-action" onClick={() => logoutMutation.mutate()}><IconLogout size={16} className="me-1" />{t(language, "signOut")}</button> : null}
</div>
</div>
</header>
@@ -450,15 +512,15 @@ export default function App() {
<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 === "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="pv-filter-grid pv-filter-grid-archive"><SegmentedSelect label={language === "en" ? "Day" : "Dzień"} value={SINGLE_DAY_ARCHIVE_KEYS.has(archiveRange) ? archiveRange : ""} onChange={(value) => { setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={archiveQuickRangeOptions(language)} /><SelectField label={language === "en" ? "Last period" : "Ostatnie dni"} value={archiveListRangeOptions(language).some((item) => item.key === archiveRange) ? archiveRange : ""} onChange={(value) => { setArchiveRange(value); applyArchivePreset(value, setArchiveStart, setArchiveEnd); }} options={[{ key: "", label: language === "en" ? "Choose range" : "Wybierz zakres" }, ...archiveListRangeOptions(language), { key: "custom", label: language === "en" ? "Custom range" : "Własny zakres" }]} /><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={archiveHistoryData} language={language} theme={theme} title={language === "en" ? "Historical chart" : "Wykres historyczny"} subtitle={SINGLE_DAY_ARCHIVE_KEYS.has(archiveRange) ? (language === "en" ? "Single-day view is automatically trimmed to active data hours." : "Widok dnia jest automatycznie przycinany do aktywnych godzin danych.") : (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 === "analytics" && <><PageHeader title={t(language, "analyticsOverview")} subtitle={t(language, "analyticsSubtitle")}><div className="pv-filter-grid"><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", "48h", "1d", "3d", "14d", "60d", "ytd"].includes(item.key)).map((item) => ({ key: item.key, label: translateRangeLabel(language, item.key, item.label) })), { key: "custom", label: language === "en" ? "Custom" : "Ręczny" }]} /><SegmentedSelect label={t(language, "bucket")} value={bucket} onChange={setBucket} options={config.capabilities.buckets.map((item) => ({ key: item.key, label: translateBucket(language, item.key) }))} /><SelectField 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">{allWidgets.production}</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></>}
{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-xl-5"><AppearancePanel language={language} theme={theme} setTheme={setTheme} viewMode={viewMode} setViewMode={setViewMode} userName={authQuery.data?.display_name ?? authQuery.data?.user ?? ""} /></div><div className="col-12 col-xl-7"><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>{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>
);
}
@@ -469,6 +531,12 @@ function translateBucket(language: Language, key: string): string { const map: R
function comparisonOptions(language: Language) { return [{ key: "none", label: translateCompareMode(language, "none") }, { key: "previous_period", label: translateCompareMode(language, "previous_period") }, { key: "previous_year", label: translateCompareMode(language, "previous_year") }, { key: "previous_year_2", label: translateCompareMode(language, "previous_year_2") }, { key: "previous_year_3", label: translateCompareMode(language, "previous_year_3") }, { key: "previous_month_12", label: translateCompareMode(language, "previous_month_12") }, { key: "previous_month_24", label: translateCompareMode(language, "previous_month_24") }, { key: "custom_multi", label: translateCompareMode(language, "custom_multi") }]; }
function applyArchivePreset(rangeKey: string, setStart: (value: string) => void, setEnd: (value: string) => void) {
const preset = archivePresetWindow(rangeKey);
if (preset) {
setStart(preset.start);
setEnd(preset.end);
return;
}
if (rangeKey !== "custom") {
setStart("");
setEnd("");
@@ -478,7 +546,9 @@ function LoadingScreen({ language }: { language: Language }) { return <div class
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 SegmentedSelect({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return <div className="pv-segmented"><div className="pv-segmented-label">{label}</div><div className="btn-group pv-segmented-group" role="group" aria-label={label}>{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 SelectField({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ key: string; label: string }> }) { return <div className="pv-filter-field"><label className="form-label small mb-1">{label}</label><select className="form-select form-select-sm" value={value} onChange={(event) => onChange(event.target.value)}>{options.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}</select></div>; }
function translateRangeLabel(language: Language, key: string, fallback: string): string { const map: Record<string, { pl: string; en: string }> = { today: { pl: "Dziś", en: "Today" }, yesterday: { pl: "Wczoraj", en: "Yesterday" }, "7d": { pl: "7 dni", en: "7d" }, "30d": { pl: "30 dni", en: "30d" }, "90d": { pl: "90 dni", en: "90d" }, "365d": { pl: "365 dni", en: "365 days" }, custom: { pl: "Ręczny", en: "Custom" } }; return map[key]?.[language] ?? fallback; }
function HeroCards({ cards, locale, language }: { cards: SnapshotPayload["hero_cards"]; locale: string; language: Language }) { return <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>; }
@@ -486,15 +556,20 @@ function StatusPanel({ metrics, locale, language }: { metrics: MetricValue[]; lo
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 ComparisonPanel({ data, language, theme }: { data?: AnalyticsPayload; language: Language; theme: ThemeMode }) {
const [comparisonDisplayMode, setComparisonDisplayMode] = useState<"line" | "bar">("line");
return <div className="card pv-card h-100"><div className="card-header d-flex flex-column flex-lg-row gap-3 align-items-start align-items-lg-center justify-content-between"><div><h3 className="card-title">{t(language, "chartComparison")}</h3><div className="text-secondary small">{comparisonDisplayMode === "line"
? (language === "en" ? "Current period stays in bars, and comparisons are shown as green lines for readability." : "Możesz widok przełączyć do słupków w celu poprawy czytelności.")
: (language === "en" ? "Switch to grouped bars when you want a direct bar-to-bar comparison." : "Przełącz do porównania wykresu linią.")}</div></div><SegmentedSelect label={language === "en" ? "Comparison style" : "Styl porównania"} value={comparisonDisplayMode} onChange={(value) => setComparisonDisplayMode(value as "line" | "bar")} options={[{ key: "line", label: language === "en" ? "Line" : "Linia" }, { key: "bar", label: language === "en" ? "Bars" : "Słupki" }]} /></div><div className="card-body"><EChart option={buildComparisonOption(data, theme, language, comparisonDisplayMode)} 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 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, "infoSave")}</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 AppearancePanel({ language, theme, setTheme, viewMode, setViewMode, userName }: { language: Language; theme: ThemeMode; setTheme: (value: ThemeMode) => void; viewMode: ViewMode; setViewMode: (value: ViewMode) => void; userName: string; }) { return <div className="card pv-card h-100"><div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2"><div><h3 className="card-title">{language === "en" ? "Appearance and mode" : "Wygląd i tryb pracy"}</h3><div className="text-secondary small">{language === "en" ? "Most-used display settings in one place." : "Najczęściej używane ustawienia wyświetlania w jednym miejscu."}</div></div>{userName ? <span className="badge bg-primary-lt text-primary">{userName}</span> : null}</div><div className="card-body d-flex flex-column gap-4"><div><div className="fw-semibold mb-2">{t(language, "theme")}</div><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 className="border-top pt-3"><div className="fw-semibold mb-2">{t(language, "viewMode")}</div><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>; }
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>; }

View File

@@ -69,7 +69,7 @@ async function demoResponse<T>(factory: () => T): Promise<T> {
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}`)),
getKioskSettings: (mode: "public" | "private") => (DEMO_MODE ? demoResponse(() => ({ mode, widgets: ["hero", "history", "strings", "status", "production", "comparison", "importStatus"], realtime_range: "today", 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) =>

View File

@@ -20,7 +20,7 @@ export const demoConfig: DashboardConfig = {
installed_power_kwp: 9.86,
},
defaults: {
realtime_range: "6h",
realtime_range: "today",
analytics_range: "30d",
analytics_bucket: "day",
tab: "realtime",
@@ -39,6 +39,8 @@ export const demoConfig: DashboardConfig = {
realtime_enabled: true,
comparison_modes: ["none", "previous_period", "previous_year"],
ranges: [
{ key: "today", label: "Dziś" },
{ key: "yesterday", label: "Wczoraj" },
{ key: "6h", label: "6h" },
{ key: "24h", label: "24h" },
{ key: "1d", label: "1 dzień" },

View File

@@ -12,7 +12,7 @@ const messages = {
disconnected: "Brak połączenia",
updatedAt: "Aktualizacja",
loginTitle: "Logowanie",
loginSubtitle: "Minitoring fotowoltaiki",
loginSubtitle: "Monitoring fotowoltaiki",
username: "Login",
password: "Hasło",
signIn: "Zaloguj",
@@ -78,7 +78,7 @@ const messages = {
recentEvents: "Ostatnie zdarzenia",
kioskLayout: "Układ kiosku",
kioskLayoutSubtitle: "Wybierz widżety i kolejność widoku kiosku",
saveLayout: "Układ zapisuje się automatycznie",
infoSave: "Po zmianie układu zapisz przyciskiem powyżej",
kioskHint: "Tryb kiosku.",
widgetSelect: "Widżety",
moveUp: "W górę",
@@ -185,7 +185,7 @@ const messages = {
recentEvents: "Recent events",
kioskLayout: "Kiosk layout",
kioskLayoutSubtitle: "Choose widgets and their order for kiosk view",
saveLayout: "Layout is saved automatically",
infoSave: "After changing the layout, save using the button above",
kioskHint: "Kiosk mode.",
widgetSelect: "Widgets",
moveUp: "Move up",

View File

@@ -118,19 +118,19 @@ body {
display: inline-flex !important;
align-items: center;
justify-content: center;
gap: 0.6rem;
min-height: 2.75rem;
padding: 0.7rem 1rem !important;
gap: 0.1rem;
min-height: 1.75rem;
padding: 0.5rem 0.5rem !important;
}
.pv-nav-icon {
width: 1.25rem;
min-width: 1.25rem;
height: 1.25rem;
width: 1rem;
min-width: 1rem;
height: 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 1.25rem;
flex: 0 0 1.1rem;
line-height: 1;
}
@@ -146,3 +146,57 @@ body {
line-height: 1.1;
white-space: nowrap;
}
.pv-navbar-action {
border-color: transparent !important;
background: rgba(127, 127, 127, 0.1) !important;
color: inherit !important;
}
.pv-navbar-action:hover,
.pv-navbar-action:focus {
background: rgba(127, 127, 127, 0.18) !important;
}
.pv-filter-grid {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 0.75rem;
align-items: end;
}
.pv-filter-grid-archive {
grid-template-columns: minmax(240px, 1.35fr) minmax(180px, 0.9fr) repeat(2, minmax(160px, 1fr));
}
.pv-segmented {
min-width: 0;
}
.pv-segmented-label {
margin-bottom: 0.35rem;
font-size: 0.75rem;
color: var(--tblr-secondary);
}
.pv-segmented-group {
display: flex !important;
flex-wrap: wrap;
gap: 0.45rem;
}
.pv-segmented-group > .btn {
border-radius: 0.65rem !important;
flex: 1 0 auto;
}
.pv-filter-field {
min-width: 0;
}
@media (max-width: 992px) {
.pv-filter-grid,
.pv-filter-grid-archive {
grid-template-columns: 1fr;
}
}