uxowe i funkcjonalne
This commit is contained in:
1 parent
84fe898a74
commit
077ea315f5
7 files changed
+352
-53
No files matched your search
@@ -31,7 +31,7 @@ def _resolve_kiosk_mode(requested_mode: str, require_write_access: bool = False)
|
|||||||
if normalized_mode != "private":
|
if normalized_mode != "private":
|
||||||
raise ValueError("Mode musi byc jednym z: public, private")
|
raise ValueError("Mode musi byc jednym z: public, private")
|
||||||
|
|
||||||
if (not auth_service.enabled) or session.get("auth_role") == "admin":
|
if not auth_service.enabled:
|
||||||
return "private", "private"
|
return "private", "private"
|
||||||
|
|
||||||
username = session.get("auth_user")
|
username = session.get("auth_user")
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from app.storage.kiosk_settings import SQLiteKioskSettingsRepository
|
|||||||
VALID_MODES = {"public", "private"}
|
VALID_MODES = {"public", "private"}
|
||||||
USER_MODE_PREFIX = "user:"
|
USER_MODE_PREFIX = "user:"
|
||||||
DEFAULT_WIDGETS = ["hero", "history", "strings", "status", "production", "comparison", "importStatus"]
|
DEFAULT_WIDGETS = ["hero", "history", "strings", "status", "production", "comparison", "importStatus"]
|
||||||
|
DEFAULT_HERO_METRICS = ["ac_power", "dc_power_total", "energy_today", "energy_total"]
|
||||||
|
DEFAULT_CHART_GROUPS = [{"id": "overview", "title": None, "metric_ids": ["ac_power", "dc_power_total", "inverter_temp"]}]
|
||||||
VALID_WIDGETS = {"hero", "quickMetrics", "history", "status", "strings", "production", "comparison", "distribution", "importStatus"}
|
VALID_WIDGETS = {"hero", "quickMetrics", "history", "status", "strings", "production", "comparison", "distribution", "importStatus"}
|
||||||
VALID_REALTIME_RANGES = {"today", "yesterday", "6h", "12h", "24h", "48h", "7d"}
|
VALID_REALTIME_RANGES = {"today", "yesterday", "6h", "12h", "24h", "48h", "7d"}
|
||||||
VALID_ANALYTICS_RANGES = {"today", "yesterday", "7d", "30d", "90d", "365d", "custom"}
|
VALID_ANALYTICS_RANGES = {"today", "yesterday", "7d", "30d", "90d", "365d", "custom"}
|
||||||
@@ -42,10 +44,12 @@ class KioskSettingsService:
|
|||||||
return {
|
return {
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"widgets": list(DEFAULT_WIDGETS),
|
"widgets": list(DEFAULT_WIDGETS),
|
||||||
|
"hero_metric_ids": list(DEFAULT_HERO_METRICS),
|
||||||
"realtime_range": self._default_realtime_range(),
|
"realtime_range": self._default_realtime_range(),
|
||||||
"analytics_range": self._default_analytics_range(),
|
"analytics_range": self._default_analytics_range(),
|
||||||
"analytics_bucket": self._default_analytics_bucket(),
|
"analytics_bucket": self._default_analytics_bucket(),
|
||||||
"compare_mode": self._default_compare_mode(),
|
"compare_mode": self._default_compare_mode(),
|
||||||
|
"chart_groups": self._normalize_chart_groups(None),
|
||||||
"updated_at": None,
|
"updated_at": None,
|
||||||
"updated_by": None,
|
"updated_by": None,
|
||||||
}
|
}
|
||||||
@@ -54,10 +58,12 @@ class KioskSettingsService:
|
|||||||
cleaned = {
|
cleaned = {
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"widgets": self._normalize_widgets(payload.get("widgets")),
|
"widgets": self._normalize_widgets(payload.get("widgets")),
|
||||||
|
"hero_metric_ids": self._normalize_metric_ids(payload.get("hero_metric_ids"), DEFAULT_HERO_METRICS),
|
||||||
"realtime_range": self._normalize_realtime_range(payload.get("realtime_range")),
|
"realtime_range": self._normalize_realtime_range(payload.get("realtime_range")),
|
||||||
"analytics_range": self._normalize_analytics_range(payload.get("analytics_range")),
|
"analytics_range": self._normalize_analytics_range(payload.get("analytics_range")),
|
||||||
"analytics_bucket": self._normalize_bucket(payload.get("analytics_bucket")),
|
"analytics_bucket": self._normalize_bucket(payload.get("analytics_bucket")),
|
||||||
"compare_mode": self._normalize_compare_mode(payload.get("compare_mode")),
|
"compare_mode": self._normalize_compare_mode(payload.get("compare_mode")),
|
||||||
|
"chart_groups": self._normalize_chart_groups(payload.get("chart_groups")),
|
||||||
"updated_at": payload.get("updated_at"),
|
"updated_at": payload.get("updated_at"),
|
||||||
"updated_by": payload.get("updated_by"),
|
"updated_by": payload.get("updated_by"),
|
||||||
}
|
}
|
||||||
@@ -83,6 +89,16 @@ class KioskSettingsService:
|
|||||||
normalized.append(widget)
|
normalized.append(widget)
|
||||||
return normalized or list(DEFAULT_WIDGETS)
|
return normalized or list(DEFAULT_WIDGETS)
|
||||||
|
|
||||||
|
def _normalize_metric_ids(self, metric_ids: Any, defaults: list[str]) -> list[str]:
|
||||||
|
if not isinstance(metric_ids, list):
|
||||||
|
return list(defaults)
|
||||||
|
normalized: list[str] = []
|
||||||
|
for item in metric_ids:
|
||||||
|
value = str(item or "").strip()
|
||||||
|
if value and value not in normalized:
|
||||||
|
normalized.append(value)
|
||||||
|
return normalized[:12] or list(defaults)
|
||||||
|
|
||||||
def _normalize_realtime_range(self, value: Any) -> str:
|
def _normalize_realtime_range(self, value: Any) -> str:
|
||||||
normalized = str(value or self._default_realtime_range()).strip()
|
normalized = str(value or self._default_realtime_range()).strip()
|
||||||
return normalized if normalized in VALID_REALTIME_RANGES else self._default_realtime_range()
|
return normalized if normalized in VALID_REALTIME_RANGES else self._default_realtime_range()
|
||||||
@@ -99,6 +115,31 @@ class KioskSettingsService:
|
|||||||
normalized = str(value or self._default_compare_mode()).strip()
|
normalized = str(value or self._default_compare_mode()).strip()
|
||||||
return normalized if normalized in self.settings.analytics["compare_modes"] else self._default_compare_mode()
|
return normalized if normalized in self.settings.analytics["compare_modes"] else self._default_compare_mode()
|
||||||
|
|
||||||
|
def _normalize_chart_groups(self, groups: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(groups, list):
|
||||||
|
return [dict(item) for item in DEFAULT_CHART_GROUPS]
|
||||||
|
|
||||||
|
normalized: list[dict[str, Any]] = []
|
||||||
|
for index, item in enumerate(groups):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
raw_metrics = item.get("metric_ids")
|
||||||
|
if not isinstance(raw_metrics, list):
|
||||||
|
continue
|
||||||
|
metric_ids: list[str] = []
|
||||||
|
for metric in raw_metrics:
|
||||||
|
value = str(metric or "").strip()
|
||||||
|
if value and value not in metric_ids:
|
||||||
|
metric_ids.append(value)
|
||||||
|
if not metric_ids:
|
||||||
|
continue
|
||||||
|
chart_id = str(item.get("id") or f"chart_{index + 1}").strip() or f"chart_{index + 1}"
|
||||||
|
title_raw = item.get("title")
|
||||||
|
title = None if title_raw is None else str(title_raw).strip() or None
|
||||||
|
normalized.append({"id": chart_id[:80], "title": title, "metric_ids": metric_ids[:24]})
|
||||||
|
|
||||||
|
return normalized[:8] or [dict(item) for item in DEFAULT_CHART_GROUPS]
|
||||||
|
|
||||||
def _default_realtime_range(self) -> str:
|
def _default_realtime_range(self) -> str:
|
||||||
raw = str(self.settings.realtime.get("history_default_range", "12h"))
|
raw = str(self.settings.realtime.get("history_default_range", "12h"))
|
||||||
return raw if raw in VALID_REALTIME_RANGES else "12h"
|
return raw if raw in VALID_REALTIME_RANGES else "12h"
|
||||||
|
|||||||
+286
-41
@@ -35,6 +35,7 @@ import type {
|
|||||||
DashboardConfig,
|
DashboardConfig,
|
||||||
DiagnosticsPayload,
|
DiagnosticsPayload,
|
||||||
DistributionPayload,
|
DistributionPayload,
|
||||||
|
KioskChartGroup,
|
||||||
HistoryPayload,
|
HistoryPayload,
|
||||||
HistoricalStatus,
|
HistoricalStatus,
|
||||||
KioskSettingsPayload,
|
KioskSettingsPayload,
|
||||||
@@ -47,6 +48,7 @@ type ThemeMode = "light" | "dark";
|
|||||||
type TabKey = "realtime" | "archive" | "analytics" | "warehouse" | "kiosk" | "settings";
|
type TabKey = "realtime" | "archive" | "analytics" | "warehouse" | "kiosk" | "settings";
|
||||||
type ViewMode = "normal" | "kiosk";
|
type ViewMode = "normal" | "kiosk";
|
||||||
type WidgetId = "hero" | "quickMetrics" | "history" | "status" | "strings" | "production" | "comparison" | "distribution" | "importStatus";
|
type WidgetId = "hero" | "quickMetrics" | "history" | "status" | "strings" | "production" | "comparison" | "distribution" | "importStatus";
|
||||||
|
type LiveWidgetId = "hero" | "quickMetrics" | "history" | "status" | "strings";
|
||||||
type BlockTarget = "hero" | "quick";
|
type BlockTarget = "hero" | "quick";
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
@@ -55,16 +57,20 @@ const STORAGE_KEYS = {
|
|||||||
kioskWidgets: "pv-kiosk-widgets-v4",
|
kioskWidgets: "pv-kiosk-widgets-v4",
|
||||||
viewMode: "pv-view-mode-v4",
|
viewMode: "pv-view-mode-v4",
|
||||||
blockConfig: "pv-block-config-v4",
|
blockConfig: "pv-block-config-v4",
|
||||||
|
liveWidgets: "pv-live-widgets-v4",
|
||||||
liveMetrics: "pv-live-metrics-v4",
|
liveMetrics: "pv-live-metrics-v4",
|
||||||
archiveMetrics: "pv-archive-metrics-v4",
|
archiveMetrics: "pv-archive-metrics-v4",
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_KIOSK_WIDGETS: WidgetId[] = ["hero", "history", "strings", "status", "production", "comparison", "importStatus"];
|
const DEFAULT_KIOSK_WIDGETS: WidgetId[] = ["hero", "history", "strings", "status", "production", "comparison", "importStatus"];
|
||||||
|
const DEFAULT_LIVE_WIDGETS: LiveWidgetId[] = ["hero", "quickMetrics", "history", "status", "strings"];
|
||||||
const DEFAULT_BLOCK_CONFIG: Record<BlockTarget, string[]> = {
|
const DEFAULT_BLOCK_CONFIG: Record<BlockTarget, string[]> = {
|
||||||
hero: ["ac_power", "dc_power_total", "energy_today", "energy_total"],
|
hero: ["ac_power", "dc_power_total", "energy_today", "energy_total"],
|
||||||
quick: ["energy_today", "energy_yesterday", "energy_total", "dc_power_total", "today_vs_yesterday"],
|
quick: ["energy_today", "energy_yesterday", "energy_total", "dc_power_total", "today_vs_yesterday"],
|
||||||
};
|
};
|
||||||
const DEFAULT_LIVE_METRICS = ["ac_power", "string_1_power", "string_2_power"];
|
const DEFAULT_LIVE_METRICS = ["ac_power", "string_1_power", "string_2_power"];
|
||||||
|
const DEFAULT_KIOSK_HERO_METRICS = ["ac_power", "dc_power_total", "energy_today", "energy_total"];
|
||||||
|
const DEFAULT_KIOSK_CHART_GROUPS: KioskChartGroup[] = [{ id: "overview", title: null, metric_ids: ["ac_power", "dc_power_total", "inverter_temp"] }];
|
||||||
function getKioskRouteMode(): "public" | "private" | null {
|
function getKioskRouteMode(): "public" | "private" | null {
|
||||||
const pathname = window.location.pathname.replace(/\/+$/, "") || "/";
|
const pathname = window.location.pathname.replace(/\/+$/, "") || "/";
|
||||||
if (pathname.endsWith("/kiosk/public")) return "public";
|
if (pathname.endsWith("/kiosk/public")) return "public";
|
||||||
@@ -121,7 +127,7 @@ function buildWidgetLabel(language: Language, widgetId: WidgetId): string {
|
|||||||
const labels: Record<WidgetId, string> = {
|
const labels: Record<WidgetId, string> = {
|
||||||
hero: language === "en" ? "Hero metrics" : "Karty hero",
|
hero: language === "en" ? "Hero metrics" : "Karty hero",
|
||||||
quickMetrics: t(language, "quickMetrics"),
|
quickMetrics: t(language, "quickMetrics"),
|
||||||
history: t(language, "chartPowerHistory"),
|
history: t(language, "kioskCharts"),
|
||||||
status: t(language, "systemStatus"),
|
status: t(language, "systemStatus"),
|
||||||
strings: t(language, "strings"),
|
strings: t(language, "strings"),
|
||||||
production: t(language, "chartProduction"),
|
production: t(language, "chartProduction"),
|
||||||
@@ -144,11 +150,84 @@ function buildUnitLabel(units: Array<string | undefined | null>): string {
|
|||||||
const unique = units.filter((item): item is string => Boolean(item)).filter((item, index, list) => list.indexOf(item) === index);
|
const unique = units.filter((item): item is string => Boolean(item)).filter((item, index, list) => list.indexOf(item) === index);
|
||||||
return unique.join(" / ");
|
return unique.join(" / ");
|
||||||
}
|
}
|
||||||
|
function buildDefaultKioskChartGroups(): KioskChartGroup[] {
|
||||||
|
return DEFAULT_KIOSK_CHART_GROUPS.map((group) => ({ ...group, metric_ids: [...group.metric_ids] }));
|
||||||
|
}
|
||||||
|
function sanitizeKioskHeroMetrics(metricIds: string[] | undefined, items?: Array<{ metric_id: string; label: string; unit: string }>): string[] {
|
||||||
|
const allowed = items?.length ? new Set(items.map((item) => item.metric_id)) : null;
|
||||||
|
const normalized = Array.from(new Set((metricIds ?? []).map((metricId) => String(metricId || "").trim()).filter(Boolean))).filter((metricId) => !allowed || allowed.has(metricId));
|
||||||
|
if (normalized.length) return normalized.slice(0, 8);
|
||||||
|
if (items?.length) {
|
||||||
|
const fallback = DEFAULT_KIOSK_HERO_METRICS.filter((metricId) => allowed?.has(metricId));
|
||||||
|
return (fallback.length ? fallback : items.slice(0, 4).map((item) => item.metric_id)).slice(0, 8);
|
||||||
|
}
|
||||||
|
return [...DEFAULT_KIOSK_HERO_METRICS];
|
||||||
|
}
|
||||||
|
function sanitizeKioskChartGroups(groups: KioskChartGroup[] | undefined, items?: Array<{ metric_id: string; label: string; unit: string }>): KioskChartGroup[] {
|
||||||
|
const allowed = items?.length ? new Set(items.map((item) => item.metric_id)) : null;
|
||||||
|
const normalized = (groups ?? []).map((group, index) => ({
|
||||||
|
id: String(group?.id || `chart_${index + 1}`),
|
||||||
|
title: (group?.title ?? "") || null,
|
||||||
|
metric_ids: Array.from(new Set((group?.metric_ids ?? []).map((metricId) => String(metricId || "").trim()).filter(Boolean))).filter((metricId) => !allowed || allowed.has(metricId)),
|
||||||
|
})).filter((group) => group.metric_ids.length);
|
||||||
|
if (normalized.length) return normalized.slice(0, 8);
|
||||||
|
if (items?.length) {
|
||||||
|
const preferred = ["ac_power", "dc_power_total", "inverter_temp"].filter((metricId) => allowed?.has(metricId));
|
||||||
|
const fallback = preferred.length ? preferred : items.slice(0, 3).map((item) => item.metric_id);
|
||||||
|
return [{ id: "overview", title: null, metric_ids: fallback }];
|
||||||
|
}
|
||||||
|
return buildDefaultKioskChartGroups();
|
||||||
|
}
|
||||||
|
function buildChartGroupAutoTitle(group: KioskChartGroup, items: Array<{ metric_id: string; label: string; unit: string }>, language: Language): string {
|
||||||
|
if (group.title?.trim()) return group.title.trim();
|
||||||
|
const labels = group.metric_ids.map((metricId) => items.find((item) => item.metric_id === metricId)?.label).filter(Boolean) as string[];
|
||||||
|
if (labels.length === 1) return labels[0];
|
||||||
|
if (labels.length >= 2 && labels.length <= 3) return labels.join(" / ");
|
||||||
|
return language === "en" ? `Chart ${group.id}` : `Wykres ${group.id}`;
|
||||||
|
}
|
||||||
|
function classifyChartMetric(item: { metric_id: string; label: string; unit: string }): "temp" | "ac" | "dc" | "other" {
|
||||||
|
const metricId = item.metric_id.toLowerCase();
|
||||||
|
const label = item.label.toLowerCase();
|
||||||
|
const unit = item.unit.toLowerCase();
|
||||||
|
if (metricId.includes("temp") || label.includes("temp") || unit.includes("°") || unit.includes("c")) return "temp";
|
||||||
|
if (metricId.includes("ac") || label.includes(" ac") || label.startsWith("ac") || label.includes("falownik")) return "ac";
|
||||||
|
if (metricId.includes("dc") || metricId.includes("string_") || label.includes("dc") || label.includes("string")) return "dc";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
function buildPresetKioskChartGroups(language: Language, items: Array<{ metric_id: string; label: string; unit: string }>, preset: "single" | "split"): KioskChartGroup[] {
|
||||||
|
if (!items.length) return buildDefaultKioskChartGroups();
|
||||||
|
if (preset === "single") return [{ id: "overview", title: null, metric_ids: items.map((item) => item.metric_id).slice(0, 12) }];
|
||||||
|
const grouped: Record<"ac" | "dc" | "temp" | "other", Array<{ metric_id: string; label: string; unit: string }>> = { ac: [], dc: [], temp: [], other: [] };
|
||||||
|
items.forEach((item) => grouped[classifyChartMetric(item)].push(item));
|
||||||
|
const charts: KioskChartGroup[] = [];
|
||||||
|
if (grouped.ac.length) charts.push({ id: "ac", title: "AC", metric_ids: grouped.ac.map((item) => item.metric_id).slice(0, 12) });
|
||||||
|
if (grouped.dc.length) charts.push({ id: "dc", title: "DC", metric_ids: grouped.dc.map((item) => item.metric_id).slice(0, 12) });
|
||||||
|
if (grouped.temp.length) charts.push({ id: "temp", title: language === "en" ? "Temperature" : "Temperatura", metric_ids: grouped.temp.map((item) => item.metric_id).slice(0, 12) });
|
||||||
|
if (grouped.other.length) charts.push({ id: "other", title: language === "en" ? "Other" : "Inne", metric_ids: grouped.other.map((item) => item.metric_id).slice(0, 12) });
|
||||||
|
return charts.length ? charts : [{ id: "overview", title: null, metric_ids: items.map((item) => item.metric_id).slice(0, 12) }];
|
||||||
|
}
|
||||||
|
function filterHistoryByChartGroup(history: HistoryPayload | undefined, group: KioskChartGroup): HistoryPayload | undefined {
|
||||||
|
return filterHistoryByMetrics(history, group.metric_ids);
|
||||||
|
}
|
||||||
function buildLiveHistoryOption(history: HistoryPayload | undefined, theme: ThemeMode, language: Language): EChartsOption {
|
function buildLiveHistoryOption(history: HistoryPayload | undefined, theme: ThemeMode, language: Language): EChartsOption {
|
||||||
const palette = buildTablerChartTheme(theme);
|
const palette = buildTablerChartTheme(theme);
|
||||||
const series = history?.series ?? [];
|
const series = history?.series ?? [];
|
||||||
const locale = localeForLanguage(language);
|
const locale = localeForLanguage(language);
|
||||||
const unitLabel = buildUnitLabel(series.map((item) => item.unit));
|
const units = series
|
||||||
|
.map((item) => item.unit || "")
|
||||||
|
.filter((item, index, list) => list.indexOf(item) === index);
|
||||||
|
const yAxes = units.map((unit, index) => {
|
||||||
|
const position: "left" | "right" = index % 2 === 0 ? "left" : "right";
|
||||||
|
return {
|
||||||
|
type: "value" as const,
|
||||||
|
name: unit,
|
||||||
|
position,
|
||||||
|
offset: index > 1 ? Math.floor((index - 1) / 2) * 56 : 0,
|
||||||
|
nameTextStyle: { color: palette.text },
|
||||||
|
axisLabel: { color: palette.text, formatter: (value: number) => formatChartNumber(value, locale) },
|
||||||
|
splitLine: index === 0 ? { lineStyle: { color: palette.grid } } : { show: false },
|
||||||
|
};
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
color: palette.series,
|
color: palette.series,
|
||||||
tooltip: {
|
tooltip: {
|
||||||
@@ -163,10 +242,10 @@ function buildLiveHistoryOption(history: HistoryPayload | undefined, theme: Them
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
legend: { top: 0, textStyle: { color: palette.text }, itemGap: 16 },
|
legend: { top: 0, textStyle: { color: palette.text }, itemGap: 16 },
|
||||||
grid: { left: 12, right: 16, top: 48, bottom: 12, containLabel: true },
|
grid: { left: 12, right: yAxes.length > 1 ? 44 : 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, locale)) },
|
xAxis: { type: "category", boundaryGap: false, axisLabel: { color: palette.text }, axisLine: { lineStyle: { color: palette.grid } }, data: (series[0]?.points ?? []).map((point: any) => formatShortTime(point.timestamp, locale)) },
|
||||||
yAxis: { type: "value", name: unitLabel, nameTextStyle: { color: palette.text }, axisLabel: { color: palette.text, formatter: (value: number) => formatChartNumber(value, locale) }, splitLine: { lineStyle: { color: palette.grid } } },
|
yAxis: yAxes.length ? yAxes : [{ type: "value" as const, name: buildUnitLabel(series.map((item) => item.unit)), nameTextStyle: { color: palette.text }, axisLabel: { color: palette.text, formatter: (value: number) => formatChartNumber(value, locale) }, splitLine: { lineStyle: { color: palette.grid } } }],
|
||||||
series: series.map((item, index) => ({ name: item.unit ? `${item.label} [${item.unit}]` : 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) })),
|
series: series.map((item, index) => ({ name: item.unit ? `${item.label} [${item.unit}]` : item.label, type: "line", smooth: true, connectNulls: true, showSymbol: false, yAxisIndex: Math.max(units.indexOf(item.unit || ""), 0), lineStyle: { width: index === 0 ? 3 : 2 }, emphasis: { focus: "series" }, data: item.points.map((point: any) => point.value) })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function buildBarOption(points: BucketPoint[], unit: string, theme: ThemeMode, language: Language): EChartsOption {
|
function buildBarOption(points: BucketPoint[], unit: string, theme: ThemeMode, language: Language): EChartsOption {
|
||||||
@@ -347,12 +426,33 @@ function getInitialLanguage(config?: DashboardConfig): Language {
|
|||||||
}
|
}
|
||||||
function getVisibleWidgets(ids: WidgetId[]): WidgetId[] { const base = ids.filter((id, index) => ids.indexOf(id) === index); return base.length > 0 ? base : DEFAULT_KIOSK_WIDGETS; }
|
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 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) {
|
function getVisibleLiveWidgets(ids: LiveWidgetId[]): LiveWidgetId[] {
|
||||||
const fromConfig = (config?.visible_entities ?? [])
|
const base = ids.filter((id, index) => ids.indexOf(id) === index);
|
||||||
.filter((item) => item.kind === "gauge")
|
return base.length ? base : DEFAULT_LIVE_WIDGETS;
|
||||||
.map((item) => ({ metric_id: item.metric_id, label: item.label, unit: item.unit }));
|
}
|
||||||
|
function toLiveWidgetIds(ids: string[]): LiveWidgetId[] {
|
||||||
|
return getVisibleLiveWidgets(ids.filter((id): id is LiveWidgetId => DEFAULT_LIVE_WIDGETS.includes(id as LiveWidgetId)));
|
||||||
|
}
|
||||||
|
function metricToHeroCard(metric: MetricValue): SnapshotPayload["hero_cards"][number] {
|
||||||
|
const numeric = typeof metric.value === "number" ? metric.value : Number(metric.value);
|
||||||
|
const accent = metric.metric_id.includes("temp") ? (Number.isFinite(numeric) && numeric >= 70 ? "rose" : Number.isFinite(numeric) && numeric >= 55 ? "amber" : "emerald") : (metric.status === "warn" ? "amber" : metric.status === "critical" ? "rose" : "emerald");
|
||||||
|
return { metric_id: metric.metric_id, label: metric.label, value: metric.value, unit: metric.unit, accent, subtitle: metric.unit || metric.status || "" };
|
||||||
|
}
|
||||||
|
function heroCardToMetric(card: SnapshotPayload["hero_cards"][number]): MetricValue {
|
||||||
|
const numeric = typeof card.value === "number" ? card.value : Number(card.value);
|
||||||
|
return { metric_id: card.metric_id, label: card.label, unit: card.unit, value: card.value, precision: Number.isFinite(numeric) && !Number.isInteger(numeric) ? 2 : 0, kind: typeof card.value === "string" ? "text" : "gauge", status: "neutral" };
|
||||||
|
}
|
||||||
|
function getChartMetricCandidates(config?: DashboardConfig) {
|
||||||
const map = new Map<string, { metric_id: string; label: string; unit: string }>();
|
const map = new Map<string, { metric_id: string; label: string; unit: string }>();
|
||||||
fromConfig.forEach((item) => map.set(item.metric_id, item));
|
(config?.visible_entities ?? [])
|
||||||
|
.filter((item) => item.kind === "gauge")
|
||||||
|
.forEach((item) => map.set(item.metric_id, { metric_id: item.metric_id, label: item.label, unit: item.unit }));
|
||||||
|
return [...map.values()];
|
||||||
|
}
|
||||||
|
function getBlockMetricCandidates(snapshot: SnapshotPayload) {
|
||||||
|
const map = new Map<string, { metric_id: string; label: string; unit: string }>();
|
||||||
|
snapshot.hero_cards.forEach((card) => map.set(card.metric_id, { metric_id: card.metric_id, label: card.label, unit: card.unit }));
|
||||||
|
Object.values(snapshot.kpis ?? {}).forEach((metric) => map.set(metric.metric_id, { metric_id: metric.metric_id, label: metric.label, unit: metric.unit }));
|
||||||
return [...map.values()];
|
return [...map.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,11 +484,12 @@ export default function App() {
|
|||||||
const [archiveRange, setArchiveRange] = useState("today");
|
const [archiveRange, setArchiveRange] = useState("today");
|
||||||
const [liveMetrics, setLiveMetrics] = useState<string[]>(() => readStorage(STORAGE_KEYS.liveMetrics, DEFAULT_LIVE_METRICS));
|
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 [archiveMetrics, setArchiveMetrics] = useState<string[]>(() => readStorage(STORAGE_KEYS.archiveMetrics, DEFAULT_LIVE_METRICS));
|
||||||
|
const [liveWidgets, setLiveWidgets] = useState<LiveWidgetId[]>(() => getVisibleLiveWidgets(readStorage<LiveWidgetId[]>(STORAGE_KEYS.liveWidgets, DEFAULT_LIVE_WIDGETS)));
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>(() => { const fromUrl = parseViewModeFromLocation(); return fromUrl === "kiosk" ? fromUrl : readStorage<ViewMode>(STORAGE_KEYS.viewMode, "normal", (raw) => (raw === "kiosk" ? "kiosk" : "normal")); });
|
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 [kioskWidgets, setKioskWidgets] = useState<WidgetId[]>(() => getVisibleWidgets(readStorage<WidgetId[]>(STORAGE_KEYS.kioskWidgets, DEFAULT_KIOSK_WIDGETS)));
|
||||||
const [kioskEditorMode, setKioskEditorMode] = useState<"private" | "public">("private");
|
const [kioskEditorMode, setKioskEditorMode] = useState<"private" | "public">("private");
|
||||||
const [privateKioskDraft, setPrivateKioskDraft] = useState<KioskSettingsPayload>({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" });
|
const [privateKioskDraft, setPrivateKioskDraft] = useState<KioskSettingsPayload>({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, hero_metric_ids: DEFAULT_KIOSK_HERO_METRICS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none", chart_groups: buildDefaultKioskChartGroups() });
|
||||||
const [publicKioskDraft, setPublicKioskDraft] = useState<KioskSettingsPayload>({ mode: "public", 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, hero_metric_ids: DEFAULT_KIOSK_HERO_METRICS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none", chart_groups: buildDefaultKioskChartGroups() });
|
||||||
const [blockConfig, setBlockConfig] = useState<Record<BlockTarget, string[]>>(() => readStorage(STORAGE_KEYS.blockConfig, DEFAULT_BLOCK_CONFIG));
|
const [blockConfig, setBlockConfig] = useState<Record<BlockTarget, string[]>>(() => readStorage(STORAGE_KEYS.blockConfig, DEFAULT_BLOCK_CONFIG));
|
||||||
const [loginForm, setLoginForm] = useState({ username: "", password: "" });
|
const [loginForm, setLoginForm] = useState({ username: "", password: "" });
|
||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
@@ -397,8 +498,8 @@ export default function App() {
|
|||||||
const [kioskSaveNotice, setKioskSaveNotice] = useState<Record<"public" | "private", string | null>>({ public: null, private: null });
|
const [kioskSaveNotice, setKioskSaveNotice] = useState<Record<"public" | "private", string | null>>({ public: null, private: null });
|
||||||
const initializedRef = useRef(false);
|
const initializedRef = useRef(false);
|
||||||
const defaultKioskSerializedRef = useRef<Record<"public" | "private", string>>({
|
const defaultKioskSerializedRef = useRef<Record<"public" | "private", string>>({
|
||||||
public: JSON.stringify({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }),
|
public: JSON.stringify({ mode: "public", widgets: DEFAULT_KIOSK_WIDGETS, hero_metric_ids: DEFAULT_KIOSK_HERO_METRICS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none", chart_groups: buildDefaultKioskChartGroups() }),
|
||||||
private: JSON.stringify({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none" }),
|
private: JSON.stringify({ mode: "private", widgets: DEFAULT_KIOSK_WIDGETS, hero_metric_ids: DEFAULT_KIOSK_HERO_METRICS, realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none", chart_groups: buildDefaultKioskChartGroups() }),
|
||||||
});
|
});
|
||||||
const lastSyncedKioskRef = useRef<Record<"public" | "private", string>>({
|
const lastSyncedKioskRef = useRef<Record<"public" | "private", string>>({
|
||||||
public: defaultKioskSerializedRef.current.public,
|
public: defaultKioskSerializedRef.current.public,
|
||||||
@@ -417,13 +518,13 @@ export default function App() {
|
|||||||
}, [config, publicMode]);
|
}, [config, publicMode]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!privateKioskSettingsQuery.data) return;
|
if (!privateKioskSettingsQuery.data) return;
|
||||||
const normalized = { ...privateKioskSettingsQuery.data, mode: "private" as const };
|
const normalized = { ...privateKioskSettingsQuery.data, mode: "private" as const, hero_metric_ids: sanitizeKioskHeroMetrics(privateKioskSettingsQuery.data.hero_metric_ids), chart_groups: sanitizeKioskChartGroups(privateKioskSettingsQuery.data.chart_groups) };
|
||||||
lastSyncedKioskRef.current.private = JSON.stringify(normalized);
|
lastSyncedKioskRef.current.private = JSON.stringify(normalized);
|
||||||
applyKioskDraftChange("private", normalized);
|
applyKioskDraftChange("private", normalized);
|
||||||
}, [privateKioskSettingsQuery.data]);
|
}, [privateKioskSettingsQuery.data]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!publicKioskSettingsQuery.data) return;
|
if (!publicKioskSettingsQuery.data) return;
|
||||||
const normalized = { ...publicKioskSettingsQuery.data, mode: "public" as const };
|
const normalized = { ...publicKioskSettingsQuery.data, mode: "public" as const, hero_metric_ids: sanitizeKioskHeroMetrics(publicKioskSettingsQuery.data.hero_metric_ids), chart_groups: sanitizeKioskChartGroups(publicKioskSettingsQuery.data.chart_groups) };
|
||||||
lastSyncedKioskRef.current.public = JSON.stringify(normalized);
|
lastSyncedKioskRef.current.public = JSON.stringify(normalized);
|
||||||
applyKioskDraftChange("public", normalized);
|
applyKioskDraftChange("public", normalized);
|
||||||
}, [publicKioskSettingsQuery.data]);
|
}, [publicKioskSettingsQuery.data]);
|
||||||
@@ -432,6 +533,7 @@ export default function App() {
|
|||||||
useEffect(() => { syncViewModeToLocation(viewMode); writeStorage(STORAGE_KEYS.viewMode, viewMode); }, [viewMode]);
|
useEffect(() => { syncViewModeToLocation(viewMode); writeStorage(STORAGE_KEYS.viewMode, viewMode); }, [viewMode]);
|
||||||
useEffect(() => { writeStorage(STORAGE_KEYS.kioskWidgets, kioskWidgets); }, [kioskWidgets]);
|
useEffect(() => { writeStorage(STORAGE_KEYS.kioskWidgets, kioskWidgets); }, [kioskWidgets]);
|
||||||
useEffect(() => { writeStorage(STORAGE_KEYS.blockConfig, blockConfig); }, [blockConfig]);
|
useEffect(() => { writeStorage(STORAGE_KEYS.blockConfig, blockConfig); }, [blockConfig]);
|
||||||
|
useEffect(() => { writeStorage(STORAGE_KEYS.liveWidgets, liveWidgets); }, [liveWidgets]);
|
||||||
useEffect(() => { writeStorage(STORAGE_KEYS.liveMetrics, liveMetrics); }, [liveMetrics]);
|
useEffect(() => { writeStorage(STORAGE_KEYS.liveMetrics, liveMetrics); }, [liveMetrics]);
|
||||||
useEffect(() => { writeStorage(STORAGE_KEYS.archiveMetrics, archiveMetrics); }, [archiveMetrics]);
|
useEffect(() => { writeStorage(STORAGE_KEYS.archiveMetrics, archiveMetrics); }, [archiveMetrics]);
|
||||||
|
|
||||||
@@ -443,34 +545,62 @@ export default function App() {
|
|||||||
const canSavePrivateKioskSettings = !publicMode && dataEnabled;
|
const canSavePrivateKioskSettings = !publicMode && dataEnabled;
|
||||||
const canSavePublicKioskSettings = !publicMode && isAdmin;
|
const canSavePublicKioskSettings = !publicMode && isAdmin;
|
||||||
const { snapshot, connected, lastUpdated } = useRealtimeSocket(dataEnabled);
|
const { snapshot, connected, lastUpdated } = useRealtimeSocket(dataEnabled);
|
||||||
const metricCandidates = useMemo(() => getMetricCandidates(snapshot, config), [snapshot, config]);
|
const chartMetricCandidates = useMemo(() => getChartMetricCandidates(config), [config]);
|
||||||
|
const blockMetricCandidates = useMemo(() => getBlockMetricCandidates(snapshot), [snapshot]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!metricCandidates.length) return;
|
if (!chartMetricCandidates.length) return;
|
||||||
const allowed = new Set(metricCandidates.map((item) => item.metric_id));
|
const allowed = new Set(chartMetricCandidates.map((item) => item.metric_id));
|
||||||
setLiveMetrics((current) => {
|
setLiveMetrics((current) => {
|
||||||
const filtered = current.filter((item) => allowed.has(item));
|
const filtered = current.filter((item) => allowed.has(item));
|
||||||
return filtered.length ? filtered : metricCandidates.slice(0, 3).map((item) => item.metric_id);
|
return filtered.length ? filtered : chartMetricCandidates.slice(0, 3).map((item) => item.metric_id);
|
||||||
});
|
});
|
||||||
setArchiveMetrics((current) => {
|
setArchiveMetrics((current) => {
|
||||||
const filtered = current.filter((item) => allowed.has(item));
|
const filtered = current.filter((item) => allowed.has(item));
|
||||||
return filtered.length ? filtered : metricCandidates.slice(0, 3).map((item) => item.metric_id);
|
return filtered.length ? filtered : chartMetricCandidates.slice(0, 3).map((item) => item.metric_id);
|
||||||
});
|
});
|
||||||
}, [metricCandidates]);
|
}, [chartMetricCandidates]);
|
||||||
const liveHistoryMetrics = useMemo(() => liveMetrics.filter((item) => item !== "inverter_temp"), [liveMetrics]);
|
useEffect(() => {
|
||||||
|
if (!blockMetricCandidates.length) return;
|
||||||
|
const allowed = new Set(blockMetricCandidates.map((item) => item.metric_id));
|
||||||
|
setBlockConfig((current) => {
|
||||||
|
const next = {
|
||||||
|
hero: current.hero.filter((item) => allowed.has(item)),
|
||||||
|
quick: current.quick.filter((item) => allowed.has(item)),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
hero: next.hero.length ? next.hero : blockMetricCandidates.filter((item) => DEFAULT_BLOCK_CONFIG.hero.includes(item.metric_id)).map((item) => item.metric_id),
|
||||||
|
quick: next.quick.length ? next.quick : blockMetricCandidates.filter((item) => DEFAULT_BLOCK_CONFIG.quick.includes(item.metric_id)).map((item) => item.metric_id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [blockMetricCandidates]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chartMetricCandidates.length) return;
|
||||||
|
setPrivateKioskDraft((current) => ({ ...current, chart_groups: sanitizeKioskChartGroups(current.chart_groups, chartMetricCandidates) }));
|
||||||
|
setPublicKioskDraft((current) => ({ ...current, chart_groups: sanitizeKioskChartGroups(current.chart_groups, chartMetricCandidates) }));
|
||||||
|
}, [chartMetricCandidates]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!blockMetricCandidates.length) return;
|
||||||
|
setPrivateKioskDraft((current) => ({ ...current, hero_metric_ids: sanitizeKioskHeroMetrics(current.hero_metric_ids, blockMetricCandidates) }));
|
||||||
|
setPublicKioskDraft((current) => ({ ...current, hero_metric_ids: sanitizeKioskHeroMetrics(current.hero_metric_ids, blockMetricCandidates) }));
|
||||||
|
}, [blockMetricCandidates]);
|
||||||
|
const liveHistoryMetrics = useMemo(() => liveMetrics, [liveMetrics]);
|
||||||
const effectiveKioskSettings = publicMode ? publicKioskDraft : privateKioskDraft;
|
const effectiveKioskSettings = publicMode ? publicKioskDraft : privateKioskDraft;
|
||||||
|
const effectiveKioskChartGroups = useMemo(() => sanitizeKioskChartGroups(effectiveKioskSettings.chart_groups, chartMetricCandidates), [effectiveKioskSettings.chart_groups, chartMetricCandidates]);
|
||||||
|
const effectiveKioskHeroMetricIds = useMemo(() => sanitizeKioskHeroMetrics(effectiveKioskSettings.hero_metric_ids, blockMetricCandidates), [effectiveKioskSettings.hero_metric_ids, blockMetricCandidates]);
|
||||||
const kioskActive = publicMode || privateKioskRoute || viewMode === "kiosk";
|
const kioskActive = publicMode || privateKioskRoute || viewMode === "kiosk";
|
||||||
const effectiveKioskWidgets = toWidgetIds(kioskActive ? effectiveKioskSettings.widgets : kioskWidgets);
|
const effectiveKioskWidgets = toWidgetIds(kioskActive ? effectiveKioskSettings.widgets : kioskWidgets);
|
||||||
const effectiveRealtimeRange = kioskActive ? effectiveKioskSettings.realtime_range : realtimeRange;
|
const effectiveRealtimeRange = kioskActive ? effectiveKioskSettings.realtime_range : realtimeRange;
|
||||||
const effectiveAnalyticsRange = kioskActive ? effectiveKioskSettings.analytics_range : analyticsRange;
|
const effectiveAnalyticsRange = kioskActive ? effectiveKioskSettings.analytics_range : analyticsRange;
|
||||||
const effectiveBucket = kioskActive ? effectiveKioskSettings.analytics_bucket : bucket;
|
const effectiveBucket = kioskActive ? effectiveKioskSettings.analytics_bucket : bucket;
|
||||||
const effectiveCompare = kioskActive ? effectiveKioskSettings.compare_mode : compare;
|
const effectiveCompare = kioskActive ? effectiveKioskSettings.compare_mode : compare;
|
||||||
const historyQuery = useRealtimeHistory(effectiveRealtimeRange, dataEnabled, { metrics: liveHistoryMetrics, publicKiosk: publicMode });
|
const historyQuery = useRealtimeHistory(effectiveRealtimeRange, dataEnabled, { publicKiosk: publicMode });
|
||||||
const sanitizedCompareRanges = compareRanges.filter((item) => item.start && item.end);
|
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 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 analyticsQuery = useAnalytics(analyticsStart && analyticsEnd && !kioskActive ? "custom" : effectiveAnalyticsRange, effectiveBucket, effectiveCompare, dataEnabled, analyticsOptions);
|
||||||
const historical = useHistoricalImport(hasWarehouseAccess);
|
const historical = useHistoricalImport(hasWarehouseAccess);
|
||||||
const archiveQuery = useRealtimeHistory(archiveStart && archiveEnd ? "custom" : archiveRange, dataEnabled, { start: archiveStart || undefined, end: archiveEnd || undefined, metrics: archiveMetrics, publicKiosk: publicMode });
|
const archiveQuery = useRealtimeHistory(archiveStart && archiveEnd ? "custom" : archiveRange, dataEnabled, { start: archiveStart || undefined, end: archiveEnd || undefined, metrics: archiveMetrics, publicKiosk: publicMode });
|
||||||
const liveHistoryData = useMemo(() => filterHistoryByMetrics(trimSingleDayHistory(historyQuery.data, effectiveRealtimeRange), liveHistoryMetrics), [historyQuery.data, effectiveRealtimeRange, liveHistoryMetrics]);
|
const rawRealtimeHistoryData = useMemo(() => trimSingleDayHistory(historyQuery.data, effectiveRealtimeRange), [historyQuery.data, effectiveRealtimeRange]);
|
||||||
|
const liveHistoryData = useMemo(() => filterHistoryByMetrics(rawRealtimeHistoryData, liveHistoryMetrics), [rawRealtimeHistoryData, liveHistoryMetrics]);
|
||||||
const archiveHistoryData = useMemo(() => filterHistoryByMetrics(trimSingleDayHistory(archiveQuery.data, archiveRange), archiveMetrics), [archiveQuery.data, archiveRange, archiveMetrics]);
|
const archiveHistoryData = useMemo(() => filterHistoryByMetrics(trimSingleDayHistory(archiveQuery.data, archiveRange), archiveMetrics), [archiveQuery.data, archiveRange, archiveMetrics]);
|
||||||
const usersQuery = useQuery<AuthUsersPayload>({ queryKey: ["auth-users"], queryFn: api.getUsers, enabled: dataEnabled && isAdmin, staleTime: 15_000 });
|
const usersQuery = useQuery<AuthUsersPayload>({ queryKey: ["auth-users"], queryFn: api.getUsers, enabled: dataEnabled && isAdmin, staleTime: 15_000 });
|
||||||
const diagnosticsQuery = useQuery<DiagnosticsPayload>({ queryKey: ["diagnostics"], queryFn: api.getDiagnostics, enabled: hasSettingsAccess, staleTime: 20_000 });
|
const diagnosticsQuery = useQuery<DiagnosticsPayload>({ queryKey: ["diagnostics"], queryFn: api.getDiagnostics, enabled: hasSettingsAccess, staleTime: 20_000 });
|
||||||
@@ -495,7 +625,7 @@ export default function App() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const applyKioskDraftChange = (mode: "public" | "private", next: KioskSettingsPayload) => {
|
const applyKioskDraftChange = (mode: "public" | "private", next: KioskSettingsPayload) => {
|
||||||
const normalized: KioskSettingsPayload = { ...next, mode };
|
const normalized: KioskSettingsPayload = { ...next, mode, hero_metric_ids: sanitizeKioskHeroMetrics(next.hero_metric_ids), chart_groups: sanitizeKioskChartGroups(next.chart_groups) };
|
||||||
if (mode === "public") setPublicKioskDraft(normalized); else setPrivateKioskDraft(normalized);
|
if (mode === "public") setPublicKioskDraft(normalized); else setPrivateKioskDraft(normalized);
|
||||||
setKioskSaveNotice((current) => ({ ...current, [mode]: null }));
|
setKioskSaveNotice((current) => ({ ...current, [mode]: null }));
|
||||||
};
|
};
|
||||||
@@ -530,25 +660,68 @@ export default function App() {
|
|||||||
|
|
||||||
const locale = localeForLanguage(language);
|
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 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 effectiveLiveWidgets = useMemo(() => getVisibleLiveWidgets(toLiveWidgetIds(liveWidgets)), [liveWidgets]);
|
||||||
const summary = analyticsQuery.production.data?.summary;
|
const summary = analyticsQuery.production.data?.summary;
|
||||||
const topStatus = snapshot.status ?? [];
|
const statusHiddenMetrics = new Set(["inverter_temp", "data_refresh", "data_freshness"]);
|
||||||
const heroCards = snapshot.hero_cards.filter((card) => blockConfig.hero.includes(card.metric_id));
|
const topStatus = (snapshot.status ?? []).filter((metric) => !statusHiddenMetrics.has(metric.metric_id));
|
||||||
const quickMetrics = Object.values(snapshot.kpis ?? {}).filter((metric) => blockConfig.quick.includes(metric.metric_id));
|
const metricLookup = useMemo(() => {
|
||||||
|
const map = new Map<string, MetricValue>();
|
||||||
|
snapshot.hero_cards.forEach((card) => map.set(card.metric_id, heroCardToMetric(card)));
|
||||||
|
Object.values(snapshot.kpis ?? {}).forEach((metric) => map.set(metric.metric_id, metric));
|
||||||
|
return map;
|
||||||
|
}, [snapshot.hero_cards, snapshot.kpis]);
|
||||||
|
const heroCardLookup = useMemo(() => new Map(snapshot.hero_cards.map((card) => [card.metric_id, card])), [snapshot.hero_cards]);
|
||||||
|
const heroCards = blockConfig.hero.map((metricId) => heroCardLookup.get(metricId) ?? (metricLookup.get(metricId) ? metricToHeroCard(metricLookup.get(metricId)!) : null)).filter(Boolean) as SnapshotPayload["hero_cards"];
|
||||||
|
const kioskHeroCards = effectiveKioskHeroMetricIds.map((metricId) => heroCardLookup.get(metricId) ?? (metricLookup.get(metricId) ? metricToHeroCard(metricLookup.get(metricId)!) : null)).filter(Boolean) as SnapshotPayload["hero_cards"];
|
||||||
|
const quickMetrics = blockConfig.quick.map((metricId) => metricLookup.get(metricId)).filter(Boolean) as MetricValue[];
|
||||||
const publicKioskUrl = `${window.location.origin}/kiosk/public`;
|
const publicKioskUrl = `${window.location.origin}/kiosk/public`;
|
||||||
const privateKioskUrl = `${window.location.origin}/kiosk/private`;
|
const privateKioskUrl = `${window.location.origin}/kiosk/private`;
|
||||||
|
|
||||||
const allWidgets: Record<WidgetId, ReactElement | null> = {
|
const allWidgets: Record<WidgetId, ReactElement | null> = {
|
||||||
hero: <HeroCards cards={heroCards} locale={locale} language={language} />,
|
hero: <HeroCards cards={kioskActive ? kioskHeroCards : heroCards} locale={locale} language={language} />,
|
||||||
quickMetrics: <QuickMetrics metrics={quickMetrics} locale={locale} language={language} />,
|
quickMetrics: <QuickMetrics metrics={quickMetrics} locale={locale} language={language} />,
|
||||||
history: <LiveHistoryPanel data={liveHistoryData} 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} />,
|
status: <StatusPanel metrics={topStatus} locale={locale} language={language} connected={connected} lastUpdated={lastUpdated} diagnostics={diagnosticsQuery.data} config={config} />,
|
||||||
strings: <StringsPanel rows={snapshot.strings} locale={locale} language={language} />,
|
strings: <StringsPanel rows={snapshot.strings} locale={locale} language={language} />,
|
||||||
production: <ProductionPanel data={analyticsQuery.production.data} language={language} theme={theme} />,
|
production: <ProductionPanel data={analyticsQuery.production.data} language={language} theme={theme} />,
|
||||||
comparison: effectiveCompare !== "none" ? <ComparisonPanel data={analyticsQuery.production.data} language={language} theme={theme} /> : null,
|
comparison: effectiveCompare !== "none" ? <ComparisonPanel data={analyticsQuery.production.data} language={language} theme={theme} /> : null,
|
||||||
distribution: null,
|
distribution: null,
|
||||||
importStatus: <HistoricalPanel status={historical.status.data} language={language} locale={locale} compact />,
|
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>; };
|
const renderWidget = (widgetId: WidgetId) => {
|
||||||
|
if (widgetId === "history") {
|
||||||
|
const groups = effectiveKioskChartGroups;
|
||||||
|
const columnClass = groups.length <= 1 ? "col-12" : "col-12 col-xxl-6";
|
||||||
|
return groups.map((group, index) => (
|
||||||
|
<div key={`${widgetId}-${group.id}-${index}`} className={columnClass}>
|
||||||
|
<LiveHistoryPanel
|
||||||
|
data={filterHistoryByChartGroup(rawRealtimeHistoryData, group)}
|
||||||
|
language={language}
|
||||||
|
theme={theme}
|
||||||
|
title={buildChartGroupAutoTitle(group, chartMetricCandidates, language)}
|
||||||
|
subtitle={group.metric_ids.map((metricId) => chartMetricCandidates.find((item) => item.metric_id === metricId)?.label).filter(Boolean).join(" · ") || t(language, "realtimeSubtitle")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
const content = allWidgets[widgetId];
|
||||||
|
if (!content) return null;
|
||||||
|
return <div key={widgetId} className={widgetId === "hero" ? "col-12" : "col-12 col-xxl-4"}>{content}</div>;
|
||||||
|
};
|
||||||
|
const renderLiveWidget = (widgetId: LiveWidgetId) => {
|
||||||
|
const content = allWidgets[widgetId];
|
||||||
|
if (!content) return null;
|
||||||
|
const className = widgetId === "hero"
|
||||||
|
? "col-12"
|
||||||
|
: widgetId === "quickMetrics"
|
||||||
|
? "col-12 col-xl-4"
|
||||||
|
: widgetId === "history"
|
||||||
|
? "col-12 col-xl-8"
|
||||||
|
: widgetId === "status"
|
||||||
|
? "col-12 col-xl-4"
|
||||||
|
: "col-12 col-xl-8";
|
||||||
|
return <div key={widgetId} className={className}>{content}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
if ((!publicMode && authQuery.isLoading) || (authEnabled && !authenticated && loginMutation.isPending)) return <LoadingScreen language={language} />;
|
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 (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} />;
|
||||||
@@ -609,17 +782,17 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">{navbar}{menu}<div className="page-wrapper"><div className="page-body"><div className="container-xl">
|
<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 === "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">{effectiveLiveWidgets.map((widgetId) => renderLiveWidget(widgetId))}</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 === "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={chartMetricCandidates.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="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 === "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 === "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" ? "Choose kiosk type, tune ranges and arrange sections." : "Wybierz typ kiosku, ustaw zakresy i ułóż sekcje."} /><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={canPersistCurrentKioskSettings} saveNotice={kioskSaveNotice[kioskEditorMode]} onSave={saveCurrentKioskSettings} onReset={() => resetKioskDraft(kioskEditorMode)} allowPublicMode={isAdmin} /></div><div className="col-12 col-xxl-4"><KioskLinkPanel language={language} publicKioskUrl={publicKioskUrl} privateKioskUrl={privateKioskUrl} publicSettings={publicKioskDraft} privateSettings={privateKioskDraft} showPublicLink={isAdmin} /></div></div></>}
|
{activeTab === "kiosk" && <><PageHeader title={t(language, "kiosk")} subtitle={language === "en" ? "Choose kiosk type, tune ranges and arrange sections." : "Wybierz typ kiosku, ustaw zakresy i ułóż sekcje."} /><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={canPersistCurrentKioskSettings} saveNotice={kioskSaveNotice[kioskEditorMode]} onSave={saveCurrentKioskSettings} onReset={() => resetKioskDraft(kioskEditorMode)} allowPublicMode={isAdmin} chartItems={chartMetricCandidates} heroItems={blockMetricCandidates} /></div><div className="col-12 col-xxl-4"><KioskLinkPanel language={language} publicKioskUrl={publicKioskUrl} privateKioskUrl={privateKioskUrl} publicSettings={publicKioskDraft} privateSettings={privateKioskDraft} showPublicLink={isAdmin} /></div></div></>}
|
||||||
|
|
||||||
{activeTab === "settings" && <><PageHeader title={t(language, "settings")} subtitle={language === "en" ? "Quick settings, dashboard visibility and live diagnostics." : "Szybkie ustawienia, widoczność dashboardu i diagnostyka live."} /><div className="row row-cards g-3"><div className="col-12 col-xl-5"><AppearancePanel language={language} setLanguage={setLanguage} theme={theme} setTheme={setTheme} viewMode={viewMode} setViewMode={setViewMode} userName={authQuery.data?.display_name ?? authQuery.data?.user ?? ""} /></div><div className="col-12 col-xl-7"><DiagnosticPanel language={language} locale={locale} data={diagnosticsQuery.data} loading={diagnosticsQuery.isLoading} onRefresh={() => diagnosticsQuery.refetch()} /></div><div className="col-12 col-xl-6"><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 col-xl-6"><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></>}
|
{activeTab === "settings" && <><PageHeader title={t(language, "settings")} subtitle={language === "en" ? "Quick settings, dashboard visibility and live diagnostics." : "Szybkie ustawienia, widoczność dashboardu i diagnostyka live."} /><div className="row row-cards g-3"><div className="col-12 col-xl-5"><AppearancePanel language={language} setLanguage={setLanguage} theme={theme} setTheme={setTheme} viewMode={viewMode} setViewMode={setViewMode} userName={authQuery.data?.display_name ?? authQuery.data?.user ?? ""} /></div><div className="col-12 col-xl-7"><DiagnosticPanel language={language} locale={locale} data={diagnosticsQuery.data} loading={diagnosticsQuery.isLoading} onRefresh={() => diagnosticsQuery.refetch()} /></div><div className="col-12 col-xl-6"><LiveSectionVisibilityPanel language={language} selected={effectiveLiveWidgets} onChange={setLiveWidgets} /></div><div className="col-12 col-xl-6"><LiveChartMetricsPanel language={language} items={chartMetricCandidates.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={blockMetricCandidates} 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>
|
</div></div></div></div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -654,7 +827,17 @@ function StatusStat({ label, value }: { label: string; value: string }) { return
|
|||||||
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 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 SystemStatus({ items, locale, language }: { items: 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">{items.map((metric) => <div key={metric.metric_id} className="d-flex align-items-center justify-content-between gap-3 status-row border rounded-3 px-3 py-2"><div><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit || metric.status}</div></div><div className="fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div>)}</div></div>; }
|
function SystemStatus({ items, locale, language }: { items: 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">{items.map((metric) => <div key={metric.metric_id} className="d-flex align-items-center justify-content-between gap-3 status-row border rounded-3 px-3 py-2"><div><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit || metric.status}</div></div><div className="fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div>)}</div></div>; }
|
||||||
function StringPanels({ 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.map((row) => <div key={row.id} className="col-12 col-md-6"><div className="string-panel border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{row.label}</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"><span>{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 StringPanels({ 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.map((row) => <div key={row.id} className="col-12 col-md-6"><div className="string-panel border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{row.label}</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"><span>{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 StatusPanel({ metrics, locale, language }: { metrics: MetricValue[]; locale: string; language: Language }) { return <SystemStatus items={metrics} locale={locale} language={language} />; }
|
function StatusDot({ ok }: { ok: boolean }) { return <span className={`d-inline-block rounded-circle ${ok ? "bg-green" : "bg-yellow"}`} style={{ width: 10, height: 10 }} />; }
|
||||||
|
function StatusPanel({ metrics, locale, language, connected, lastUpdated, diagnostics, config }: { metrics: MetricValue[]; locale: string; language: Language; connected: boolean; lastUpdated?: string | null; diagnostics?: DiagnosticsPayload; config?: DashboardConfig }) {
|
||||||
|
const infoItems = [
|
||||||
|
{ label: language === "en" ? "Live feed" : "Połączenie live", value: connected ? (language === "en" ? "Connected" : "Połączono") : (language === "en" ? "Waiting" : "Oczekiwanie"), dot: <StatusDot ok={connected} /> },
|
||||||
|
diagnostics ? { label: "InfluxDB", value: diagnostics.influx.reachable ? (language === "en" ? "Connected" : "Połączono") : (language === "en" ? "Error" : "Błąd"), dot: <StatusDot ok={diagnostics.influx.reachable} /> } : null,
|
||||||
|
{ label: language === "en" ? "Last update" : "Ostatni odczyt", value: formatDateTime(lastUpdated, locale) },
|
||||||
|
config ? { label: language === "en" ? "System" : "System", value: `${config.app.version} · ${config.app.timezone}` } : null,
|
||||||
|
config ? { label: language === "en" ? "Installed power" : "Moc instalacji", value: `${formatValue(config.app.installed_power_kwp, "kWp", 2, locale)}` } : null,
|
||||||
|
].filter(Boolean) as Array<{ label: string; value: string; dot?: ReactNode }>;
|
||||||
|
return <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"><div className="d-flex flex-column gap-2">{infoItems.map((item) => <div key={item.label} className="d-flex align-items-center justify-content-between gap-3 border rounded-3 px-3 py-2"><div className="text-secondary small">{item.label}</div><div className="d-flex align-items-center gap-2">{item.dot ?? null}<span className="fw-semibold text-end">{item.value}</span></div></div>)}</div>{metrics.length ? <div className="border-top pt-3 d-flex flex-column gap-2">{metrics.map((metric) => <div key={metric.metric_id} className="d-flex align-items-center justify-content-between gap-3"><div><div className="fw-medium">{labelForMetric(language, metric.metric_id, metric.label)}</div><div className="text-secondary small">{metric.unit || metric.status}</div></div><div className="fw-semibold">{formatValue(metric.value, metric.unit, 2, locale)}</div></div>)}</div> : null}</div></div>;
|
||||||
|
}
|
||||||
function StringsPanel({ rows, locale, language }: { rows: SnapshotGroupRow[]; locale: string; language: Language }) { return <StringPanels rows={rows} locale={locale} language={language} />; }
|
function StringsPanel({ rows, locale, language }: { rows: SnapshotGroupRow[]; locale: string; language: Language }) { return <StringPanels rows={rows} locale={locale} language={language} />; }
|
||||||
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 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>; }
|
||||||
|
|
||||||
@@ -670,16 +853,78 @@ function HistoricalPanel({ status, language, locale, compact = false }: { status
|
|||||||
function ImportControls({ status, language, onStart, onSyncNow, onCancel }: { status?: HistoricalStatus; language: Language; onStart: (payload: { start_date?: string; end_date?: string; chunk_days?: number; force?: boolean }) => void; onSyncNow: () => void; onCancel: () => void; }) { const [startDate, setStartDate] = useState(status?.available_start_date ?? ""); const [endDate, setEndDate] = useState(status?.available_end_date ?? ""); const [chunkDays, setChunkDays] = useState(String(status?.default_chunk_days ?? 7)); useEffect(() => { if (!status) return; setStartDate((current) => current || status.available_start_date || ""); setEndDate((current) => current || status.available_end_date || ""); setChunkDays((current) => current || String(status.default_chunk_days || 7)); }, [status]); return <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 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 KioskModeCard({ active, title, subtitle, onClick }: { active: boolean; title: string; subtitle: string; onClick: () => void; }) { return <button className={`btn text-start w-100 p-3 ${active ? "btn-primary" : "btn-outline-secondary"}`} onClick={onClick}><div className="fw-semibold mb-1">{title}</div><div className={active ? "text-white text-opacity-75 small" : "text-secondary small"}>{subtitle}</div></button>; }
|
function KioskModeCard({ active, title, subtitle, onClick }: { active: boolean; title: string; subtitle: string; onClick: () => void; }) { return <button className={`btn text-start w-100 p-3 ${active ? "btn-primary" : "btn-outline-secondary"}`} onClick={onClick}><div className="fw-semibold mb-1">{title}</div><div className={active ? "text-white text-opacity-75 small" : "text-secondary small"}>{subtitle}</div></button>; }
|
||||||
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">{language === "en" ? "3. Section order" : "3. Kolejność sekcji"}</h3><div className="text-secondary small">{language === "en" ? "Top list is shown in kiosk from left to right, top to bottom." : "Lista u góry jest wyświetlana w kiosku dokładnie w tej kolejności."}</div></div></div><div className="card-body"><div className="row g-3"><div className="col-12"><div className="alert alert-info py-2 mb-0">{language === "en" ? "Tip: keep the most important sections first: hero, chart, strings/status." : "Wskazówka: na początku trzymaj najważniejsze sekcje: hero, wykres, stringi/status."}</div></div><div className="col-12 col-lg-7"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{language === "en" ? "Visible in kiosk" : "Widoczne w kiosku"}</div><div className="d-flex flex-column gap-2">{selected.map((id, index) => <div key={id} className="d-flex align-items-center justify-content-between gap-2 border rounded-3 px-3 py-2 bg-body-tertiary"><div><div className="fw-medium">{index + 1}. {labels.get(id)}</div><div className="text-secondary small">{widgetOrder.find((item) => item.id === id)?.tab}</div></div><div className="btn-list"><button className="btn btn-sm btn-outline-secondary" onClick={() => move(id, -1)}>{language === "en" ? "Up" : "Wyżej"}</button><button className="btn btn-sm btn-outline-secondary" onClick={() => move(id, 1)}>{language === "en" ? "Down" : "Niżej"}</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">{language === "en" ? "Available sections" : "Dostępne sekcje"}</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">{language === "en" ? "3. Section order" : "3. Kolejność sekcji"}</h3><div className="text-secondary small">{language === "en" ? "Top list is shown in kiosk from left to right, top to bottom." : "Lista u góry jest wyświetlana w kiosku dokładnie w tej kolejności."}</div></div></div><div className="card-body"><div className="row g-3"><div className="col-12"><div className="alert alert-info py-2 mb-0">{language === "en" ? "Tip: keep the most important sections first: hero, chart, strings/status." : "Wskazówka: na początku trzymaj najważniejsze sekcje: hero, wykres, stringi/status."}</div></div><div className="col-12 col-lg-7"><div className="border rounded-3 p-3 h-100"><div className="fw-semibold mb-3">{language === "en" ? "Visible in kiosk" : "Widoczne w kiosku"}</div><div className="d-flex flex-column gap-2">{selected.map((id, index) => <div key={id} className="d-flex align-items-center justify-content-between gap-2 border rounded-3 px-3 py-2 bg-body-tertiary"><div><div className="fw-medium">{index + 1}. {labels.get(id)}</div><div className="text-secondary small">{widgetOrder.find((item) => item.id === id)?.tab}</div></div><div className="btn-list"><button className="btn btn-sm btn-outline-secondary" onClick={() => move(id, -1)}>{language === "en" ? "Up" : "Wyżej"}</button><button className="btn btn-sm btn-outline-secondary" onClick={() => move(id, 1)}>{language === "en" ? "Down" : "Niżej"}</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">{language === "en" ? "Available sections" : "Dostępne sekcje"}</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, allowPublicMode }: { 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; allowPublicMode: boolean; }) {
|
function KioskSettingsEditorPanel({ language, value, onChange, onSave, onReset, selectedMode, onModeChange, labels, buckets, compareModes, saving, dirty, canSave, saveNotice, allowPublicMode, chartItems, heroItems }: { language: Language; value: KioskSettingsPayload; onChange: (value: KioskSettingsPayload) => void; onSave: () => void; onReset: () => void; selectedMode: "public" | "private"; onModeChange: (value: "public" | "private") => void; labels: Map<WidgetId, string>; buckets: Array<{ key: string; label: string }>; compareModes: string[]; saving: boolean; dirty: boolean; canSave: boolean; saveNotice: string | null; allowPublicMode: boolean; chartItems: Array<{ metric_id: string; label: string; unit: string }>; heroItems: Array<{ metric_id: string; label: string; unit: string }>; }) {
|
||||||
const widgets = toWidgetIds(value.widgets);
|
const widgets = toWidgetIds(value.widgets);
|
||||||
return <div className="d-flex flex-column gap-3"><div className="card pv-card"><div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2"><div><h3 className="card-title">{language === "en" ? "1. Kiosk type and ranges" : "1. Typ kiosku i zakresy"}</h3><div className="text-secondary small">{allowPublicMode ? (language === "en" ? "Choose the kiosk audience first, then tune the content." : "Najpierw wybierz odbiorcę kiosku, potem dopracuj zawartość.") : (language === "en" ? "These settings affect only your private kiosk after login." : "Te ustawienia dotyczą tylko Twojego prywatnego kiosku po zalogowaniu.")}</div></div><span className={`badge ${dirty ? "bg-yellow-lt text-yellow" : "bg-green-lt text-green"}`}>{dirty ? (language === "en" ? "Unsaved changes" : "Niezapisane zmiany") : (language === "en" ? "Up to date" : "Aktualne")}</span></div><div className="card-body d-flex flex-column gap-4">{allowPublicMode ? <div className="row g-3"><div className="col-12 col-md-6"><KioskModeCard active={selectedMode === "private"} title={language === "en" ? "Private kiosk" : "Kiosk prywatny"} subtitle={language === "en" ? "For logged-in users. Uses private ranges and layout." : "Dla zalogowanych użytkowników. Korzysta z prywatnych zakresów i układu."} onClick={() => onModeChange("private")} /></div><div className="col-12 col-md-6"><KioskModeCard active={selectedMode === "public"} title={language === "en" ? "Public kiosk" : "Kiosk publiczny"} subtitle={language === "en" ? "Read-only screen without login. Safer for TV or hallway display." : "Ekran tylko do odczytu bez logowania. Dobry na TV lub monitor ogólnodostępny."} onClick={() => onModeChange("public")} /></div></div> : <div className="alert alert-info mb-0">{language === "en" ? "You can manage your own private kiosk layout and ranges here." : "Tutaj ustawisz własny prywatny kiosk: układ, zakresy i porównanie."}</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">{language === "en" ? "Aggregation" : "Agregacja"}</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}>{translateBucket(language, item.key)}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Comparison on kiosk" : "Porównanie w kiosku"}</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="row g-3"><StatusStat label={language === "en" ? "Visible sections" : "Widoczne sekcje"} value={String(widgets.length)} /><StatusStat label={language === "en" ? "Live" : "Live"} value={value.realtime_range} /><StatusStat label={language === "en" ? "Analytics" : "Analityka"} value={value.analytics_range} /><StatusStat label={language === "en" ? "Bucket" : "Bucket"} value={translateBucket(language, value.analytics_bucket)} /><StatusStat label={language === "en" ? "Comparison" : "Porównanie"} value={translateCompareMode(language, value.compare_mode)} /><StatusStat label={language === "en" ? "Save state" : "Stan zapisu"} value={saveNotice || (dirty ? (language === "en" ? "Waiting to save" : "Czeka na zapis") : (language === "en" ? "Saved" : "Zapisane"))} /></div><div className="d-flex flex-wrap justify-content-end gap-2 border-top pt-3"><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={Line truncated
|
const chartGroups = sanitizeKioskChartGroups(value.chart_groups, chartItems);
|
||||||
|
const heroMetricIds = sanitizeKioskHeroMetrics(value.hero_metric_ids, heroItems);
|
||||||
|
return <div className="d-flex flex-column gap-3"><div className="card pv-card"><div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2"><div><h3 className="card-title">{language === "en" ? "1. Kiosk type and ranges" : "1. Typ kiosku i zakresy"}</h3><div className="text-secondary small">{allowPublicMode ? (language === "en" ? "Choose the kiosk audience first, then tune the content." : "Najpierw wybierz odbiorcę kiosku, potem dopracuj zawartość.") : (language === "en" ? "These settings affect only your private kiosk after login and are stored per user." : "Te ustawienia dotyczą tylko Twojego prywatnego kiosku po zalogowaniu i zapisują się osobno dla użytkownika.")}</div></div><span className={`badge ${dirty ? "bg-yellow-lt text-yellow" : "bg-green-lt text-green"}`}>{dirty ? (language === "en" ? "Unsaved changes" : "Niezapisane zmiany") : (language === "en" ? "Up to date" : "Aktualne")}</span></div><div className="card-body d-flex flex-column gap-4">{allowPublicMode ? <div className="row g-3"><div className="col-12 col-md-6"><KioskModeCard active={selectedMode === "private"} title={language === "en" ? "Private kiosk" : "Kiosk prywatny"} subtitle={language === "en" ? "Each logged-in user keeps their own private kiosk layout and charts." : "Każdy zalogowany użytkownik ma własny prywatny układ i wykresy kiosku."} onClick={() => onModeChange("private")} /></div><div className="col-12 col-md-6"><KioskModeCard active={selectedMode === "public"} title={language === "en" ? "Public kiosk" : "Kiosk publiczny"} subtitle={language === "en" ? "Read-only screen without login. Safer for TV or hallway display." : "Ekran tylko do odczytu bez logowania. Dobry na TV lub monitor ogólnodostępny."} onClick={() => onModeChange("public")} /></div></div> : <div className="alert alert-info mb-0">{language === "en" ? "You can manage your own private kiosk layout and ranges here." : "Tutaj ustawisz własny prywatny kiosk: układ, zakresy i porównanie."}</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">{language === "en" ? "Aggregation" : "Agregacja"}</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}>{translateBucket(language, item.key)}</option>)}</select></div><div className="col-12 col-md-6"><label className="form-label small mb-1">{language === "en" ? "Comparison on kiosk" : "Porównanie w kiosku"}</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="row g-3"><StatusStat label={language === "en" ? "Visible sections" : "Widoczne sekcje"} value={String(widgets.length)} /><StatusStat label={language === "en" ? "Hero cards" : "Karty hero"} value={String(heroMetricIds.length)} /><StatusStat label={language === "en" ? "Charts in kiosk" : "Wykresy w kiosku"} value={String(chartGroups.length)} /><StatusStat label={language === "en" ? "Live" : "Live"} value={value.realtime_range} /><StatusStat label={language === "en" ? "Analytics" : "Analityka"} value={value.analytics_range} /><StatusStat label={language === "en" ? "Bucket" : "Bucket"} value={translateBucket(language, value.analytics_bucket)} /><StatusStat label={language === "en" ? "Save state" : "Stan zapisu"} value={saveNotice || (dirty ? (language === "en" ? "Waiting to save" : "Czeka na zapis") : (language === "en" ? "Saved" : "Zapisane"))} /></div><div className="d-flex flex-wrap justify-content-end gap-2 border-top pt-3"><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.Line truncated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function KioskHeroPanel({ language, items, selected, onChange, heroEnabled }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; selected: string[]; onChange: (value: string[]) => void; heroEnabled: boolean; }) {
|
||||||
|
const safeSelected = sanitizeKioskHeroMetrics(selected, items);
|
||||||
|
const toggle = (metricId: string) => {
|
||||||
|
const next = safeSelected.includes(metricId)
|
||||||
|
? (safeSelected.length > 1 ? safeSelected.filter((item) => item !== metricId) : safeSelected)
|
||||||
|
: [...safeSelected, metricId];
|
||||||
|
onChange(sanitizeKioskHeroMetrics(next, items));
|
||||||
|
};
|
||||||
|
return <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" ? "3. Hero card content" : "3. Zawartość kart hero"}</h3><div className="text-secondary small">{language === "en" ? "Choose exactly which KPIs should appear in the kiosk hero section." : "Wybierz dokładnie, które KPI mają pojawiać się w sekcji hero kiosku."}</div></div><span className="badge bg-primary-lt text-primary">{safeSelected.length}</span></div><div className="card-body d-flex flex-column gap-3">{!heroEnabled ? <div className="alert alert-warning py-2 mb-0">{language === "en" ? "Hero section is hidden in kiosk layout. Enable Hero section above to display these cards." : "Sekcja hero jest ukryta w układzie kiosku. Włącz sekcję Hero powyżej, aby pokazać te karty."}</div> : null}<div className="d-flex flex-wrap gap-2">{items.map((item) => <button key={`hero-${item.metric_id}`} className={`btn btn-sm ${safeSelected.includes(item.metric_id) ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => toggle(item.metric_id)}>{item.label}</button>)}</div></div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KioskChartGroupsPanel({ language, items, groups, onChange, historyEnabled }: { language: Language; items: Array<{ metric_id: string; label: string; unit: string }>; groups: KioskChartGroup[]; onChange: (value: KioskChartGroup[]) => void; historyEnabled: boolean; }) {
|
||||||
|
const safeGroups = sanitizeKioskChartGroups(groups, items);
|
||||||
|
const updateGroup = (groupId: string, updater: (group: KioskChartGroup) => KioskChartGroup) => onChange(safeGroups.map((group) => group.id === groupId ? updater(group) : group));
|
||||||
|
const move = (groupId: string, direction: -1 | 1) => {
|
||||||
|
const index = safeGroups.findIndex((group) => group.id === groupId);
|
||||||
|
if (index === -1) return;
|
||||||
|
const target = index + direction;
|
||||||
|
if (target < 0 || target >= safeGroups.length) return;
|
||||||
|
const next = [...safeGroups];
|
||||||
|
[next[index], next[target]] = [next[target], next[index]];
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
const addGroup = () => {
|
||||||
|
const fallbackMetricIds = items.slice(0, Math.min(items.length, 3)).map((item) => item.metric_id);
|
||||||
|
if (!fallbackMetricIds.length) return;
|
||||||
|
onChange([...safeGroups, { id: `chart_${Date.now()}`, title: null, metric_ids: [fallbackMetricIds[0]] }]);
|
||||||
|
};
|
||||||
|
const removeGroup = (groupId: string) => {
|
||||||
|
const next = safeGroups.filter((group) => group.id !== groupId);
|
||||||
|
onChange(next.length ? next : sanitizeKioskChartGroups([], items));
|
||||||
|
};
|
||||||
|
const toggleMetric = (groupId: string, metricId: string) => updateGroup(groupId, (group) => ({
|
||||||
|
...group,
|
||||||
|
metric_ids: group.metric_ids.includes(metricId)
|
||||||
|
? (group.metric_ids.length > 1 ? group.metric_ids.filter((item) => item !== metricId) : group.metric_ids)
|
||||||
|
: [...group.metric_ids, metricId],
|
||||||
|
}));
|
||||||
|
return <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" ? "4. Kiosk chart content" : "4. Zawartość wykresów kiosku"}</h3><div className="text-secondary small">{language === "en" ? "Create one combined chart or split kiosk into separate AC, DC, temperature and other charts." : "Ułóż jeden wykres zbiorczy albo kilka osobnych kart, np. AC, DC i temperatura."}</div></div><div className="btn-list"><button className="btn btn-outline-secondary btn-sm" onClick={() => onChange(buildPresetKioskChartGroups(language, items, "single"))}>{language === "en" ? "One combined chart" : "Jeden wykres"}</button><button className="btn btn-outline-secondary btn-sm" onClick={() => onChange(buildPresetKioskChartGroups(language, items, "split"))}>{language === "en" ? "Split AC / DC / temp" : "Podziel AC / DC / temp"}</button><button className="btn btn-primary btn-sm" onClick={addGroup} disabled={!items.length}>{language === "en" ? "Add chart" : "Dodaj wykres"}</button></div></div><div className="card-body d-flex flex-column gap-3">{!historyEnabled ? <div className="alert alert-warning py-2 mb-0">{language === "en" ? "The chart section is hidden in kiosk layout. Enable History section above to show these charts." : "Sekcja wykresów jest ukryta w układzie kiosku. Włącz sekcję wykresu powyżej, aby je pokazać."}</div> : null}{safeGroups.map((group, index) => <div key={group.id} className="border rounded-3 p-3 d-flex flex-column gap-3"><div className="d-flex flex-wrap justify-content-between align-items-center gap-2"><div className="d-flex flex-column gap-2 flex-grow-1"><div className="fw-semibold">{language === "en" ? `Chart ${index + 1}` : `Wykres ${index + 1}`}</div><input className="form-control" placeholder={language === "en" ? "Optional chart title" : "Opcjonalny tytuł wykresu"} value={group.title ?? ""} onChange={(event) => updateGroup(group.id, (current) => ({ ...current, title: event.target.value || null }))} /></div><div className="btn-list"><span className="badge bg-primary-lt text-primary">{group.metric_ids.length}</span><button className="btn btn-sm btn-outline-secondary" onClick={() => move(group.id, -1)}>{language === "en" ? "Up" : "Wyżej"}</button><button className="btn btn-sm btn-outline-secondary" onClick={() => move(group.id, 1)}>{language === "en" ? "Down" : "Niżej"}</button><button className="btn btn-sm btn-outline-danger" onClick={() => removeGroup(group.id)}><IconX size={16} /></button></div></div><div className="d-flex flex-wrap gap-2">{items.map((item) => <button key={`${group.id}-${item.metric_id}`} className={`btn btn-sm ${group.metric_ids.includes(item.metric_id) ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => toggleMetric(group.id, item.metric_id)}>{item.label} <span className="opacity-75">({item.unit})</span></button>)}</div></div>)}{!items.length ? <div className="text-secondary">{language === "en" ? "No realtime metrics available for kiosk charts." : "Brak dostępnych metryk live do wykresów kiosku."}</div> : null}</div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function KioskLinkPanel({ language, publicKioskUrl, privateKioskUrl, publicSettings, privateSettings, showPublicLink }: { language: Language; publicKioskUrl: string; privateKioskUrl: string; publicSettings: KioskSettingsPayload; privateSettings: KioskSettingsPayload; showPublicLink: boolean }) { const [copied, setCopied] = useState<"public" | "private" | null>(null); const copy = async (value: string, mode: "public" | "private") => { await navigator.clipboard.writeText(value); setCopied(mode); window.setTimeout(() => setCopied(null), 1500); }; return <div className="d-flex flex-column gap-3">{showPublicLink ? <div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "2. Share kiosk" : "2. Udostępnianie kiosku"}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="border rounded-3 p-3"><div className="d-flex justify-content-between align-items-start gap-2 mb-2"><div><div className="fw-semibold">{language === "en" ? "Public kiosk" : "Kiosk publiczny"}</div><div className="text-secondary small">{language === "en" ? "No login required." : "Nie wymaga logowania."}</div></div><span className="badge bg-green-lt text-green">TV</span></div><input className="form-control mb-2" value={publicKioskUrl} readOnly /><div className="small text-secondary mb-3">{language === "en" ? "Live" : "Live"}: {publicSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {publicSettings.analytics_range}</div><button className="btn btn-primary w-100" onClick={() => copy(publicKioskUrl, "public")}>{copied === "public" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy public link" : "Kopiuj link publiczny")}</button></div><div className="border rounded-3 p-3"><div className="d-flex justify-content-between align-items-start gap-2 mb-2"><div><div className="fw-semibold">{language === "en" ? "Private kiosk" : "Kiosk prywatny"}</div><div className="text-secondary small">{language === "en" ? "Requires login." : "Wymaga logowania."}</div></div><span className="badge bg-primary-lt text-primary">{language === "en" ? "Secure" : "Bezpieczny"}</span></div><input className="form-control mb-2" value={privateKioskUrl} readOnly /><div className="small text-secondary mb-3">{language === "en" ? "Live" : "Live"}: {privateSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {privateSettings.analytics_range}</div><button className="btn btn-outline-primary w-100" onClick={() => copy(privateKioskUrl, "private")}>{copied === "private" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy private link" : "Kopiuj link prywatny")}</button></div></div></div> : <div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Private kiosk link" : "Link do prywatnego kiosku"}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="border rounded-3 p-3"><div className="d-flex justify-content-between align-items-start gap-2 mb-2"><div><div className="fw-semibold">{language === "en" ? "Your private kiosk" : "Twój prywatny kiosk"}</div><div className="text-secondary small">{language === "en" ? "Requires login and uses your saved kiosk layout." : "Wymaga logowania i używa Twojego zapisanego układu kiosku."}</div></div><span className="badge bg-primary-lt text-primary">{language === "en" ? "User" : "User"}</span></div><input className="form-control mb-2" value={privateKioskUrl} readOnly /><div className="small text-secondary mb-3">{language === "en" ? "Live" : "Live"}: {privateSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {privateSettings.analytics_range}</div><button className="btn btn-primary w-100" onClick={() => copy(privateKioskUrl, "private")}>{copied === "private" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy private link" : "Kopiuj link prywatny")}</button></div></div></div>}<div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Quick guidance" : "Szybka wskazówka"}</h3></div><div className="card-body"><div className="text-secondary small">{showPublicLink ? (language === "en" ? "Public kiosk is best for shared screens. Private kiosk is better when you want full data access after login." : "Publiczny kiosk sprawdzi się na współdzielonych ekranach. Prywatny kiosk jest lepszy, gdy po zalogowaniu ma być dostęp do pełnych danych.") : (language === "en" ? "Private kiosk keeps your own layout and ranges separate from the admin configuration." : "Prywatny kiosk zachowuje Twój własny układ i zakresy oddzielnie od konfiguracji administratora.")}</div></div></div></div>; }
|
function KioskLinkPanel({ language, publicKioskUrl, privateKioskUrl, publicSettings, privateSettings, showPublicLink }: { language: Language; publicKioskUrl: string; privateKioskUrl: string; publicSettings: KioskSettingsPayload; privateSettings: KioskSettingsPayload; showPublicLink: boolean }) { const [copied, setCopied] = useState<"public" | "private" | null>(null); const copy = async (value: string, mode: "public" | "private") => { await navigator.clipboard.writeText(value); setCopied(mode); window.setTimeout(() => setCopied(null), 1500); }; return <div className="d-flex flex-column gap-3">{showPublicLink ? <div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "2. Share kiosk" : "2. Udostępnianie kiosku"}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="border rounded-3 p-3"><div className="d-flex justify-content-between align-items-start gap-2 mb-2"><div><div className="fw-semibold">{language === "en" ? "Public kiosk" : "Kiosk publiczny"}</div><div className="text-secondary small">{language === "en" ? "No login required." : "Nie wymaga logowania."}</div></div><span className="badge bg-green-lt text-green">TV</span></div><input className="form-control mb-2" value={publicKioskUrl} readOnly /><div className="small text-secondary mb-3">{language === "en" ? "Live" : "Live"}: {publicSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {publicSettings.analytics_range}</div><button className="btn btn-primary w-100" onClick={() => copy(publicKioskUrl, "public")}>{copied === "public" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy public link" : "Kopiuj link publiczny")}</button></div><div className="border rounded-3 p-3"><div className="d-flex justify-content-between align-items-start gap-2 mb-2"><div><div className="fw-semibold">{language === "en" ? "Private kiosk" : "Kiosk prywatny"}</div><div className="text-secondary small">{language === "en" ? "Requires login." : "Wymaga logowania."}</div></div><span className="badge bg-primary-lt text-primary">{language === "en" ? "Secure" : "Bezpieczny"}</span></div><input className="form-control mb-2" value={privateKioskUrl} readOnly /><div className="small text-secondary mb-3">{language === "en" ? "Live" : "Live"}: {privateSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {privateSettings.analytics_range}</div><button className="btn btn-outline-primary w-100" onClick={() => copy(privateKioskUrl, "private")}>{copied === "private" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy private link" : "Kopiuj link prywatny")}</button></div></div></div> : <div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Private kiosk link" : "Link do prywatnego kiosku"}</h3></div><div className="card-body d-flex flex-column gap-3"><div className="border rounded-3 p-3"><div className="d-flex justify-content-between align-items-start gap-2 mb-2"><div><div className="fw-semibold">{language === "en" ? "Your private kiosk" : "Twój prywatny kiosk"}</div><div className="text-secondary small">{language === "en" ? "Requires login and uses your saved kiosk layout." : "Wymaga logowania i używa Twojego zapisanego układu kiosku."}</div></div><span className="badge bg-primary-lt text-primary">{language === "en" ? "User" : "User"}</span></div><input className="form-control mb-2" value={privateKioskUrl} readOnly /><div className="small text-secondary mb-3">{language === "en" ? "Live" : "Live"}: {privateSettings.realtime_range} · {language === "en" ? "Analytics" : "Analityka"}: {privateSettings.analytics_range}</div><button className="btn btn-primary w-100" onClick={() => copy(privateKioskUrl, "private")}>{copied === "private" ? (language === "en" ? "Copied" : "Skopiowano") : (language === "en" ? "Copy private link" : "Kopiuj link prywatny")}</button></div></div></div>}<div className="card pv-card"><div className="card-header"><h3 className="card-title">{language === "en" ? "Quick guidance" : "Szybka wskazówka"}</h3></div><div className="card-body"><div className="text-secondary small">{showPublicLink ? (language === "en" ? "Public kiosk is best for shared screens. Private kiosk is better when you want full data access after login." : "Publiczny kiosk sprawdzi się na współdzielonych ekranach. Prywatny kiosk jest lepszy, gdy po zalogowaniu ma być dostęp do pełnych danych.") : (language === "en" ? "Private kiosk keeps your own layout and ranges separate from the admin configuration." : "Prywatny kiosk zachowuje Twój własny układ i zakresy oddzielnie od konfiguracji administratora.")}</div></div></div></div>; }
|
||||||
function ActionTile({ active, icon, title, subtitle, onClick }: { active: boolean; icon: ReactNode; title: string; subtitle: string; onClick: () => void; }) { return <button className={`btn text-start w-100 h-100 p-3 ${active ? "btn-primary" : "btn-outline-secondary"}`} onClick={onClick}><div className="d-flex align-items-center gap-2 mb-2"><span>{icon}</span><span className="fw-semibold">{title}</span></div><div className={active ? "text-white text-opacity-75 small" : "text-secondary small"}>{subtitle}</div></button>; }
|
function ActionTile({ active, icon, title, subtitle, onClick }: { active: boolean; icon: ReactNode; title: string; subtitle?: string; onClick: () => void; }) { return <button className={`btn text-start w-100 h-100 p-3 ${active ? "btn-primary" : "btn-outline-secondary"}`} onClick={onClick}><div className={`d-flex align-items-center gap-2 ${subtitle ? "mb-2" : ""}`}><span>{icon}</span><span className="fw-semibold">{title}</span></div>{subtitle ? <div className={active ? "text-white text-opacity-75 small" : "text-secondary small"}>{subtitle}</div> : null}</button>; }
|
||||||
function AppearancePanel({ language, setLanguage, theme, setTheme, viewMode, setViewMode, userName }: { language: Language; setLanguage: (value: Language) => void; theme: ThemeMode; setTheme: (value: ThemeMode) => void; viewMode: ViewMode; setViewMode: (value: ViewMode) => void; userName: string; }) { return <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" ? "Quick settings" : "Szybkie ustawienia"}</h3><div className="text-secondary small">{language === "en" ? "Most useful display options in one place." : "Najważniejsze opcje ekranu 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="row g-2"><div className="col-12 col-sm-6"><ActionTile active={theme === "light"} icon={<IconSun size={18} />} title={t(language, "light")} subtitle={language === "en" ? "Bright interface for office work." : "Jasny interfejs do pracy w dzień."} onClick={() => setTheme("light")} /></div><div className="col-12 col-sm-6"><ActionTile active={theme === "dark"} icon={<IconMoon size={18} />} title={t(language, "dark")} subtitle={language === "en" ? "Better contrast for dashboards and TV." : "Lepszy kontrast dla dashboardów i TV."} onClick={() => setTheme("dark")} /></div></div></div><div className="border-top pt-3"><div className="fw-semibold mb-2">{t(language, "viewMode")}</div><div className="row g-2"><div className="col-12 col-sm-6"><ActionTile active={viewMode === "normal"} icon={<IconSettings size={18} />} title={t(language, "normalMode")} subtitle={language === "en" ? "Full navigation and controls." : "Pełna nawigacja i sterowanie."} onClick={() => setViewMode("normal")} /></div><div className="col-12 col-sm-6"><ActionTile active={viewMode === "kiosk"} icon={<IconDeviceDesktop size={18} />} title={t(language, "kioskMode")} subtitle={language === "en" ? "Simplified screen for wall monitor." : "Uproszczony widok na ekran ścienny."} onClick={() => setViewMode("kiosk")} /></div></div></div><div className="border-top pt-3"><div className="fw-semibold mb-2">{language === "en" ? "Language" : "Język"}</div><div className="row g-2"><div className="col-12 col-sm-6"><ActionTile active={language === "pl"} icon={<IconLanguage size={18} />} title="Polski" subtitle={language === "en" ? "Switch interface to Polish." : "Przełącz interfejs na polski."} onClick={() => setLanguage("pl")} /></div><div className="col-12 col-sm-6"><ActionTile active={language === "en"} icon={<IconLanguage size={18} />} title="English" subtitle={language === "en" ? "Switch interface to English." : "Przełącz interfejs na angielski."} onClick={() => setLanguage("en")} /></div></div></div></div></div>; }
|
function AppearancePanel({ language, setLanguage, theme, setTheme, viewMode, setViewMode, userName }: { language: Language; setLanguage: (value: Language) => void; theme: ThemeMode; setTheme: (value: ThemeMode) => void; viewMode: ViewMode; setViewMode: (value: ViewMode) => void; userName: string; }) { return <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" ? "Quick settings" : "Szybkie ustawienia"}</h3><div className="text-secondary small">{language === "en" ? "Most useful display options in one place." : "Najważniejsze opcje ekranu 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="row g-2"><div className="col-12 col-sm-6"><ActionTile active={theme === "light"} icon={<IconSun size={18} />} title={t(language, "light")} onClick={() => setTheme("light")} /></div><div className="col-12 col-sm-6"><ActionTile active={theme === "dark"} icon={<IconMoon size={18} />} title={t(language, "dark")} onClick={() => setTheme("dark")} /></div></div></div><div className="border-top pt-3"><div className="fw-semibold mb-2">{t(language, "viewMode")}</div><div className="row g-2"><div className="col-12 col-sm-6"><ActionTile active={viewMode === "normal"} icon={<IconSettings size={18} />} title={t(language, "normalMode")} onClick={() => setViewMode("normal")} /></div><div className="col-12 col-sm-6"><ActionTile active={viewMode === "kiosk"} icon={<IconDeviceDesktop size={18} />} title={t(language, "kioskMode")} onClick={() => setViewMode("kiosk")} /></div></div></div><div className="border-top pt-3"><div className="fw-semibold mb-2">{language === "en" ? "Language" : "Język"}</div><div className="row g-2"><div className="col-12 col-sm-6"><ActionTile active={language === "pl"} icon={<IconLanguage size={18} />} title="Polski" onClick={() => setLanguage("pl")} /></div><div className="col-12 col-sm-6"><ActionTile active={language === "en"} icon={<IconLanguage size={18} />} title="English" onClick={() => setLanguage("en")} /></div></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 d-flex flex-wrap align-items-center justify-content-between gap-2"><div><h3 className="card-title">{title}</h3><div className="text-secondary small">{language === "en" ? "Select what should appear on charts." : "Wybierz co ma pojawiać się na wykresach."}</div></div><span className="badge bg-primary-lt text-primary">{selected.length}</span></div><div className="card-body"><div className="d-flex flex-wrap gap-2">{items.map((item) => <button key={item.metric_id} className={`btn ${selected.includes(item.metric_id) ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => toggle(item.metric_id)}>{item.label} <span className="opacity-75">({item.unit})</span></button>)}</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 d-flex flex-wrap align-items-center justify-content-between gap-2"><div><h3 className="card-title">{title}</h3><div className="text-secondary small">{language === "en" ? "Select what should appear on charts." : "Wybierz co ma pojawiać się na wykresach."}</div></div><span className="badge bg-primary-lt text-primary">{selected.length}</span></div><div className="card-body"><div className="d-flex flex-wrap gap-2">{items.map((item) => <button key={item.metric_id} className={`btn ${selected.includes(item.metric_id) ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => toggle(item.metric_id)}>{item.label} <span className="opacity-75">({item.unit})</span></button>)}</div></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 wykresów"} items={items} selected={selected} onChange={onChange} />; }
|
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 wykresów"} items={items} selected={selected} onChange={onChange} />; }
|
||||||
|
function LiveSectionVisibilityPanel({ language, selected, onChange }: { language: Language; selected: LiveWidgetId[]; onChange: (value: LiveWidgetId[]) => void; }) {
|
||||||
|
const items: Array<{ id: LiveWidgetId; label: string }> = [
|
||||||
|
{ id: "hero", label: language === "en" ? "Hero" : "Hero" },
|
||||||
|
{ id: "quickMetrics", label: t(language, "quickMetrics") },
|
||||||
|
{ id: "history", label: t(language, "chartPowerHistory") },
|
||||||
|
{ id: "status", label: t(language, "systemStatus") },
|
||||||
|
{ id: "strings", label: t(language, "strings") },
|
||||||
|
];
|
||||||
|
const toggle = (id: LiveWidgetId) => {
|
||||||
|
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">{language === "en" ? "LIVE section visibility" : "Widoczność sekcji LIVE"}</h3><div className="text-secondary small">{language === "en" ? "Show or hide whole blocks on the live dashboard." : "Włączaj i wyłączaj całe bloki na dashboardzie Live."}</div></div></div><div className="card-body d-flex flex-wrap gap-2">{items.map((item) => <button key={item.id} className={`btn ${selected.includes(item.id) ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => toggle(item.id)}>{item.label}</button>)}</div></div>;
|
||||||
|
}
|
||||||
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] }); }; const Section = ({ target, title }: { target: BlockTarget; title: string }) => <div className="border rounded-3 p-3 h-100"><div className="d-flex justify-content-between align-items-center mb-3"><div className="fw-semibold">{title}</div><span className="badge bg-primary-lt text-primary">{config[target].length}</span></div><div className="d-flex flex-wrap gap-2">{items.map((item) => <button key={`${target}-${item.metric_id}`} className={`btn btn-sm ${config[target].includes(item.metric_id) ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => toggle(target, item.metric_id)}>{item.label}</button>)}</div></div>; return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{language === "en" ? "Metric visibility in blocks" : "Widoczność metryk w blokach"}</h3><div className="text-secondary small">{language === "en" ? "Control which KPIs appear in hero and quick sections." : "Steruj tym, które KPI pojawiają się w sekcji hero i szybkich metrykach."}</div></div></div><div className="card-body"><div className="row g-3"><div className="col-12"><Section target="hero" title="Hero metrics" /></div><div className="col-12"><Section target="quick" title="Quick metrics" /></div></div></div></div>; }
|
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] }); }; const Section = ({ target, title }: { target: BlockTarget; title: string }) => <div className="border rounded-3 p-3 h-100"><div className="d-flex justify-content-between align-items-center mb-3"><div className="fw-semibold">{title}</div><span className="badge bg-primary-lt text-primary">{config[target].length}</span></div><div className="d-flex flex-wrap gap-2">{items.map((item) => <button key={`${target}-${item.metric_id}`} className={`btn btn-sm ${config[target].includes(item.metric_id) ? "btn-primary" : "btn-outline-secondary"}`} onClick={() => toggle(target, item.metric_id)}>{item.label}</button>)}</div></div>; return <div className="card pv-card h-100"><div className="card-header"><div><h3 className="card-title">{language === "en" ? "Metric visibility in blocks" : "Widoczność metryk w blokach"}</h3><div className="text-secondary small">{language === "en" ? "Control which KPIs appear in hero and quick sections." : "Steruj tym, które KPI pojawiają się w sekcji hero i szybkich metrykach."}</div></div></div><div className="card-body"><div className="row g-3"><div className="col-12"><Section target="hero" title="Hero metrics" /></div><div className="col-12"><Section target="quick" title="Quick metrics" /></div></div></div></div>; }
|
||||||
function DiagnosticBadge({ ok, label }: { ok: boolean; label: string }) { return <span className={`badge ${ok ? "bg-green-lt text-green" : "bg-red-lt text-red"}`}>{label}</span>; }
|
function DiagnosticBadge({ ok, label }: { ok: boolean; label: string }) { return <span className={`badge ${ok ? "bg-green-lt text-green" : "bg-red-lt text-red"}`}>{label}</span>; }
|
||||||
function DiagnosticPanel({ language, locale, data, loading, onRefresh }: { language: Language; locale: string; data?: DiagnosticsPayload; loading: boolean; onRefresh: () => void; }) { 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" ? "Diagnostics" : "Diagnostyka"}</h3><div className="text-secondary small">{language === "en" ? "API, InfluxDB and application status." : "Stan API, InfluxDB i aplikacji."}</div></div><button className="btn btn-outline-secondary btn-sm" onClick={onRefresh}><IconRefresh size={16} className="me-1" />{language === "en" ? "Refresh" : "Odśwież"}</button></div><div className="card-body d-flex flex-column gap-3">{loading && !data ? <div className="text-secondary">{t(language, "loading")}…</div> : null}{data ? <><div className="row g-3"><StatusStat label="API" value={data.api.status} /><StatusStat label="InfluxDB" value={data.influx.status} /><StatusStat label={language === "en" ? "Uptime" : "Czas pracy"} value={formatDurationShort(data.app.uptime_seconds, locale)} /><StatusStat label={language === "en" ? "Version" : "Wersja"} value={data.app.version} /><StatusStat label="PID" value={String(data.app.pid)} /><StatusStat label="Python" value={data.app.python_version} /></div><div className="row g-3"><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100 d-flex flex-column gap-2"><div className="d-flex justify-content-between align-items-center"><div className="fw-semibold">{language === "en" ? "InfluxDB connection" : "Połączenie z InfluxDB"}</div><DiagnosticBadge ok={data.influx.reachable} label={data.influx.reachable ? (language === "en" ? "Connected" : "Połączono") : (language === "en" ? "Error" : "Błąd")} /></div><div className="small text-secondary">URL</div><div className="fw-medium">{data.influx.url}</div><div className="small text-secondary">{language === "en" ? "Database" : "Baza"}</div><div className="fw-medium">{data.influx.database}</div><div className="small text-secondary">{language === "en" ? "User" : "Użytkownik"}</div><div className="fw-medium">{data.influx.username_masked || "—"}</div><div className="small text-secondary">{language === "en" ? "Timeout / SSL" : "Timeout / SSL"}</div><div className="fw-medium">{data.influx.timeout_seconds}s / {data.influx.verify_ssl ? "verify" : "no-verify"}</div>{data.influx.error ? <div className="alert alert-danger py-2 mb-0">{data.influx.error}</div> : null}</div></div><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100 d-flex flex-column gap-2"><div className="d-flex justify-content-between align-items-center"><div className="fw-semibold">{language === "en" ? "Application details" : "Szczegóły aplikacji"}</div><DiagnosticBadge ok={data.api.status === "ok"} label={data.api.status} /></div><div className="small text-secondary">{language === "en" ? "API prefix" : "Prefix API"}</div><div className="fw-medium">{data.api.prefix}</div><div className="small text-secondary">{language === "en" ? "Started at" : "Uruchomiono"}</div><div className="fw-medium">{formatDateTime(data.app.started_at, locale)}</div><div className="small text-secondary">{language === "en" ? "Timezone" : "Strefa czasu"}</div><div className="fw-medium">{data.app.timezone}</div><div className="small text-secondary">{language === "en" ? "SQLite" : "SQLite"}</div><div className="fw-medium text-break">{data.storage.sqlite_path}</div><div className="small text-secondary">{language === "en" ? "History sync" : "Synchronizacja historii"}</div><div className="fw-medium">{data.storage.historical_import_enabled ? (language === "en" ? "Enabled" : "Włączona") : (language === "en" ? "Disabled" : "Wyłączona")} · auto: {data.storage.auto_sync_enabled ? "on" : "off"} · chunk: {data.storage.default_chunk_days}</div></div></div></div></> : <div className="text-secondary">{language === "en" ? "No diagnostics data." : "Brak danych diagnostycznych."}</div>}</div></div>; }
|
function DiagnosticPanel({ language, locale, data, loading, onRefresh }: { language: Language; locale: string; data?: DiagnosticsPayload; loading: boolean; onRefresh: () => void; }) { 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" ? "Diagnostics" : "Diagnostyka"}</h3><div className="text-secondary small">{language === "en" ? "API, InfluxDB and application status." : "Stan API, InfluxDB i aplikacji."}</div></div><button className="btn btn-outline-secondary btn-sm" onClick={onRefresh}><IconRefresh size={16} className="me-1" />{language === "en" ? "Refresh" : "Odśwież"}</button></div><div className="card-body d-flex flex-column gap-3">{loading && !data ? <div className="text-secondary">{t(language, "loading")}…</div> : null}{data ? <><div className="row g-3"><StatusStat label="API" value={data.api.status} /><StatusStat label="InfluxDB" value={data.influx.status} /><StatusStat label={language === "en" ? "Uptime" : "Czas pracy"} value={formatDurationShort(data.app.uptime_seconds, locale)} /><StatusStat label={language === "en" ? "Version" : "Wersja"} value={data.app.version} /><StatusStat label="PID" value={String(data.app.pid)} /><StatusStat label="Python" value={data.app.python_version} /></div><div className="row g-3"><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100 d-flex flex-column gap-2"><div className="d-flex justify-content-between align-items-center"><div className="fw-semibold">{language === "en" ? "InfluxDB connection" : "Połączenie z InfluxDB"}</div><DiagnosticBadge ok={data.influx.reachable} label={data.influx.reachable ? (language === "en" ? "Connected" : "Połączono") : (language === "en" ? "Error" : "Błąd")} /></div><div className="small text-secondary">URL</div><div className="fw-medium">{data.influx.url}</div><div className="small text-secondary">{language === "en" ? "Database" : "Baza"}</div><div className="fw-medium">{data.influx.database}</div><div className="small text-secondary">{language === "en" ? "User" : "Użytkownik"}</div><div className="fw-medium">{data.influx.username_masked || "—"}</div><div className="small text-secondary">{language === "en" ? "Timeout / SSL" : "Timeout / SSL"}</div><div className="fw-medium">{data.influx.timeout_seconds}s / {data.influx.verify_ssl ? "verify" : "no-verify"}</div>{data.influx.error ? <div className="alert alert-danger py-2 mb-0">{data.influx.error}</div> : null}</div></div><div className="col-12 col-xl-6"><div className="border rounded-3 p-3 h-100 d-flex flex-column gap-2"><div className="d-flex justify-content-between align-items-center"><div className="fw-semibold">{language === "en" ? "Application details" : "Szczegóły aplikacji"}</div><DiagnosticBadge ok={data.api.status === "ok"} label={data.api.status} /></div><div className="small text-secondary">{language === "en" ? "API prefix" : "Prefix API"}</div><div className="fw-medium">{data.api.prefix}</div><div className="small text-secondary">{language === "en" ? "Started at" : "Uruchomiono"}</div><div className="fw-medium">{formatDateTime(data.app.started_at, locale)}</div><div className="small text-secondary">{language === "en" ? "Timezone" : "Strefa czasu"}</div><div className="fw-medium">{data.app.timezone}</div><div className="small text-secondary">{language === "en" ? "SQLite" : "SQLite"}</div><div className="fw-medium text-break">{data.storage.sqlite_path}</div><div className="small text-secondary">{language === "en" ? "History sync" : "Synchronizacja historii"}</div><div className="fw-medium">{data.storage.historical_import_enabled ? (language === "en" ? "Enabled" : "Włączona") : (language === "en" ? "Disabled" : "Wyłączona")} · auto: {data.storage.auto_sync_enabled ? "on" : "off"} · chunk: {data.storage.default_chunk_days}</div></div></div></div></> : <div className="text-secondary">{language === "en" ? "No diagnostics data." : "Brak danych diagnostycznych."}</div>}</div></div>; }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
AuthUsersPayload,
|
AuthUsersPayload,
|
||||||
DashboardConfig,
|
DashboardConfig,
|
||||||
DiagnosticsPayload,
|
DiagnosticsPayload,
|
||||||
|
KioskChartGroup,
|
||||||
KioskSettingsPayload,
|
KioskSettingsPayload,
|
||||||
DistributionPayload,
|
DistributionPayload,
|
||||||
HistoryPayload,
|
HistoryPayload,
|
||||||
@@ -68,9 +69,13 @@ async function demoResponse<T>(factory: () => T): Promise<T> {
|
|||||||
return clone(factory());
|
return clone(factory());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function demoKioskChartGroups(): KioskChartGroup[] {
|
||||||
|
return [{ id: "overview", title: null, metric_ids: ["ac_power", "dc_power_total", "inverter_temp"] }];
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
getConfig: () => (DEMO_MODE ? demoResponse(() => demoConfig) : request<DashboardConfig>("/dashboard/config")),
|
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: "today", 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"], hero_metric_ids: ["ac_power", "dc_power_total", "energy_today", "energy_total"], realtime_range: "today", analytics_range: "30d", analytics_bucket: "day", compare_mode: "none", chart_groups: demoKioskChartGroups() })) : 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) })),
|
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")),
|
getAuthStatus: () => (DEMO_MODE ? demoResponse(() => demoAuthStatus) : request<AuthStatus>("/auth/status")),
|
||||||
login: (username: string, password: string) =>
|
login: (username: string, password: string) =>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const demoConfig: DashboardConfig = {
|
|||||||
{ metric_id: "string_1_voltage", label: "Napiecie stringu DC1", entity_id: "sofarsolar_dc1_voltage", measurement: "V", unit: "V", 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_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: "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" },
|
{ metric_id: "inverter_temp", label: "Temperatura falownika", entity_id: "sofarsolar_temprature_inverter", measurement: "°C", unit: "°C", kind: "gauge" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,9 +77,9 @@ export const demoSnapshot = (): SnapshotPayload => ({
|
|||||||
hero_cards: [
|
hero_cards: [
|
||||||
{ metric_id: "ac_power", label: "Produkcja AC", value: 6840, unit: "W", accent: "emerald", subtitle: "Aktualna moc oddawana przez falownik" },
|
{ 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: "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: "string_1_power", label: "String DC1", value: 3450, unit: "W", accent: "emerald", subtitle: "Wschód" },
|
||||||
{ metric_id: "dc2_power", label: "String DC2", value: 3310, unit: "W", accent: "emerald", subtitle: "Zachod" },
|
{ metric_id: "string_2_power", label: "String DC2", value: 3310, unit: "W", accent: "emerald", subtitle: "Zachód" },
|
||||||
{ metric_id: "inverter_temperature", label: "Temp. falownika", value: 47.3, unit: "°C", accent: "rose", subtitle: "Live status termiczny" },
|
{ metric_id: "inverter_temp", label: "Temp. falownika", value: 47.3, unit: "°C", accent: "rose", subtitle: "Live status termiczny" },
|
||||||
],
|
],
|
||||||
kpis: {
|
kpis: {
|
||||||
energy_today: { metric_id: "energy_today", label: "Energia dzis", unit: "kWh", value: 31.8, precision: 2, kind: "counter", status: "ok" },
|
energy_today: { metric_id: "energy_today", label: "Energia dzis", unit: "kWh", value: 31.8, precision: 2, kind: "counter", status: "ok" },
|
||||||
@@ -94,7 +94,7 @@ export const demoSnapshot = (): SnapshotPayload => ({
|
|||||||
],
|
],
|
||||||
phases: [],
|
phases: [],
|
||||||
status: [
|
status: [
|
||||||
{ metric_id: "inverter_temperature", label: "Temperatura falownika", unit: "°C", value: 47.3, precision: 1, kind: "gauge", status: "ok" },
|
{ metric_id: "inverter_temp", 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" },
|
{ metric_id: "data_freshness", label: "Swiezosc danych", unit: "", value: "3 s temu", precision: 0, kind: "text", status: "ok" },
|
||||||
],
|
],
|
||||||
faults: [],
|
faults: [],
|
||||||
@@ -110,9 +110,9 @@ export const demoHistory: HistoryPayload = {
|
|||||||
end: isoAt(0),
|
end: isoAt(0),
|
||||||
series: [
|
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: "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: "string_1_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: "string_2_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]) },
|
{ metric_id: "inverter_temp", label: "Temp. falownika", unit: "°C", points: historyPoints([22, 24, 27, 31, 35, 39, 42, 45, 47.3, 46.8, 44.1, 41.2]) },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const messages = {
|
|||||||
settingsSubtitle: "Import archiwum, wygląd, kiosk, bezpieczeństwo",
|
settingsSubtitle: "Import archiwum, wygląd, kiosk, bezpieczeństwo",
|
||||||
noData: "Brak danych",
|
noData: "Brak danych",
|
||||||
noDataDescription: "Brak odpowiedzi z backendu lub InfluxDB.",
|
noDataDescription: "Brak odpowiedzi z backendu lub InfluxDB.",
|
||||||
chartPowerHistory: "Historia mocy i temperatury",
|
kioskCharts: "Wykresy",
|
||||||
chartProduction: "Produkcja",
|
chartProduction: "Produkcja",
|
||||||
chartProductionSubtitle: "Agregacja w wybranym bucketcie",
|
chartProductionSubtitle: "Agregacja w wybranym bucketcie",
|
||||||
chartComparison: "Porównanie okresów",
|
chartComparison: "Porównanie okresów",
|
||||||
@@ -141,7 +141,7 @@ const messages = {
|
|||||||
settingsSubtitle: "Archive import, appearance, kiosk, security",
|
settingsSubtitle: "Archive import, appearance, kiosk, security",
|
||||||
noData: "No data",
|
noData: "No data",
|
||||||
noDataDescription: "No response from backend or InfluxDB.",
|
noDataDescription: "No response from backend or InfluxDB.",
|
||||||
chartPowerHistory: "Power and temperature history",
|
kioskCharts: "Charts",
|
||||||
chartProduction: "Production",
|
chartProduction: "Production",
|
||||||
chartProductionSubtitle: "Aggregated by selected bucket",
|
chartProductionSubtitle: "Aggregated by selected bucket",
|
||||||
chartComparison: "Period comparison",
|
chartComparison: "Period comparison",
|
||||||
|
|||||||
@@ -263,13 +263,21 @@ export interface AuthUsersPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface KioskChartGroup {
|
||||||
|
id: string;
|
||||||
|
title?: string | null;
|
||||||
|
metric_ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface KioskSettingsPayload {
|
export interface KioskSettingsPayload {
|
||||||
mode: "public" | "private";
|
mode: "public" | "private";
|
||||||
widgets: string[];
|
widgets: string[];
|
||||||
|
hero_metric_ids: string[];
|
||||||
realtime_range: string;
|
realtime_range: string;
|
||||||
analytics_range: string;
|
analytics_range: string;
|
||||||
analytics_bucket: string;
|
analytics_bucket: string;
|
||||||
compare_mode: string;
|
compare_mode: string;
|
||||||
|
chart_groups: KioskChartGroup[];
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
updated_by?: string | null;
|
updated_by?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user