first commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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