first commit
This commit is contained in:
66
frontend/src/components/analytics/ComparisonChart.tsx
Normal file
66
frontend/src/components/analytics/ComparisonChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/analytics/DistributionPieChart.tsx
Normal file
51
frontend/src/components/analytics/DistributionPieChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
frontend/src/components/analytics/PeriodControls.tsx
Normal file
76
frontend/src/components/analytics/PeriodControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/analytics/ProductionBarChart.tsx
Normal file
56
frontend/src/components/analytics/ProductionBarChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/analytics/SummaryCards.tsx
Normal file
34
frontend/src/components/analytics/SummaryCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/common/Badge.tsx
Normal file
21
frontend/src/components/common/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
frontend/src/components/common/Card.tsx
Normal file
31
frontend/src/components/common/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
frontend/src/components/common/EChart.tsx
Normal file
31
frontend/src/components/common/EChart.tsx
Normal 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} />;
|
||||
}
|
||||
13
frontend/src/components/common/EmptyState.tsx
Normal file
13
frontend/src/components/common/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
frontend/src/components/common/Icons.tsx
Normal file
84
frontend/src/components/common/Icons.tsx
Normal 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>;
|
||||
}
|
||||
17
frontend/src/components/common/ValuePair.tsx
Normal file
17
frontend/src/components/common/ValuePair.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
frontend/src/components/layout/AppShell.tsx
Normal file
16
frontend/src/components/layout/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/layout/TopNav.tsx
Normal file
56
frontend/src/components/layout/TopNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
frontend/src/components/realtime/HeroKpiGrid.tsx
Normal file
32
frontend/src/components/realtime/HeroKpiGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/realtime/KpiStrip.tsx
Normal file
35
frontend/src/components/realtime/KpiStrip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
frontend/src/components/realtime/LiveHistoryChart.tsx
Normal file
64
frontend/src/components/realtime/LiveHistoryChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
frontend/src/components/realtime/LiveStatusBoard.tsx
Normal file
30
frontend/src/components/realtime/LiveStatusBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/realtime/PhaseGrid.tsx
Normal file
26
frontend/src/components/realtime/PhaseGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/realtime/StringGrid.tsx
Normal file
34
frontend/src/components/realtime/StringGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
frontend/src/components/settings/ConfigPanel.tsx
Normal file
82
frontend/src/components/settings/ConfigPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
frontend/src/components/settings/HistoricalImportPanel.tsx
Normal file
74
frontend/src/components/settings/HistoricalImportPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/status/FaultBanner.tsx
Normal file
24
frontend/src/components/status/FaultBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user