This commit is contained in:
Mateusz Gruszczyński committed 2026-03-24 15:49:06 +01:00
1 parent 55a3c1ab7b
commit 84fe898a74
9 files changed
+201 -40

No files matched your search

+1 -1
View File
@@ -79,7 +79,7 @@ class BucketPoint:
label: str label: str
start: datetime start: datetime
end: datetime end: datetime
value: float value: float | None
@dataclass @dataclass
+37 -6
View File
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
import os import os
import platform import platform
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request, session
from app.core_settings import get_settings from app.core_settings import get_settings
from app.services.capabilities import build_capabilities from app.services.capabilities import build_capabilities
@@ -19,6 +19,27 @@ dashboard_blueprint = Blueprint("dashboard", __name__)
_APP_STARTED_AT = datetime.now(timezone.utc) _APP_STARTED_AT = datetime.now(timezone.utc)
def _resolve_kiosk_mode(requested_mode: str, require_write_access: bool = False) -> tuple[str, str]:
normalized_mode = (requested_mode or "private").strip().lower()
auth_service = get_auth_service()
if normalized_mode == "public":
if require_write_access and auth_service.enabled and session.get("auth_role") != "admin":
raise PermissionError("Brak uprawnien do edycji publicznego kiosku")
return "public", "public"
if normalized_mode != "private":
raise ValueError("Mode musi byc jednym z: public, private")
if (not auth_service.enabled) or session.get("auth_role") == "admin":
return "private", "private"
username = session.get("auth_user")
if not username:
raise PermissionError("Authentication required")
return f"user:{username}", "private"
@dashboard_blueprint.get("/dashboard/config") @dashboard_blueprint.get("/dashboard/config")
def dashboard_config(): def dashboard_config():
settings = get_settings() settings = get_settings()
@@ -65,8 +86,12 @@ def dashboard_config():
def dashboard_kiosk_settings(): def dashboard_kiosk_settings():
requested_mode = request.args.get("mode") or ("public" if request.args.get("publicKiosk") == "1" else "private") requested_mode = request.args.get("mode") or ("public" if request.args.get("publicKiosk") == "1" else "private")
try: try:
payload = get_kiosk_settings_service().get(requested_mode) storage_mode, response_mode = _resolve_kiosk_mode(requested_mode)
payload = get_kiosk_settings_service().get(storage_mode)
payload["mode"] = response_mode
return jsonify(to_plain(payload)) return jsonify(to_plain(payload))
except PermissionError as exc:
return jsonify({"detail": str(exc)}), 403
except ValueError as exc: except ValueError as exc:
return jsonify({"detail": str(exc)}), 400 return jsonify({"detail": str(exc)}), 400
@@ -74,11 +99,11 @@ def dashboard_kiosk_settings():
@dashboard_blueprint.put("/dashboard/kiosk-settings") @dashboard_blueprint.put("/dashboard/kiosk-settings")
def update_dashboard_kiosk_settings(): def update_dashboard_kiosk_settings():
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
mode = payload.get("mode", "private") requested_mode = payload.get("mode", "private")
auth_service = get_auth_service()
try: try:
auth_service.require_admin() storage_mode, response_mode = _resolve_kiosk_mode(requested_mode, require_write_access=True)
updated = get_kiosk_settings_service().update_from_session(mode, payload) updated = get_kiosk_settings_service().update_from_session(storage_mode, payload)
updated["mode"] = response_mode
return jsonify(to_plain(updated)) return jsonify(to_plain(updated))
except PermissionError as exc: except PermissionError as exc:
return jsonify({"detail": str(exc)}), 403 return jsonify({"detail": str(exc)}), 403
@@ -88,6 +113,12 @@ def update_dashboard_kiosk_settings():
@dashboard_blueprint.get("/dashboard/diagnostics") @dashboard_blueprint.get("/dashboard/diagnostics")
def dashboard_diagnostics(): def dashboard_diagnostics():
auth_service = get_auth_service()
try:
auth_service.require_admin()
except PermissionError as exc:
return jsonify({"detail": str(exc)}), 403
settings = get_settings() settings = get_settings()
influx_diagnostics = InfluxHTTPService(settings).diagnose() influx_diagnostics = InfluxHTTPService(settings).diagnose()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
+18
View File
@@ -4,20 +4,30 @@ from datetime import date
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from app.services.auth import get_auth_service
from app.services.historical_sync import get_historical_sync_service from app.services.historical_sync import get_historical_sync_service
from app.utils.serialization import to_plain from app.utils.serialization import to_plain
historical_blueprint = Blueprint("historical", __name__) historical_blueprint = Blueprint("historical", __name__)
service = get_historical_sync_service() service = get_historical_sync_service()
auth_service = get_auth_service()
@historical_blueprint.get("/historical/status") @historical_blueprint.get("/historical/status")
def historical_status(): def historical_status():
try:
auth_service.require_admin()
except PermissionError as exc:
return jsonify({"detail": str(exc)}), 403
return jsonify(to_plain(service.status())) return jsonify(to_plain(service.status()))
@historical_blueprint.post("/historical/start") @historical_blueprint.post("/historical/start")
def historical_start(): def historical_start():
try:
auth_service.require_admin()
except PermissionError as exc:
return jsonify({"detail": str(exc)}), 403
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
try: try:
status = service.start( status = service.start(
@@ -35,6 +45,10 @@ def historical_start():
@historical_blueprint.post("/historical/sync-now") @historical_blueprint.post("/historical/sync-now")
def historical_sync_now(): def historical_sync_now():
try:
auth_service.require_admin()
except PermissionError as exc:
return jsonify({"detail": str(exc)}), 403
try: try:
status = service.start(auto=True) status = service.start(auto=True)
return jsonify(to_plain(status)) return jsonify(to_plain(status))
@@ -44,6 +58,10 @@ def historical_sync_now():
@historical_blueprint.post("/historical/cancel") @historical_blueprint.post("/historical/cancel")
def historical_cancel(): def historical_cancel():
try:
auth_service.require_admin()
except PermissionError as exc:
return jsonify({"detail": str(exc)}), 403
return jsonify(to_plain(service.cancel())) return jsonify(to_plain(service.cancel()))
+8 -7
View File
@@ -37,7 +37,7 @@ class AnalyticsService:
window = resolve_window(range_key=range_key, start=start, end=end) window = resolve_window(range_key=range_key, start=start, end=end)
current_days = self.energy.daily_records_for_window(window.start, window.end, persist_missing=True) current_days = self.energy.daily_records_for_window(window.start, window.end, persist_missing=True)
current = self.energy.bucketize_daily(current_days, bucket) current = self.energy.bucketize_daily(current_days, bucket)
total = round(sum(item.value for item in current), 2) total = round(sum((item.value or 0.0) for item in current), 2)
comparison = [] comparison = []
comparison_total = None comparison_total = None
@@ -52,7 +52,7 @@ class AnalyticsService:
compare_window = resolve_window(start=compare_start, end=compare_end) compare_window = resolve_window(start=compare_start, end=compare_end)
comparison_days = self.energy.daily_records_for_window(compare_window.start, compare_window.end, persist_missing=True) comparison_days = self.energy.daily_records_for_window(compare_window.start, compare_window.end, persist_missing=True)
comparison_series = self.energy.bucketize_daily(comparison_days, bucket) comparison_series = self.energy.bucketize_daily(comparison_days, bucket)
comparison_total_value = round(sum(point.value for point in comparison_series), 2) comparison_total_value = round(sum((point.value or 0.0) for point in comparison_series), 2)
comparisons.append({ comparisons.append({
"key": item.get("key") or f"custom_{index + 1}", "key": item.get("key") or f"custom_{index + 1}",
"label": item.get("label") or f"Custom {index + 1}", "label": item.get("label") or f"Custom {index + 1}",
@@ -70,7 +70,7 @@ class AnalyticsService:
compare_window = shift_window(window, compare_mode) compare_window = shift_window(window, compare_mode)
comparison_days = self.energy.daily_records_for_window(compare_window.start, compare_window.end, persist_missing=True) comparison_days = self.energy.daily_records_for_window(compare_window.start, compare_window.end, persist_missing=True)
comparison = self.energy.bucketize_daily(comparison_days, bucket) comparison = self.energy.bucketize_daily(comparison_days, bucket)
comparison_total = round(sum(item.value for item in comparison), 2) comparison_total = round(sum((item.value or 0.0) for item in comparison), 2)
comparison_delta_pct = compare_delta_pct(total, comparison_total) comparison_delta_pct = compare_delta_pct(total, comparison_total)
comparisons.append({ comparisons.append({
"key": compare_mode, "key": compare_mode,
@@ -82,8 +82,9 @@ class AnalyticsService:
"points": comparison, "points": comparison,
}) })
average_bucket = round(total / len(current), 2) if current else 0.0 populated_current = [item for item in current if item.value is not None]
best_bucket = max(current, key=lambda item: item.value, default=None) average_bucket = round(total / len(populated_current), 2) if populated_current else 0.0
best_bucket = max(populated_current, key=lambda item: item.value or 0.0, default=None)
return { return {
"unit": "kWh", "unit": "kWh",
@@ -121,7 +122,7 @@ class AnalyticsService:
) -> dict: ) -> dict:
payload = self.production(range_key=range_key, bucket=bucket, compare_mode="none", start=start, end=end) payload = self.production(range_key=range_key, bucket=bucket, compare_mode="none", start=start, end=end)
current = payload["current"] current = payload["current"]
total = round(sum(item.value for item in current), 2) total = round(sum((item.value or 0.0) for item in current), 2)
denominator = total or 1.0 denominator = total or 1.0
return { return {
"unit": payload["unit"], "unit": payload["unit"],
@@ -134,7 +135,7 @@ class AnalyticsService:
"share": round((item.value / denominator) * 100.0, 2), "share": round((item.value / denominator) * 100.0, 2),
} }
for item in current for item in current
if item.value > 0 if item.value is not None and item.value > 0
], ],
"meta": payload["meta"], "meta": payload["meta"],
} }
+4 -2
View File
@@ -104,7 +104,7 @@ class EnergyService:
return rows return rows
def bucketize_daily(self, records: list[DailyEnergyRecord], bucket: str) -> list[BucketPoint]: def bucketize_daily(self, records: list[DailyEnergyRecord], bucket: str) -> list[BucketPoint]:
grouped: dict[str, dict] = defaultdict(lambda: {"value": 0.0, "start": None, "end": None, "label": ""}) grouped: dict[str, dict] = defaultdict(lambda: {"value": 0.0, "start": None, "end": None, "label": "", "has_data": False})
for record in records: for record in records:
start = datetime.combine(record.day, time.min, tzinfo=self.tz) start = datetime.combine(record.day, time.min, tzinfo=self.tz)
@@ -137,7 +137,9 @@ class EnergyService:
current = grouped[key] current = grouped[key]
current["label"] = label current["label"] = label
if record.samples_count > 0:
current["value"] += record.energy_kwh current["value"] += record.energy_kwh
current["has_data"] = True
current["start"] = bucket_start if current["start"] is None else min(current["start"], bucket_start) current["start"] = bucket_start if current["start"] is None else min(current["start"], bucket_start)
current["end"] = bucket_end if current["end"] is None else max(current["end"], bucket_end) current["end"] = bucket_end if current["end"] is None else max(current["end"], bucket_end)
@@ -149,7 +151,7 @@ class EnergyService:
label=item["label"], label=item["label"],
start=item["start"], start=item["start"],
end=item["end"], end=item["end"],
value=round(item["value"], 2), value=round(item["value"], 2) if item["has_data"] else None,
) )
) )
return rows return rows
+5 -2
View File
@@ -9,6 +9,7 @@ from app.storage.kiosk_settings import SQLiteKioskSettingsRepository
VALID_MODES = {"public", "private"} VALID_MODES = {"public", "private"}
USER_MODE_PREFIX = "user:"
DEFAULT_WIDGETS = ["hero", "history", "strings", "status", "production", "comparison", "importStatus"] DEFAULT_WIDGETS = ["hero", "history", "strings", "status", "production", "comparison", "importStatus"]
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"}
@@ -66,9 +67,11 @@ class KioskSettingsService:
def _normalize_mode(self, mode: str) -> str: def _normalize_mode(self, mode: str) -> str:
normalized = (mode or "").strip().lower() normalized = (mode or "").strip().lower()
if normalized not in VALID_MODES: if normalized in VALID_MODES:
raise ValueError("Mode musi byc jednym z: public, private")
return normalized return normalized
if normalized.startswith(USER_MODE_PREFIX) and len(normalized) > len(USER_MODE_PREFIX):
return normalized
raise ValueError("Mode musi byc jednym z: public, private")
def _normalize_widgets(self, widgets: Any) -> list[str]: def _normalize_widgets(self, widgets: Any) -> list[str]:
if not isinstance(widgets, list): if not isinstance(widgets, list):
+74 -21
View File
@@ -174,7 +174,7 @@ function buildBarOption(points: BucketPoint[], unit: string, theme: ThemeMode, l
const locale = localeForLanguage(language); const locale = localeForLanguage(language);
return { return {
color: [palette.series[0]], color: [palette.series[0]],
tooltip: { trigger: "axis", backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text }, valueFormatter: (value) => formatValue(Number(value), unit, 2, locale) }, tooltip: { trigger: "axis", backgroundColor: palette.tooltip, borderColor: palette.grid, textStyle: { color: palette.text }, valueFormatter: (value) => value == null ? "--" : formatValue(Number(value), unit, 2, locale) },
grid: { left: 12, right: 16, top: 16, bottom: 40, containLabel: true }, grid: { left: 12, right: 16, top: 16, bottom: 40, containLabel: true },
xAxis: { type: "category", axisLabel: { color: palette.text, rotate: points.length > 12 ? 32 : 0 }, axisLine: { lineStyle: { color: palette.grid } }, data: points.map((point) => point.label) }, xAxis: { type: "category", axisLabel: { color: palette.text, rotate: points.length > 12 ? 32 : 0 }, axisLine: { lineStyle: { color: palette.grid } }, data: points.map((point) => point.label) },
yAxis: { type: "value", name: unit, nameTextStyle: { color: palette.text }, axisLabel: { color: palette.text, formatter: (value: number) => formatChartNumber(value, locale) }, splitLine: { lineStyle: { color: palette.grid } } }, yAxis: { type: "value", name: unit, nameTextStyle: { color: palette.text }, axisLabel: { color: palette.text, formatter: (value: number) => formatChartNumber(value, locale) }, splitLine: { lineStyle: { color: palette.grid } } },
@@ -200,7 +200,7 @@ function buildComparisonOption(data: AnalyticsPayload | undefined, theme: ThemeM
formatter: (params: any) => { formatter: (params: any) => {
const rows = Array.isArray(params) ? params : [params]; const rows = Array.isArray(params) ? params : [params];
const header = rows[0]?.axisValueLabel ?? ""; const header = rows[0]?.axisValueLabel ?? "";
return [header, ...rows.map((row) => `${row.marker ?? ""} ${row.seriesName}: ${formatValue(Number(row.value), unit, 2, locale)}`)].join("<br/>"); return [header, ...rows.map((row) => `${row.marker ?? ""} ${row.seriesName}: ${row.value == null ? "--" : formatValue(Number(row.value), unit, 2, locale)}`)].join("<br/>");
}, },
}, },
legend: verticalLegend legend: verticalLegend
@@ -331,6 +331,14 @@ function trimSingleDayHistory(history: HistoryPayload | undefined, mode: string)
})), })),
}; };
} }
function filterHistoryByMetrics(history: HistoryPayload | undefined, metricIds: string[]): HistoryPayload | undefined {
if (!history) return history;
const allowed = new Set(metricIds);
return {
...history,
series: allowed.size ? history.series.filter((series) => allowed.has(series.metric_id)) : [],
};
}
function getInitialTheme(config?: DashboardConfig): ThemeMode { function getInitialTheme(config?: DashboardConfig): ThemeMode {
return readStorage<ThemeMode>(STORAGE_KEYS.theme, (config?.defaults.theme as ThemeMode) ?? "dark", (raw) => (raw === "light" ? "light" : "dark")); return readStorage<ThemeMode>(STORAGE_KEYS.theme, (config?.defaults.theme as ThemeMode) ?? "dark", (raw) => (raw === "light" ? "light" : "dark"));
} }
@@ -428,6 +436,12 @@ export default function App() {
useEffect(() => { writeStorage(STORAGE_KEYS.archiveMetrics, archiveMetrics); }, [archiveMetrics]); useEffect(() => { writeStorage(STORAGE_KEYS.archiveMetrics, archiveMetrics); }, [archiveMetrics]);
const dataEnabled = authenticated || authEnabled === false; const dataEnabled = authenticated || authEnabled === false;
const currentRole = publicMode ? null : (authQuery.data?.role ?? null);
const isAdmin = authEnabled === false || currentRole === "admin";
const hasWarehouseAccess = !publicMode && isAdmin;
const hasSettingsAccess = !publicMode && isAdmin;
const canSavePrivateKioskSettings = !publicMode && dataEnabled;
const canSavePublicKioskSettings = !publicMode && isAdmin;
const { snapshot, connected, lastUpdated } = useRealtimeSocket(dataEnabled); const { snapshot, connected, lastUpdated } = useRealtimeSocket(dataEnabled);
const metricCandidates = useMemo(() => getMetricCandidates(snapshot, config), [snapshot, config]); const metricCandidates = useMemo(() => getMetricCandidates(snapshot, config), [snapshot, config]);
useEffect(() => { useEffect(() => {
@@ -454,12 +468,12 @@ export default function App() {
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(dataEnabled && !publicMode); const historical = useHistoricalImport(hasWarehouseAccess);
const archiveQuery = useRealtimeHistory(archiveStart && archiveEnd ? "custom" : archiveRange, dataEnabled, { start: archiveStart || undefined, end: archiveEnd || undefined, metrics: archiveMetrics, publicKiosk: publicMode }); const archiveQuery = useRealtimeHistory(archiveStart && archiveEnd ? "custom" : archiveRange, dataEnabled, { start: archiveStart || undefined, end: archiveEnd || undefined, metrics: archiveMetrics, publicKiosk: publicMode });
const liveHistoryData = useMemo(() => trimSingleDayHistory(historyQuery.data, effectiveRealtimeRange), [historyQuery.data, effectiveRealtimeRange]); const liveHistoryData = useMemo(() => filterHistoryByMetrics(trimSingleDayHistory(historyQuery.data, effectiveRealtimeRange), liveHistoryMetrics), [historyQuery.data, effectiveRealtimeRange, liveHistoryMetrics]);
const archiveHistoryData = useMemo(() => trimSingleDayHistory(archiveQuery.data, archiveRange), [archiveQuery.data, archiveRange]); 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 && (authQuery.data?.role === "admin"), 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: dataEnabled && !publicMode, staleTime: 20_000 }); const diagnosticsQuery = useQuery<DiagnosticsPayload>({ queryKey: ["diagnostics"], queryFn: api.getDiagnostics, enabled: hasSettingsAccess, staleTime: 20_000 });
const loginMutation = useMutation({ mutationFn: () => api.login(loginForm.username, loginForm.password), onSuccess: async () => { setLoginError(null); setLoginForm((value) => ({ ...value, password: "" })); await queryClient.invalidateQueries({ queryKey: ["auth-status"] }); await queryClient.invalidateQueries({ queryKey: ["dashboard-config"] }); }, onError: (error: Error) => setLoginError(parseError(error) || t(language, "loginError")) }); const loginMutation = useMutation({ mutationFn: () => api.login(loginForm.username, loginForm.password), onSuccess: async () => { setLoginError(null); setLoginForm((value) => ({ ...value, password: "" })); await queryClient.invalidateQueries({ queryKey: ["auth-status"] }); await queryClient.invalidateQueries({ queryKey: ["dashboard-config"] }); }, onError: (error: Error) => setLoginError(parseError(error) || t(language, "loginError")) });
const logoutMutation = useMutation({ mutationFn: api.logout, onSuccess: async () => { await queryClient.clear(); await queryClient.invalidateQueries({ queryKey: ["auth-status"] }); } }); const logoutMutation = useMutation({ mutationFn: api.logout, onSuccess: async () => { await queryClient.clear(); await queryClient.invalidateQueries({ queryKey: ["auth-status"] }); } });
@@ -486,17 +500,29 @@ export default function App() {
setKioskSaveNotice((current) => ({ ...current, [mode]: null })); setKioskSaveNotice((current) => ({ ...current, [mode]: null }));
}; };
const canPersistKioskSettings = !publicMode && (authEnabled === false || authQuery.data?.role === "admin"); useEffect(() => {
if (!isAdmin && kioskEditorMode === "public") {
setKioskEditorMode("private");
}
}, [isAdmin, kioskEditorMode]);
useEffect(() => {
if ((activeTab === "warehouse" && !hasWarehouseAccess) || (activeTab === "settings" && !hasSettingsAccess)) {
setActiveTab("realtime");
}
}, [activeTab, hasSettingsAccess, hasWarehouseAccess]);
const privateKioskDirty = JSON.stringify(privateKioskDraft) !== lastSyncedKioskRef.current.private; const privateKioskDirty = JSON.stringify(privateKioskDraft) !== lastSyncedKioskRef.current.private;
const publicKioskDirty = JSON.stringify(publicKioskDraft) !== lastSyncedKioskRef.current.public; const publicKioskDirty = JSON.stringify(publicKioskDraft) !== lastSyncedKioskRef.current.public;
const currentKioskDirty = kioskEditorMode === "public" ? publicKioskDirty : privateKioskDirty; const currentKioskDirty = kioskEditorMode === "public" ? publicKioskDirty : privateKioskDirty;
const canPersistCurrentKioskSettings = kioskEditorMode === "public" ? canSavePublicKioskSettings : canSavePrivateKioskSettings;
const resetKioskDraft = (mode: "public" | "private") => { const resetKioskDraft = (mode: "public" | "private") => {
const serialized = lastSyncedKioskRef.current[mode] || defaultKioskSerializedRef.current[mode]; const serialized = lastSyncedKioskRef.current[mode] || defaultKioskSerializedRef.current[mode];
const parsed = JSON.parse(serialized) as KioskSettingsPayload; const parsed = JSON.parse(serialized) as KioskSettingsPayload;
applyKioskDraftChange(mode, { ...parsed, mode }); applyKioskDraftChange(mode, { ...parsed, mode });
}; };
const saveCurrentKioskSettings = () => { const saveCurrentKioskSettings = () => {
if (!canPersistKioskSettings || saveKioskSettingsMutation.isPending) return; if (!canPersistCurrentKioskSettings || saveKioskSettingsMutation.isPending) return;
const payload = kioskEditorMode === "public" ? publicKioskDraft : privateKioskDraft; const payload = kioskEditorMode === "public" ? publicKioskDraft : privateKioskDraft;
setKioskSaveNotice((current) => ({ ...current, [payload.mode]: null })); setKioskSaveNotice((current) => ({ ...current, [payload.mode]: null }));
saveKioskSettingsMutation.mutate(payload); saveKioskSettingsMutation.mutate(payload);
@@ -508,7 +534,6 @@ export default function App() {
const topStatus = snapshot.status ?? []; const topStatus = snapshot.status ?? [];
const heroCards = snapshot.hero_cards.filter((card) => blockConfig.hero.includes(card.metric_id)); const heroCards = snapshot.hero_cards.filter((card) => blockConfig.hero.includes(card.metric_id));
const quickMetrics = Object.values(snapshot.kpis ?? {}).filter((metric) => blockConfig.quick.includes(metric.metric_id)); const quickMetrics = Object.values(snapshot.kpis ?? {}).filter((metric) => blockConfig.quick.includes(metric.metric_id));
const isAdmin = authQuery.data?.role === "admin";
const publicKioskUrl = `${window.location.origin}/kiosk/public`; const publicKioskUrl = `${window.location.origin}/kiosk/public`;
const privateKioskUrl = `${window.location.origin}/kiosk/private`; const privateKioskUrl = `${window.location.origin}/kiosk/private`;
@@ -535,6 +560,7 @@ export default function App() {
<div className="navbar-brand navbar-brand-autodark d-flex align-items-center gap-2"><span className="avatar avatar-sm bg-primary-lt text-primary border-0"><IconBolt size={18} /></span><div><div className="fw-bold">{config.app.site_name}</div><div className="text-secondary small">{t(language, "operatorPanel")}</div></div></div> <div className="navbar-brand navbar-brand-autodark d-flex align-items-center gap-2"><span className="avatar avatar-sm bg-primary-lt text-primary border-0"><IconBolt size={18} /></span><div><div className="fw-bold">{config.app.site_name}</div><div className="text-secondary small">{t(language, "operatorPanel")}</div></div></div>
<div className="navbar-nav flex-row order-md-last align-items-center gap-2"> <div className="navbar-nav flex-row order-md-last align-items-center gap-2">
<span className={`badge ${connected ? "bg-green-lt text-green" : "bg-yellow-lt text-yellow"}`}>{connected ? t(language, "connected") : t(language, "disconnected")}</span> <span className={`badge ${connected ? "bg-green-lt text-green" : "bg-yellow-lt text-yellow"}`}>{connected ? t(language, "connected") : t(language, "disconnected")}</span>
{!publicMode && currentRole ? <span className={`badge ${isAdmin ? "bg-primary-lt text-primary" : "bg-azure-lt text-azure"}`}>{currentRole}</span> : null}
<button className="btn btn-icon btn-ghost-secondary" onClick={() => setTheme((current) => (current === "dark" ? "light" : "dark"))} title={t(language, "theme")}>{theme === "dark" ? <IconSun size={18} /> : <IconMoon size={18} />}</button> <button className="btn btn-icon btn-ghost-secondary" onClick={() => setTheme((current) => (current === "dark" ? "light" : "dark"))} title={t(language, "theme")}>{theme === "dark" ? <IconSun size={18} /> : <IconMoon size={18} />}</button>
<button className="btn btn-icon btn-ghost-secondary" onClick={() => setLanguage((current) => (current === "pl" ? "en" : "pl"))} title={t(language, "language")}><IconLanguage size={18} /></button> <button className="btn btn-icon btn-ghost-secondary" onClick={() => setLanguage((current) => (current === "pl" ? "en" : "pl"))} title={t(language, "language")}><IconLanguage size={18} /></button>
{!publicMode ? <button className="btn btn-sm pv-navbar-action" onClick={() => setViewMode((current) => (current === "normal" ? "kiosk" : "normal"))}><IconDeviceDesktop size={16} className="me-1" />{viewMode === "normal" ? t(language, "openKiosk") : t(language, "exitKiosk")}</button> : null} {!publicMode ? <button className="btn btn-sm pv-navbar-action" onClick={() => setViewMode((current) => (current === "normal" ? "kiosk" : "normal"))}><IconDeviceDesktop size={16} className="me-1" />{viewMode === "normal" ? t(language, "openKiosk") : t(language, "exitKiosk")}</button> : null}
@@ -543,15 +569,38 @@ export default function App() {
</div> </div>
</header> </header>
); );
const menuTabs: Array<{ id: TabKey; label: string; icon: ReactElement; visible: boolean }> = [
{ id: "realtime" as TabKey, label: language === "en" ? "Live" : "Live", icon: <IconLayoutDashboard size={18} />, visible: true },
{ id: "archive" as TabKey, label: language === "en" ? "Historical live" : "Dane chwilowe", icon: <IconHistory size={18} />, visible: true },
{ id: "analytics" as TabKey, label: t(language, "analytics"), icon: <IconChartBar size={18} />, visible: true },
{ id: "warehouse" as TabKey, label: language === "en" ? "Data warehouse" : "Hurtownia danych", icon: <IconDatabaseImport size={18} />, visible: hasWarehouseAccess },
{ id: "kiosk" as TabKey, label: t(language, "kiosk"), icon: <IconDeviceDesktop size={18} />, visible: true },
{ id: "settings" as TabKey, label: t(language, "settings"), icon: <IconSettings size={18} />, visible: hasSettingsAccess },
].filter((item) => item.visible);
const menu = ( const menu = (
<div className="navbar-expand-md pv-subnav border-bottom"><div className="container-xl"><div className="navbar-collapse"><ul className="navbar-nav"> <div className="pv-subnav border-bottom">
<NavItem icon={<IconLayoutDashboard size={18} />} active={activeTab === "realtime"} onClick={() => setActiveTab("realtime")} label={language === "en" ? "Live" : "Live"} /> <div className="container-xl">
<NavItem icon={<IconHistory size={18} />} active={activeTab === "archive"} onClick={() => setActiveTab("archive")} label={language === "en" ? "Historical live" : "Dane chwilowe"} /> <div className="pv-subnav-shell d-flex flex-wrap align-items-center justify-content-between gap-3 py-2">
<NavItem icon={<IconChartBar size={18} />} active={activeTab === "analytics"} onClick={() => setActiveTab("analytics")} label={t(language, "analytics")} /> <div className="d-flex flex-wrap gap-2">
<NavItem icon={<IconDatabaseImport size={18} />} active={activeTab === "warehouse"} onClick={() => setActiveTab("warehouse")} label={language === "en" ? "Data warehouse" : "Hurtownia danych"} /> {menuTabs.map((item) => (
<NavItem icon={<IconDeviceDesktop size={18} />} active={activeTab === "kiosk"} onClick={() => setActiveTab("kiosk")} label={t(language, "kiosk")} /> <button
<NavItem icon={<IconSettings size={18} />} active={activeTab === "settings"} onClick={() => setActiveTab("settings")} label={t(language, "settings")} /> key={item.id}
</ul><div className="ms-auto text-secondary small d-none d-md-flex align-items-center gap-2"><IconClockHour4 size={16} />{t(language, "updatedAt")}: {formatDateTime(lastUpdated, locale)}</div></div></div></div> className={`btn pv-menu-tab ${activeTab === item.id ? "active" : ""}`}
onClick={() => setActiveTab(item.id)}
>
<span className="pv-menu-tab-icon">{item.icon}</span>
<span>{item.label}</span>
</button>
))}
</div>
<div className="pv-menu-meta">
<IconClockHour4 size={16} />
<span>{t(language, "updatedAt")}: {formatDateTime(lastUpdated, locale)}</span>
</div>
</div>
</div>
</div>
); );
if (viewMode === "kiosk" || publicMode) { if (viewMode === "kiosk" || publicMode) {
@@ -568,7 +617,7 @@ export default function App() {
{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={canPersistKioskSettings} saveNotice={kioskSaveNotice[kioskEditorMode]} onSave={saveCurrentKioskSettings} onReset={() => resetKioskDraft(kioskEditorMode)} /></div><div className="col-12 col-xxl-4"><KioskLinkPanel language={language} publicKioskUrl={publicKioskUrl} privateKioskUrl={privateKioskUrl} publicSettings={publicKioskDraft} privateSettings={privateKioskDraft} /></div></div></>} {activeTab === "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 === "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"><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></>}
</div></div></div></div> </div></div></div></div>
@@ -621,8 +670,12 @@ function HistoricalPanel({ status, language, locale, compact = false }: { status
function ImportControls({ status, language, onStart, onSyncNow, onCancel }: { status?: HistoricalStatus; language: Language; onStart: (payload: { start_date?: string; end_date?: string; chunk_days?: number; force?: boolean }) => void; onSyncNow: () => void; onCancel: () => void; }) { const [startDate, setStartDate] = useState(status?.available_start_date ?? ""); const [endDate, setEndDate] = useState(status?.available_end_date ?? ""); const [chunkDays, setChunkDays] = useState(String(status?.default_chunk_days ?? 7)); useEffect(() => { if (!status) return; setStartDate((current) => current || status.available_start_date || ""); setEndDate((current) => current || status.available_end_date || ""); setChunkDays((current) => current || String(status.default_chunk_days || 7)); }, [status]); return <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 }: { language: Language; value: KioskSettingsPayload; onChange: (value: KioskSettingsPayload) => void; onSave: () => void; onReset: () => void; selectedMode: "public" | "private"; onModeChange: (value: "public" | "private") => void; labels: Map<WidgetId, string>; buckets: Array<{ key: string; label: string }>; compareModes: string[]; saving: boolean; dirty: boolean; canSave: boolean; saveNotice: string | null; }) { const widgets = toWidgetIds(value.widgets); return <div className="d-flex flex-column gap-3"><div className="card pv-card"><div className="card-header 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">{language === "en" ? "First choose who will use the kiosk, then fine-tune what they see." : "Najpierw wybierz odbiorcę kiosku, potem dopracuj to co widzi."}</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"><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="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={savLine truncated 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 KioskLinkPanel({ language, publicKioskUrl, privateKioskUrl, publicSettings, privateSettings }: { language: Language; publicKioskUrl: string; privateKioskUrl: string; publicSettings: KioskSettingsPayload; privateSettings: KioskSettingsPayload }) { const [copied, setCopied] = useState<"public" | "private" | null>(null); const copy = async (value: string, mode: "public" | "private") => { await navigator.clipboard.writeText(value); setCopied(mode); window.setTimeout(() => setCopied(null), 1500); }; return <div className="d-flex flex-column gap-3"><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" ? "Quick guidance" : "Szybka wskazówka"}</h3></div><div className="card-body"><div className="text-secondary small">{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."}</div></div></div></div>; } 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
}
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 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 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")} 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 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>; }
+53
View File
@@ -209,3 +209,56 @@ body {
.text-break { .text-break {
word-break: break-word; word-break: break-word;
} }
.pv-subnav-shell {
min-height: 4rem;
}
.pv-menu-tab {
display: inline-flex !important;
align-items: center;
gap: 0.55rem;
border: 1px solid transparent !important;
border-radius: 999px !important;
padding: 0.6rem 0.95rem !important;
background: rgba(127, 127, 127, 0.08) !important;
color: inherit !important;
font-weight: 500;
}
.pv-menu-tab:hover,
.pv-menu-tab:focus {
background: rgba(127, 127, 127, 0.14) !important;
}
.pv-menu-tab.active {
background: rgba(32, 107, 196, 0.14) !important;
border-color: rgba(32, 107, 196, 0.22) !important;
color: var(--tblr-primary) !important;
box-shadow: 0 0 0 1px rgba(32, 107, 196, 0.06) inset;
}
.pv-menu-tab-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.1rem;
min-width: 1.1rem;
}
.pv-menu-meta {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: var(--tblr-secondary);
font-size: 0.875rem;
white-space: nowrap;
}
@media (max-width: 992px) {
.pv-menu-meta {
width: 100%;
white-space: normal;
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ export interface BucketPoint {
label: string; label: string;
start: string; start: string;
end: string; end: string;
value: number; value: number | null;
} }
export interface AnalyticsSummary { export interface AnalyticsSummary {