commit 189722d6dbb4d9c1d51964d1fd0994cc03c7f7b4
parent d36d96903543a8c472e1f329dd20abb2af447306
Author: AbhinavRai01 <abhinavrai004@gmail.com>
Date: Fri, 27 Mar 2026 19:41:44 +0530
Merge branch 'main' of https://github.com/maydayv7/demeter
Diffstat:
26 files changed, 2409 insertions(+), 2393 deletions(-)
diff --git a/frontend/src/components/ui/ChartTooltip.jsx b/frontend/src/components/ui/ChartTooltip.jsx
@@ -0,0 +1,27 @@
+/**
+ * Shared Custom Tooltip for Recharts components
+ */
+export default function ChartTooltip({ active, payload, label }) {
+ if (!active || !payload?.length) return null;
+ return (
+ <div
+ style={{
+ padding: "8px 12px",
+ borderRadius: 8,
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ background: "var(--tooltip-bg)",
+ border: "1px solid var(--border)",
+ color: "var(--text)",
+ boxShadow: "var(--shadow)",
+ }}
+ >
+ <div style={{ color: "var(--text-3)", marginBottom: 4 }}>{label}</div>
+ {payload.map((p) => (
+ <div key={p.dataKey} style={{ color: p.color, marginTop: 2 }}>
+ {p.name}: <strong>{p.value}</strong>
+ </div>
+ ))}
+ </div>
+ );
+}
diff --git a/frontend/src/components/ui/EmptyState.jsx b/frontend/src/components/ui/EmptyState.jsx
@@ -0,0 +1,67 @@
+export default function EmptyState({
+ icon: Icon,
+ title,
+ description,
+ style = {},
+ className = "",
+}) {
+ return (
+ <div
+ className={className}
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ height: "100%",
+ gap: 16,
+ padding: 40,
+ ...style,
+ }}
+ >
+ {Icon && (
+ <div
+ style={{
+ width: 64,
+ height: 64,
+ borderRadius: 20,
+ background: "rgba(74,222,128,0.06)",
+ border: "1px solid rgba(74,222,128,0.15)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <Icon size={28} style={{ color: "var(--green)" }} />
+ </div>
+ )}
+ <div style={{ textAlign: "center" }}>
+ {title && (
+ <div
+ style={{
+ fontWeight: 700,
+ fontSize: 15,
+ color: "var(--text-2)",
+ marginBottom: 8,
+ }}
+ >
+ {title}
+ </div>
+ )}
+ {description && (
+ <div
+ style={{
+ fontSize: 13,
+ color: "var(--text-3)",
+ maxWidth: 300,
+ lineHeight: 1.5,
+ margin: "0 auto",
+ }}
+ >
+ {description}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/frontend/src/components/ui/FilterBar.jsx b/frontend/src/components/ui/FilterBar.jsx
@@ -0,0 +1,20 @@
+export default function FilterBar({ children, style = {}, className = "" }) {
+ return (
+ <div
+ className={className}
+ style={{
+ flexShrink: 0,
+ padding: "8px 24px",
+ borderBottom: "1px solid var(--border)",
+ background: "var(--bg-2)",
+ display: "flex",
+ alignItems: "center",
+ flexWrap: "wrap",
+ gap: 12,
+ ...style,
+ }}
+ >
+ {children}
+ </div>
+ );
+}
diff --git a/frontend/src/components/ui/FilterPill.jsx b/frontend/src/components/ui/FilterPill.jsx
@@ -0,0 +1,28 @@
+export default function FilterPill({
+ active,
+ onClick,
+ children,
+ className = "",
+ style = {},
+}) {
+ return (
+ <button
+ onClick={onClick}
+ className={className}
+ style={{
+ padding: "5px 12px",
+ borderRadius: 20,
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ cursor: "pointer",
+ background: active ? "rgba(74,222,128,0.15)" : "var(--surface)",
+ border: `1px solid ${active ? "rgba(74,222,128,0.4)" : "var(--border)"}`,
+ color: active ? "var(--green)" : "var(--text-3)",
+ flexShrink: 0,
+ ...style,
+ }}
+ >
+ {children}
+ </button>
+ );
+}
diff --git a/frontend/src/components/ui/IconButton.jsx b/frontend/src/components/ui/IconButton.jsx
@@ -0,0 +1,31 @@
+export default function IconButton({
+ onClick,
+ children,
+ className = "",
+ style = {},
+ ...props
+}) {
+ return (
+ <button
+ onClick={onClick}
+ className={className}
+ style={{
+ width: 34,
+ height: 34,
+ borderRadius: 8,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ color: "var(--text-3)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ cursor: "pointer",
+ flexShrink: 0,
+ ...style,
+ }}
+ {...props}
+ >
+ {children}
+ </button>
+ );
+}
diff --git a/frontend/src/components/ui/InlineLabel.jsx b/frontend/src/components/ui/InlineLabel.jsx
@@ -0,0 +1,10 @@
+export default function InlineLabel({ children, className = "", style = {} }) {
+ return (
+ <div
+ className={`section-label ${className}`}
+ style={{ marginBottom: 0, ...style }}
+ >
+ {children}
+ </div>
+ );
+}
diff --git a/frontend/src/components/ui/LoadingShimmer.jsx b/frontend/src/components/ui/LoadingShimmer.jsx
@@ -0,0 +1,30 @@
+export default function LoadingShimmer({
+ count = 3,
+ height = 80,
+ style = {},
+ className = "",
+}) {
+ return (
+ <div
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ gap: 12,
+ ...style,
+ }}
+ className={className}
+ >
+ {Array.from({ length: count }).map((_, i) => (
+ <div
+ key={i}
+ className="shimmer"
+ style={{
+ height: height,
+ borderRadius: 12,
+ border: "1px solid var(--border)",
+ }}
+ />
+ ))}
+ </div>
+ );
+}
diff --git a/frontend/src/components/ui/PageHeader.jsx b/frontend/src/components/ui/PageHeader.jsx
@@ -0,0 +1,22 @@
+export default function PageHeader({ title, subtitle, children }) {
+ return (
+ <header
+ style={{
+ flexShrink: 0,
+ padding: "0 24px",
+ height: 64,
+ borderBottom: "1px solid var(--border)",
+ background: "var(--bg-2)",
+ display: "flex",
+ alignItems: "center",
+ gap: 16,
+ }}
+ >
+ <div>
+ <h1 className="page-title">{title}</h1>
+ {subtitle && <p className="page-subtitle">{subtitle}</p>}
+ </div>
+ {children}
+ </header>
+ );
+}
diff --git a/frontend/src/components/ui/PageShell.jsx b/frontend/src/components/ui/PageShell.jsx
@@ -0,0 +1,27 @@
+import Sidebar from "../Sidebar";
+
+export default function PageShell({ children }) {
+ return (
+ <div
+ style={{
+ display: "flex",
+ height: "100vh",
+ overflow: "hidden",
+ background: "var(--bg)",
+ }}
+ >
+ <Sidebar />
+ <main
+ className="animate-fade-in"
+ style={{
+ flex: 1,
+ display: "flex",
+ flexDirection: "column",
+ overflow: "hidden",
+ }}
+ >
+ {children}
+ </main>
+ </div>
+ );
+}
diff --git a/frontend/src/components/ui/Pagination.jsx b/frontend/src/components/ui/Pagination.jsx
@@ -0,0 +1,83 @@
+import { ChevronLeft, ChevronRight } from "lucide-react";
+
+export default function Pagination({
+ page,
+ totalPages,
+ setPage,
+ style = {},
+ className = "",
+}) {
+ if (totalPages <= 1) return null;
+ return (
+ <div
+ className={className}
+ style={{
+ display: "flex",
+ justifyContent: "center",
+ alignItems: "center",
+ gap: 8,
+ marginTop: 20,
+ ...style,
+ }}
+ >
+ <button
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
+ disabled={page === 1}
+ style={{
+ width: 32,
+ height: 32,
+ borderRadius: 8,
+ cursor: page === 1 ? "not-allowed" : "pointer",
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ color: page === 1 ? "var(--text-3)" : "var(--text-2)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <ChevronLeft size={14} />
+ </button>
+
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
+ <button
+ key={n}
+ onClick={() => setPage(n)}
+ style={{
+ width: 32,
+ height: 32,
+ borderRadius: 8,
+ cursor: "pointer",
+ fontFamily: "DM Mono, monospace",
+ fontSize: 12,
+ background: n === page ? "var(--green)" : "var(--surface)",
+ border: `1px solid ${n === page ? "transparent" : "var(--border)"}`,
+ color: n === page ? "var(--btn-on-green)" : "var(--text-2)",
+ fontWeight: n === page ? 700 : 400,
+ }}
+ >
+ {n}
+ </button>
+ ))}
+
+ <button
+ onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
+ disabled={page === totalPages}
+ style={{
+ width: 32,
+ height: 32,
+ borderRadius: 8,
+ cursor: page === totalPages ? "not-allowed" : "pointer",
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ color: page === totalPages ? "var(--text-3)" : "var(--text-2)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <ChevronRight size={14} />
+ </button>
+ </div>
+ );
+}
diff --git a/frontend/src/components/ui/PrimaryButton.jsx b/frontend/src/components/ui/PrimaryButton.jsx
@@ -0,0 +1,35 @@
+export default function PrimaryButton({
+ onClick,
+ children,
+ icon: Icon,
+ className = "",
+ style = {},
+ ...props
+}) {
+ return (
+ <button
+ onClick={onClick}
+ className={className}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 7,
+ padding: "8px 18px",
+ borderRadius: 10,
+ fontSize: 13,
+ fontWeight: 600,
+ background: "var(--green)",
+ border: "none",
+ color: "var(--btn-on-green)",
+ cursor: "pointer",
+ boxShadow: "0 0 16px rgba(74,222,128,0.2)",
+ flexShrink: 0,
+ ...style,
+ }}
+ {...props}
+ >
+ {Icon && <Icon size={15} />}
+ {children}
+ </button>
+ );
+}
diff --git a/frontend/src/components/ui/SearchBar.jsx b/frontend/src/components/ui/SearchBar.jsx
@@ -0,0 +1,69 @@
+import { Search, X } from "lucide-react";
+
+export default function SearchBar({
+ value,
+ onChange,
+ onClear,
+ placeholder,
+ style = {},
+ className = "",
+}) {
+ return (
+ <div
+ style={{ position: "relative", flex: 1, maxWidth: 360, ...style }}
+ className={className}
+ >
+ <Search
+ size={14}
+ style={{
+ position: "absolute",
+ left: 10,
+ top: "50%",
+ transform: "translateY(-50%)",
+ color: "var(--text-3)",
+ }}
+ />
+ <input
+ value={value}
+ onChange={(e) => onChange(e.target.value)}
+ placeholder={placeholder}
+ style={{
+ width: "100%",
+ paddingLeft: 32,
+ paddingRight: value ? 28 : 12,
+ paddingTop: 7,
+ paddingBottom: 7,
+ borderRadius: 8,
+ fontSize: 13,
+ fontFamily: "DM Mono, monospace",
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ color: "var(--text)",
+ outline: "none",
+ caretColor: "var(--green)",
+ }}
+ />
+ {value && (
+ <button
+ onClick={onClear}
+ style={{
+ position: "absolute",
+ right: 8,
+ top: "50%",
+ transform: "translateY(-50%)",
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ padding: 0,
+ color: "var(--text-3)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <X size={12} />
+ </button>
+ )}
+ </div>
+ );
+}
diff --git a/frontend/src/components/ui/SectionCard.jsx b/frontend/src/components/ui/SectionCard.jsx
@@ -0,0 +1,22 @@
+export default function SectionCard({
+ children,
+ className = "",
+ style = {},
+ ...props
+}) {
+ return (
+ <div
+ className={className}
+ style={{
+ borderRadius: 14,
+ padding: 20,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ ...style,
+ }}
+ {...props}
+ >
+ {children}
+ </div>
+ );
+}
diff --git a/frontend/src/components/ui/SelectField.jsx b/frontend/src/components/ui/SelectField.jsx
@@ -0,0 +1,51 @@
+import { ChevronDown } from "lucide-react";
+
+export default function SelectField({
+ value,
+ onChange,
+ options = [],
+ style = {},
+ className = "",
+ ...props
+}) {
+ // Options should be an array of { value, label }
+ return (
+ <div style={{ position: "relative", ...style }} className={className}>
+ <select
+ value={value}
+ onChange={onChange}
+ style={{
+ appearance: "none",
+ padding: "5px 24px 5px 10px",
+ borderRadius: 8,
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ color: "var(--text-2)",
+ cursor: "pointer",
+ outline: "none",
+ width: "100%",
+ }}
+ {...props}
+ >
+ {options.map((opt, i) => (
+ <option key={i} value={opt.value}>
+ {opt.label}
+ </option>
+ ))}
+ </select>
+ <ChevronDown
+ size={10}
+ style={{
+ position: "absolute",
+ right: 8,
+ top: "50%",
+ transform: "translateY(-50%)",
+ pointerEvents: "none",
+ color: "var(--text-3)",
+ }}
+ />
+ </div>
+ );
+}
diff --git a/frontend/src/components/ui/StatusBadge.jsx b/frontend/src/components/ui/StatusBadge.jsx
@@ -0,0 +1,44 @@
+export const STATUS_COLORS = {
+ Healthy: {
+ bg: "rgba(74,222,128,0.12)",
+ text: "var(--green)",
+ border: "rgba(74,222,128,0.3)",
+ },
+ Attention: {
+ bg: "rgba(245,158,11,0.12)",
+ text: "var(--amber)",
+ border: "rgba(245,158,11,0.3)",
+ },
+ Critical: {
+ bg: "rgba(248,113,113,0.12)",
+ text: "var(--red)",
+ border: "rgba(248,113,113,0.3)",
+ },
+};
+
+export default function StatusBadge({
+ status,
+ label,
+ className = "",
+ style = {},
+}) {
+ const st = STATUS_COLORS[status] || STATUS_COLORS.Healthy;
+ return (
+ <div
+ className={className}
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ padding: "3px 8px",
+ borderRadius: 20,
+ background: st.bg,
+ color: st.text,
+ border: `1px solid ${st.border}`,
+ display: "inline-block",
+ ...style,
+ }}
+ >
+ {label || status.toUpperCase()}
+ </div>
+ );
+}
diff --git a/frontend/src/components/ui/index.js b/frontend/src/components/ui/index.js
@@ -0,0 +1,15 @@
+export { default as PageShell } from "./PageShell";
+export { default as PageHeader } from "./PageHeader";
+export { default as IconButton } from "./IconButton";
+export { default as PrimaryButton } from "./PrimaryButton";
+export { default as SearchBar } from "./SearchBar";
+export { default as FilterBar } from "./FilterBar";
+export { default as FilterPill } from "./FilterPill";
+export { default as SelectField } from "./SelectField";
+export { default as SectionCard } from "./SectionCard";
+export { default as ChartTooltip } from "./ChartTooltip";
+export { default as StatusBadge } from "./StatusBadge";
+export { default as LoadingShimmer } from "./LoadingShimmer";
+export { default as EmptyState } from "./EmptyState";
+export { default as Pagination } from "./Pagination";
+export { default as InlineLabel } from "./InlineLabel";
diff --git a/frontend/src/index.css b/frontend/src/index.css
@@ -321,12 +321,39 @@ body {
}
}
+@keyframes slideDownRight {
+ from {
+ opacity: 0;
+ transform: translate(-20px, -20px) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translate(0, 0) scale(1);
+ }
+}
+@keyframes fadeScale {
+ from {
+ opacity: 0;
+ transform: scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
.animate-fade-up {
animation: fadeUp 0.5s ease forwards;
}
.animate-fade-in {
animation: fadeIn 0.3s ease forwards;
}
+.animate-slide-down-right {
+ animation: slideDownRight 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
+}
+.animate-fade-scale {
+ animation: fadeScale 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards;
+}
.status-dot {
animation: statusPulse 2s ease-in-out infinite;
}
diff --git a/frontend/src/pages/AddCrop.jsx b/frontend/src/pages/AddCrop.jsx
@@ -26,7 +26,12 @@ import {
FlaskConical,
Cpu,
} from "lucide-react";
-import Sidebar from "../components/Sidebar";
+import {
+ PageShell,
+ PageHeader,
+ IconButton,
+ SectionCard,
+} from "../components/ui";
// Agent Formatting
const AGENT_META = {
@@ -427,16 +432,7 @@ export default function AddCrop() {
const INPUT_FIELDS = getInputFields(t);
return (
- <div
- style={{
- display: "flex",
- height: "100vh",
- overflow: "hidden",
- background: "var(--bg)",
- }}
- >
- <Sidebar />
-
+ <PageShell>
{/* Toast */}
{toast && (
<div
@@ -482,55 +478,25 @@ export default function AddCrop() {
}}
>
{/* Header */}
- <header
- style={{
- flexShrink: 0,
- padding: "0 24px",
- height: 64,
- borderBottom: "1px solid var(--border)",
- background: "var(--bg-2)",
- display: "flex",
- alignItems: "center",
- gap: 12,
- }}
+ <PageHeader
+ title={t("add_title")}
+ subtitle={t("add_subtitle")}
+ icon={Sprout}
+ iconColor="var(--green)"
+ iconBg="rgba(74,222,128,0.1)"
>
- <button
- onClick={() => navigate("/dashboard")}
- style={{
- width: 34,
- height: 34,
- borderRadius: 8,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- color: "var(--text-3)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- cursor: "pointer",
- }}
- >
- <ArrowLeft size={15} />
- </button>
-
<div
style={{
- width: 32,
- height: 32,
- borderRadius: 8,
- flexShrink: 0,
- background: "rgba(74,222,128,0.1)",
- border: "1px solid rgba(74,222,128,0.2)",
display: "flex",
alignItems: "center",
- justifyContent: "center",
+ gap: 8,
+ marginRight: 8,
+ order: -1,
}}
>
- <Sprout size={15} style={{ color: "var(--green)" }} />
- </div>
-
- <div>
- <h1 className="page-title">{t("add_title")}</h1>
- <p className="page-subtitle">{t("add_subtitle")}</p>
+ <IconButton onClick={() => navigate("/dashboard")}>
+ <ArrowLeft size={15} />
+ </IconButton>
</div>
{/* Cycle counter */}
@@ -562,7 +528,7 @@ export default function AddCrop() {
{t("add_cycles_done", { n: cycles, s: cycles !== 1 ? "S" : "" })}
</div>
)}
- </header>
+ </PageHeader>
{/* Pipeline phase strip */}
{phase === "running" && (
@@ -828,14 +794,7 @@ export default function AddCrop() {
</div>
{/* Sensor inputs */}
- <div
- style={{
- borderRadius: 16,
- padding: 22,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- }}
- >
+ <SectionCard>
<div className="section-label">{t("add_sensor_params")}</div>
<div
style={{
@@ -971,7 +930,7 @@ export default function AddCrop() {
),
)}
</div>
- </div>
+ </SectionCard>
</div>
{/* Live agent log */}
@@ -1279,6 +1238,6 @@ export default function AddCrop() {
)}
</div>
</main>
- </div>
+ </PageShell>
);
}
diff --git a/frontend/src/pages/Alerts.jsx b/frontend/src/pages/Alerts.jsx
@@ -11,10 +11,18 @@ import {
RefreshCw,
SlidersHorizontal,
Scissors,
+ RotateCcw,
} from "lucide-react";
import { useFarmData } from "../hooks/useFarmData";
import { generateAlerts } from "../utils/dataUtils";
-import Sidebar from "../components/Sidebar";
+import {
+ PageShell,
+ PageHeader,
+ IconButton,
+ FilterPill,
+ LoadingShimmer,
+ EmptyState,
+} from "../components/ui";
import { useT } from "../hooks/useTranslation";
// Severity
@@ -59,7 +67,7 @@ const AGENT_COLORS = {
HISTORIAN: "var(--text-3)",
};
-function AlertCard({ alert, onAck, onDismiss, t, td }) {
+function AlertCard({ alert, onAck, onUnack, onDismiss, t, td }) {
const style = alert.isHarvestAlert ? HARVEST_STYLE : SEV[alert.severity];
const Icon = style.icon;
@@ -179,7 +187,7 @@ function AlertCard({ alert, onAck, onDismiss, t, td }) {
{/* Actions */}
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
- {!alert.ack && (
+ {!alert.ack ? (
<button
onClick={() => onAck(alert.id)}
title="Acknowledge"
@@ -198,6 +206,25 @@ function AlertCard({ alert, onAck, onDismiss, t, td }) {
>
<CheckCircle2 size={13} />
</button>
+ ) : (
+ <button
+ onClick={() => onUnack(alert.id)}
+ title="Unacknowledge"
+ style={{
+ width: 28,
+ height: 28,
+ borderRadius: 8,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ color: "var(--text-3)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ cursor: "pointer",
+ }}
+ >
+ <RotateCcw size={13} />
+ </button>
)}
<button
onClick={() => onDismiss(alert.id)}
@@ -238,22 +265,33 @@ export default function Alerts() {
const ack = (id) =>
setAlerts((a) => a.map((al) => (al.id === id ? { ...al, ack: true } : al)));
+ const unack = (id) =>
+ setAlerts((a) =>
+ a.map((al) => (al.id === id ? { ...al, ack: false } : al)),
+ );
const dismiss = (id) => setAlerts((a) => a.filter((al) => al.id !== id));
const ackAll = () => setAlerts((a) => a.map((al) => ({ ...al, ack: true })));
const counts = useMemo(
() => ({
- harvest: alerts.filter((a) => a.isHarvestAlert && !a.ack).length,
+ harvest: alerts.filter((a) => a.isHarvestAlert && (showAcked || !a.ack))
+ .length,
critical: alerts.filter(
- (a) => a.severity === "critical" && !a.ack && !a.isHarvestAlert,
+ (a) =>
+ a.severity === "critical" &&
+ (showAcked || !a.ack) &&
+ !a.isHarvestAlert,
+ ).length,
+ warning: alerts.filter(
+ (a) => a.severity === "warning" && (showAcked || !a.ack),
).length,
- warning: alerts.filter((a) => a.severity === "warning" && !a.ack).length,
info: alerts.filter(
- (a) => a.severity === "info" && !a.ack && !a.isHarvestAlert,
+ (a) =>
+ a.severity === "info" && (showAcked || !a.ack) && !a.isHarvestAlert,
).length,
- total: alerts.filter((a) => !a.ack).length,
+ total: alerts.filter((a) => showAcked || !a.ack).length,
}),
- [alerts],
+ [alerts, showAcked],
);
const filtered = useMemo(
@@ -297,314 +335,225 @@ export default function Alerts() {
];
return (
- <div
- style={{
- display: "flex",
- height: "100vh",
- overflow: "hidden",
- background: "var(--bg)",
- }}
- >
- <Sidebar />
-
- <main
- style={{
- flex: 1,
- display: "flex",
- flexDirection: "column",
- overflow: "hidden",
- }}
+ <PageShell>
+ {/* Header */}
+ <PageHeader
+ title={t("alerts_title")}
+ subtitle={
+ loading
+ ? t("alerts_subtitle_loading")
+ : t("alerts_subtitle", {
+ unacked: counts.total,
+ total: alerts.length,
+ })
+ }
>
- {/* Header */}
- <header
+ <div
style={{
- flexShrink: 0,
- padding: "0 24px",
- height: 64,
- borderBottom: "1px solid var(--border)",
- background: "var(--bg-2)",
+ marginLeft: "auto",
display: "flex",
alignItems: "center",
- gap: 16,
+ gap: 8,
}}
>
- <div>
- <h1 className="page-title">{t("alerts_title")}</h1>
- <p className="page-subtitle">
- {loading
- ? t("alerts_subtitle_loading")
- : t("alerts_subtitle", {
- unacked: counts.total,
- total: alerts.length,
- })}
- </p>
- </div>
+ <IconButton onClick={refreshData}>
+ <RefreshCw size={14} className={loading ? "animate-spin" : ""} />
+ </IconButton>
- <div
+ <button
+ onClick={() => setShowAcked(!showAcked)}
style={{
- marginLeft: "auto",
display: "flex",
alignItems: "center",
- gap: 8,
+ gap: 6,
+ padding: "6px 12px",
+ borderRadius: 8,
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ background: showAcked ? "rgba(74,222,128,0.1)" : "var(--surface)",
+ border: `1px solid ${showAcked ? "rgba(74,222,128,0.3)" : "var(--border)"}`,
+ color: showAcked ? "var(--green)" : "var(--text-3)",
+ cursor: "pointer",
}}
>
- <button
- onClick={refreshData}
- style={{
- width: 34,
- height: 34,
- borderRadius: 8,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- color: "var(--text-3)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- cursor: "pointer",
- }}
- >
- <RefreshCw size={14} className={loading ? "animate-spin" : ""} />
- </button>
+ {showAcked ? <BellOff size={12} /> : <Bell size={12} />}
+ {showAcked ? t("alerts_unacked_only") : t("alerts_show_all")}
+ </button>
- <button
- onClick={() => setShowAcked(!showAcked)}
- style={{
- display: "flex",
- alignItems: "center",
- gap: 6,
- padding: "6px 12px",
- borderRadius: 8,
- fontSize: 11,
- fontFamily: "DM Mono, monospace",
- background: showAcked
- ? "rgba(74,222,128,0.1)"
- : "var(--surface)",
- border: `1px solid ${showAcked ? "rgba(74,222,128,0.3)" : "var(--border)"}`,
- color: showAcked ? "var(--green)" : "var(--text-3)",
- cursor: "pointer",
- }}
- >
- {showAcked ? <Bell size={12} /> : <BellOff size={12} />}
- {showAcked ? t("alerts_show_all") : t("alerts_unacked_only")}
- </button>
+ <button
+ onClick={ackAll}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ padding: "6px 12px",
+ borderRadius: 8,
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ color: "var(--text-3)",
+ cursor: "pointer",
+ }}
+ >
+ <CheckCircle2 size={12} /> {t("alerts_ack_all_btn")}
+ </button>
+ </div>
+ </PageHeader>
- <button
- onClick={ackAll}
- style={{
- display: "flex",
- alignItems: "center",
- gap: 6,
- padding: "6px 12px",
- borderRadius: 8,
- fontSize: 11,
- fontFamily: "DM Mono, monospace",
- background: "var(--surface)",
- border: "1px solid var(--border)",
- color: "var(--text-3)",
- cursor: "pointer",
- }}
- >
- <CheckCircle2 size={12} /> {t("alerts_ack_all")}
- </button>
- </div>
- </header>
+ {/* Filter bar */}
+ <div
+ style={{
+ flexShrink: 0,
+ padding: "8px 24px",
+ borderBottom: "1px solid var(--border)",
+ background: "var(--bg-2)",
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ overflowX: "auto",
+ }}
+ >
+ <SlidersHorizontal
+ size={14}
+ style={{ color: "var(--text-3)", flexShrink: 0 }}
+ />
+ {FILTER_OPTIONS.map(({ key, label, count, color }) => (
+ <FilterPill
+ key={key}
+ active={filter === key}
+ onClick={() => setFilter(key)}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ color: filter === key ? color || "var(--text)" : "var(--text-3)",
+ }}
+ >
+ {count > 0 && (
+ <span
+ style={{
+ width: 18,
+ height: 18,
+ borderRadius: "50%",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ fontSize: 9,
+ background: color ? `${color}30` : "var(--border)",
+ color: color || "var(--text-3)",
+ }}
+ >
+ {count}
+ </span>
+ )}
+ {label}
+ </FilterPill>
+ ))}
+ </div>
- {/* Filter bar */}
- <div
- style={{
- flexShrink: 0,
- padding: "8px 24px",
- borderBottom: "1px solid var(--border)",
- background: "var(--bg-2)",
- display: "flex",
- alignItems: "center",
- gap: 8,
- overflowX: "auto",
- }}
- >
- <SlidersHorizontal
- size={14}
- style={{ color: "var(--text-3)", flexShrink: 0 }}
+ {/* Alert list */}
+ <div style={{ flex: 1, overflowY: "auto", padding: 24 }}>
+ {loading ? (
+ <div
+ style={{
+ maxWidth: 640,
+ margin: "0 auto",
+ }}
+ >
+ <LoadingShimmer count={3} height={80} />
+ </div>
+ ) : filtered.length === 0 ? (
+ <EmptyState
+ icon={CheckCircle2}
+ title={
+ alerts.length === 0
+ ? t("alerts_empty_nodata")
+ : t("alerts_empty_connected")
+ }
/>
- {FILTER_OPTIONS.map(({ key, label, count, color }) => (
- <button
- key={key}
- onClick={() => setFilter(key)}
- style={{
- display: "flex",
- alignItems: "center",
- gap: 6,
- padding: "6px 12px",
- borderRadius: 20,
- fontSize: 12,
- fontFamily: "DM Mono, monospace",
- flexShrink: 0,
- cursor: "pointer",
- background: filter === key ? "var(--surface)" : "transparent",
- border: `1px solid ${filter === key ? "var(--border-bright)" : "transparent"}`,
- color:
- filter === key ? color || "var(--text)" : "var(--text-3)",
- transition: "all 0.15s",
- }}
- >
- {count > 0 && (
- <span
+ ) : (
+ <div
+ style={{
+ maxWidth: 640,
+ margin: "0 auto",
+ display: "flex",
+ flexDirection: "column",
+ gap: 24,
+ }}
+ >
+ {/* Unacked */}
+ {filtered.filter((a) => !a.ack).length > 0 && (
+ <div>
+ <div
style={{
- width: 18,
- height: 18,
- borderRadius: "50%",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- fontSize: 9,
- background: color ? `${color}30` : "var(--border)",
- color: color || "var(--text-3)",
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ marginBottom: 12,
}}
>
- {count}
- </span>
- )}
- {label}
- </button>
- ))}
- </div>
+ {t("alerts_unacked", {
+ n: filtered.filter((a) => !a.ack).length,
+ })}
+ </div>
+ <div
+ style={{ display: "flex", flexDirection: "column", gap: 8 }}
+ >
+ {filtered
+ .filter((a) => !a.ack)
+ .map((a) => (
+ <AlertCard
+ key={a.id}
+ alert={a}
+ onAck={ack}
+ onUnack={unack}
+ onDismiss={dismiss}
+ t={t}
+ td={td}
+ />
+ ))}
+ </div>
+ </div>
+ )}
- {/* Alert list */}
- <div style={{ flex: 1, overflowY: "auto", padding: 24 }}>
- {loading ? (
- <div
- style={{
- maxWidth: 640,
- margin: "0 auto",
- display: "flex",
- flexDirection: "column",
- gap: 12,
- }}
- >
- {[1, 2, 3].map((i) => (
+ {/* Acked */}
+ {showAcked && filtered.filter((a) => a.ack).length > 0 && (
+ <div>
<div
- key={i}
- className="shimmer"
style={{
- height: 80,
- borderRadius: 12,
- border: "1px solid var(--border)",
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ marginBottom: 12,
}}
- />
- ))}
- </div>
- ) : filtered.length === 0 ? (
- <div
- style={{
- display: "flex",
- flexDirection: "column",
- alignItems: "center",
- justifyContent: "center",
- height: "100%",
- gap: 16,
- }}
- >
- <div
- style={{
- width: 56,
- height: 56,
- borderRadius: 16,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- }}
- >
- <CheckCircle2 size={24} style={{ color: "var(--green)" }} />
- </div>
- <div style={{ color: "var(--text-2)" }}>
- {alerts.length === 0
- ? t("alerts_empty_nodata")
- : t("alerts_empty_connected")}
- </div>
- </div>
- ) : (
- <div
- style={{
- maxWidth: 640,
- margin: "0 auto",
- display: "flex",
- flexDirection: "column",
- gap: 24,
- }}
- >
- {/* Unacked */}
- {filtered.filter((a) => !a.ack).length > 0 && (
- <div>
- <div
- style={{
- fontSize: 10,
- fontFamily: "DM Mono, monospace",
- color: "var(--text-3)",
- marginBottom: 12,
- }}
- >
- {t("alerts_unacked", {
- n: filtered.filter((a) => !a.ack).length,
- })}
- </div>
- <div
- style={{ display: "flex", flexDirection: "column", gap: 8 }}
- >
- {filtered
- .filter((a) => !a.ack)
- .map((a) => (
- <AlertCard
- key={a.id}
- alert={a}
- onAck={ack}
- onDismiss={dismiss}
- t={t}
- td={td}
- />
- ))}
- </div>
+ >
+ {t("alerts_acknowledged", {
+ n: filtered.filter((a) => a.ack).length,
+ })}
</div>
- )}
-
- {/* Acked */}
- {showAcked && filtered.filter((a) => a.ack).length > 0 && (
- <div>
- <div
- style={{
- fontSize: 10,
- fontFamily: "DM Mono, monospace",
- color: "var(--text-3)",
- marginBottom: 12,
- }}
- >
- {t("alerts_acknowledged", {
- n: filtered.filter((a) => a.ack).length,
- })}
- </div>
- <div
- style={{ display: "flex", flexDirection: "column", gap: 8 }}
- >
- {filtered
- .filter((a) => a.ack)
- .map((a) => (
- <AlertCard
- key={a.id}
- alert={a}
- onAck={ack}
- onDismiss={dismiss}
- t={t}
- td={td}
- />
- ))}
- </div>
+ <div
+ style={{ display: "flex", flexDirection: "column", gap: 8 }}
+ >
+ {filtered
+ .filter((a) => a.ack)
+ .map((a) => (
+ <AlertCard
+ key={a.id}
+ alert={a}
+ onAck={ack}
+ onUnack={unack}
+ onDismiss={dismiss}
+ t={t}
+ td={td}
+ />
+ ))}
</div>
- )}
- </div>
- )}
- </div>
- </main>
- </div>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ </PageShell>
);
}
diff --git a/frontend/src/pages/Analytics.jsx b/frontend/src/pages/Analytics.jsx
@@ -28,33 +28,12 @@ import {
buildAgentStats,
} from "../utils/dataUtils";
import { TrendingUp, TrendingDown, Minus, Download } from "lucide-react";
-import Sidebar from "../components/Sidebar";
-
-// Shared Tooltip
-const CustomTooltip = ({ active, payload, label }) => {
- if (!active || !payload?.length) return null;
- return (
- <div
- style={{
- padding: "8px 12px",
- borderRadius: 8,
- fontSize: 12,
- fontFamily: "DM Mono, monospace",
- background: "var(--tooltip-bg)",
- border: "1px solid var(--border)",
- color: "var(--text)",
- boxShadow: "var(--shadow)",
- }}
- >
- <div style={{ color: "var(--text-3)", marginBottom: 4 }}>{label}</div>
- {payload.map((p) => (
- <div key={p.dataKey} style={{ color: p.color, marginTop: 2 }}>
- {p.name}: <strong>{p.value}</strong>
- </div>
- ))}
- </div>
- );
-};
+import {
+ PageShell,
+ PageHeader,
+ ChartTooltip,
+ InlineLabel,
+} from "../components/ui";
function MetricCard({ label, value, unit, change, color, loading, t }) {
const up = change > 0;
@@ -126,7 +105,7 @@ function MetricCard({ label, value, unit, change, color, loading, t }) {
function SectionHead({ label, title }) {
return (
<div style={{ marginBottom: 16 }}>
- <div className="section-label">{label}</div>
+ <InlineLabel>{label}</InlineLabel>
<h2
style={{
fontWeight: 700,
@@ -232,339 +211,151 @@ export default function Analytics() {
};
return (
- <div
- style={{
- display: "flex",
- height: "100vh",
- overflow: "hidden",
- background: "var(--bg)",
- }}
- >
- <Sidebar />
-
- <main
- style={{
- flex: 1,
- display: "flex",
- flexDirection: "column",
- overflow: "hidden",
- }}
+ <PageShell>
+ {/* Header */}
+ <PageHeader
+ title={t("analytics_title")}
+ subtitle={
+ loading
+ ? t("common_loading")
+ : t("analytics_subtitle", {
+ points: allPoints.length,
+ crops: dashboard.length,
+ })
+ }
>
- {/* Header */}
- <header
+ <div
style={{
- flexShrink: 0,
- padding: "0 24px",
- height: 64,
- borderBottom: "1px solid var(--border)",
- background: "var(--bg-2)",
+ marginLeft: "auto",
display: "flex",
alignItems: "center",
- gap: 16,
+ gap: 8,
}}
>
- <div>
- <h1 className="page-title">{t("analytics_title")}</h1>
- <p className="page-subtitle">
- {loading
- ? t("common_loading")
- : t("analytics_subtitle", {
- points: allPoints.length,
- crops: dashboard.length,
- })}
- </p>
- </div>
- <div
- style={{
- marginLeft: "auto",
- display: "flex",
- alignItems: "center",
- gap: 8,
- }}
- >
- {["24h", "7d", "30d"].map((r) => (
- <button
- key={r}
- onClick={() => setRange(r)}
- style={{
- padding: "5px 12px",
- borderRadius: 8,
- fontSize: 12,
- fontFamily: "DM Mono, monospace",
- cursor: "pointer",
- background:
- range === r ? "rgba(74,222,128,0.12)" : "var(--surface)",
- border: `1px solid ${range === r ? "rgba(74,222,128,0.3)" : "var(--border)"}`,
- color: range === r ? "var(--green)" : "var(--text-3)",
- }}
- >
- {r}
- </button>
- ))}
+ {["24h", "7d", "30d"].map((r) => (
<button
- onClick={handleExport}
+ key={r}
+ onClick={() => setRange(r)}
style={{
- display: "flex",
- alignItems: "center",
- gap: 6,
padding: "5px 12px",
borderRadius: 8,
fontSize: 12,
fontFamily: "DM Mono, monospace",
- background: "var(--surface)",
- border: "1px solid var(--border)",
- color: "var(--text-3)",
cursor: "pointer",
+ background:
+ range === r ? "rgba(74,222,128,0.12)" : "var(--surface)",
+ border: `1px solid ${range === r ? "rgba(74,222,128,0.3)" : "var(--border)"}`,
+ color: range === r ? "var(--green)" : "var(--text-3)",
}}
>
- <Download size={12} /> {t("analytics_export")}
+ {r}
</button>
- </div>
- </header>
-
- {/* Scrollable content */}
- <div
- style={{
- flex: 1,
- overflowY: "auto",
- padding: 24,
- display: "flex",
- flexDirection: "column",
- gap: 28,
- }}
- >
- {/* Metric Cards */}
- <div
- style={{
- display: "grid",
- gridTemplateColumns: "repeat(4,1fr)",
- gap: 12,
- }}
- >
- <MetricCard
- loading={loading}
- label={t("analytics_avg_ph")}
- value={latestSensors.ph}
- unit=""
- change={safePct(latestSensors.ph, prevSensors.ph)}
- color="var(--green)"
- t={t}
- />
- <MetricCard
- loading={loading}
- label={t("analytics_avg_ec")}
- value={latestSensors.ec}
- unit="dS/m"
- change={safePct(latestSensors.ec, prevSensors.ec)}
- color="var(--amber)"
- t={t}
- />
- <MetricCard
- loading={loading}
- label={t("analytics_avg_temp")}
- value={latestSensors.temp}
- unit="°C"
- change={safePct(latestSensors.temp, prevSensors.temp)}
- color="var(--blue)"
- t={t}
- />
- <MetricCard
- loading={loading}
- label={t("analytics_total_seq")}
- value={allPoints.length}
- unit=""
- change={safePct(
- allPoints.length,
- Math.max(allPoints.length - dashboard.length, 1),
- )}
- color="var(--text)"
- t={t}
- />
- </div>
-
- {/* pH + EC Charts */}
- <div
- style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}
- >
- {[
- {
- title: t("analytics_ph_over_time"),
- key: "ph",
- stroke: "var(--green)",
- gradId: "phGradA",
- gradColor: "#4ade80",
- name: t("chart_ph"),
- },
- {
- title: t("analytics_ec_conc"),
- key: "ec",
- stroke: "var(--amber)",
- gradId: "ecGradA",
- gradColor: "#f59e0b",
- name: t("chart_ec"),
- },
- ].map(({ title, key, stroke, gradId, gradColor, name }) => (
- <div
- key={key}
- style={{
- borderRadius: 14,
- padding: 20,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- }}
- >
- <SectionHead
- label={t("analytics_trace", { range: range.toUpperCase() })}
- title={title}
- />
- {buckets.length < 2 ? (
- <EmptyChart message={t("analytics_no_data_range")} />
- ) : (
- <ResponsiveContainer width="100%" height={180}>
- <AreaChart data={buckets}>
- <defs>
- <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
- <stop
- offset="0%"
- stopColor={gradColor}
- stopOpacity={0.3}
- />
- <stop
- offset="100%"
- stopColor={gradColor}
- stopOpacity={0}
- />
- </linearGradient>
- </defs>
- <CartesianGrid
- stroke="var(--border)"
- strokeDasharray="3 3"
- vertical={false}
- />
- <XAxis
- dataKey="label"
- tick={{
- fontSize: 10,
- fill: "var(--text-3)",
- fontFamily: "DM Mono",
- }}
- axisLine={false}
- tickLine={false}
- interval="preserveStartEnd"
- />
- <YAxis
- domain={["auto", "auto"]}
- tick={{
- fontSize: 10,
- fill: "var(--text-3)",
- fontFamily: "DM Mono",
- }}
- axisLine={false}
- tickLine={false}
- />
- <Tooltip content={<CustomTooltip />} />
- <Area
- type="monotone"
- dataKey={key}
- stroke={stroke}
- fill={`url(#${gradId})`}
- strokeWidth={2}
- dot={false}
- name={name}
- />
- </AreaChart>
- </ResponsiveContainer>
- )}
- </div>
- ))}
- </div>
-
- {/* Temp + Humidity */}
- <div
+ ))}
+ <button
+ onClick={handleExport}
style={{
- borderRadius: 14,
- padding: 20,
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ padding: "5px 12px",
+ borderRadius: 8,
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
background: "var(--surface)",
border: "1px solid var(--border)",
+ color: "var(--text-3)",
+ cursor: "pointer",
}}
>
- <SectionHead
- label={t("analytics_trace", { range: range.toUpperCase() })}
- title={t("analytics_temp_hum")}
- />
- {buckets.length < 2 ? (
- <EmptyChart message={t("analytics_no_data_range")} />
- ) : (
- <ResponsiveContainer width="100%" height={180}>
- <LineChart data={buckets}>
- <CartesianGrid
- stroke="var(--border)"
- strokeDasharray="3 3"
- vertical={false}
- />
- <XAxis
- dataKey="label"
- tick={{
- fontSize: 10,
- fill: "var(--text-3)",
- fontFamily: "DM Mono",
- }}
- axisLine={false}
- tickLine={false}
- interval="preserveStartEnd"
- />
- <YAxis
- yAxisId="left"
- domain={["auto", "auto"]}
- tick={{
- fontSize: 10,
- fill: "var(--text-3)",
- fontFamily: "DM Mono",
- }}
- axisLine={false}
- tickLine={false}
- />
- <YAxis
- yAxisId="right"
- orientation="right"
- domain={["auto", "auto"]}
- tick={{
- fontSize: 10,
- fill: "var(--text-3)",
- fontFamily: "DM Mono",
- }}
- axisLine={false}
- tickLine={false}
- />
- <Tooltip content={<CustomTooltip />} />
- <Line
- yAxisId="left"
- type="monotone"
- dataKey="temp"
- stroke="#60a5fa"
- strokeWidth={2}
- dot={false}
- name={t("chart_temp")}
- />
- <Line
- yAxisId="right"
- type="monotone"
- dataKey="humidity"
- stroke="#a78bfa"
- strokeWidth={2}
- dot={false}
- name={t("chart_humidity")}
- />
- </LineChart>
- </ResponsiveContainer>
+ <Download size={12} /> {t("analytics_export")}
+ </button>
+ </div>
+ </PageHeader>
+
+ {/* Scrollable content */}
+ <div
+ style={{
+ flex: 1,
+ overflowY: "auto",
+ padding: 24,
+ display: "flex",
+ flexDirection: "column",
+ gap: 28,
+ }}
+ >
+ {/* Metric Cards */}
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns: "repeat(4,1fr)",
+ gap: 12,
+ }}
+ >
+ <MetricCard
+ loading={loading}
+ label={t("analytics_avg_ph")}
+ value={latestSensors.ph}
+ unit=""
+ change={safePct(latestSensors.ph, prevSensors.ph)}
+ color="var(--green)"
+ t={t}
+ />
+ <MetricCard
+ loading={loading}
+ label={t("analytics_avg_ec")}
+ value={latestSensors.ec}
+ unit="dS/m"
+ change={safePct(latestSensors.ec, prevSensors.ec)}
+ color="var(--amber)"
+ t={t}
+ />
+ <MetricCard
+ loading={loading}
+ label={t("analytics_avg_temp")}
+ value={latestSensors.temp}
+ unit="°C"
+ change={safePct(latestSensors.temp, prevSensors.temp)}
+ color="var(--blue)"
+ t={t}
+ />
+ <MetricCard
+ loading={loading}
+ label={t("analytics_total_seq")}
+ value={allPoints.length}
+ unit=""
+ change={safePct(
+ allPoints.length,
+ Math.max(allPoints.length - dashboard.length, 1),
)}
- </div>
+ color="var(--text)"
+ t={t}
+ />
+ </div>
- {/* Activity + Radar */}
- <div
- style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}
- >
+ {/* pH + EC Charts */}
+ <div
+ style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}
+ >
+ {[
+ {
+ title: t("analytics_ph_over_time"),
+ key: "ph",
+ stroke: "var(--green)",
+ gradId: "phGradA",
+ gradColor: "#4ade80",
+ name: t("chart_ph"),
+ },
+ {
+ title: t("analytics_ec_conc"),
+ key: "ec",
+ stroke: "var(--amber)",
+ gradId: "ecGradA",
+ gradColor: "#f59e0b",
+ name: t("chart_ec"),
+ },
+ ].map(({ title, key, stroke, gradId, gradColor, name }) => (
<div
+ key={key}
style={{
borderRadius: 14,
padding: 20,
@@ -573,24 +364,35 @@ export default function Analytics() {
}}
>
<SectionHead
- label={t("analytics_daily_act")}
- title={t("analytics_seq_per_day")}
+ label={t("analytics_trace", { range: range.toUpperCase() })}
+ title={title}
/>
- {activityData.length < 2 ? (
- <EmptyChart
- height={180}
- message={t("analytics_no_data_days")}
- />
+ {buckets.length < 2 ? (
+ <EmptyChart message={t("analytics_no_data_range")} />
) : (
<ResponsiveContainer width="100%" height={180}>
- <BarChart data={activityData} barGap={4}>
+ <AreaChart data={buckets}>
+ <defs>
+ <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
+ <stop
+ offset="0%"
+ stopColor={gradColor}
+ stopOpacity={0.3}
+ />
+ <stop
+ offset="100%"
+ stopColor={gradColor}
+ stopOpacity={0}
+ />
+ </linearGradient>
+ </defs>
<CartesianGrid
stroke="var(--border)"
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
- dataKey="d"
+ dataKey="label"
tick={{
fontSize: 10,
fill: "var(--text-3)",
@@ -598,8 +400,10 @@ export default function Analytics() {
}}
axisLine={false}
tickLine={false}
+ interval="preserveStartEnd"
/>
<YAxis
+ domain={["auto", "auto"]}
tick={{
fontSize: 10,
fill: "var(--text-3)",
@@ -607,244 +411,257 @@ export default function Analytics() {
}}
axisLine={false}
tickLine={false}
- allowDecimals={false}
/>
- <Tooltip content={<CustomTooltip />} />
- <Bar
- dataKey="count"
- fill="#2d7a44"
- radius={[4, 4, 0, 0]}
- name={t("chart_sequences")}
+ <Tooltip content={<ChartTooltip />} />
+ <Area
+ type="monotone"
+ dataKey={key}
+ stroke={stroke}
+ fill={`url(#${gradId})`}
+ strokeWidth={2}
+ dot={false}
+ name={name}
/>
- </BarChart>
+ </AreaChart>
</ResponsiveContainer>
)}
</div>
+ ))}
+ </div>
- <div
- style={{
- borderRadius: 14,
- padding: 20,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- }}
- >
- <SectionHead
- label={t("analytics_param_health")}
- title={t("analytics_in_range_score")}
- />
- {radarData.length < 2 ? (
- <EmptyChart
- height={180}
- message={t("analytics_no_data_points")}
+ {/* Temp + Humidity */}
+ <div
+ style={{
+ borderRadius: 14,
+ padding: 20,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ }}
+ >
+ <SectionHead
+ label={t("analytics_trace", { range: range.toUpperCase() })}
+ title={t("analytics_temp_hum")}
+ />
+ {buckets.length < 2 ? (
+ <EmptyChart message={t("analytics_no_data_range")} />
+ ) : (
+ <ResponsiveContainer width="100%" height={180}>
+ <LineChart data={buckets}>
+ <CartesianGrid
+ stroke="var(--border)"
+ strokeDasharray="3 3"
+ vertical={false}
/>
- ) : (
- <ResponsiveContainer width="100%" height={180}>
- <RadarChart
- data={radarData}
- cx="50%"
- cy="50%"
- outerRadius="65%"
- >
- <PolarGrid stroke="var(--border)" />
- <PolarAngleAxis
- dataKey="metric"
- tick={{
- fontSize: 11,
- fill: "var(--text-3)",
- fontFamily: "DM Mono",
- }}
- />
- <Radar
- dataKey="value"
- stroke="var(--green)"
- fill="rgba(74,222,128,0.15)"
- strokeWidth={2}
- name={t("chart_in_range")}
- />
- <Tooltip content={<CustomTooltip />} />
- </RadarChart>
- </ResponsiveContainer>
- )}
- </div>
- </div>
+ <XAxis
+ dataKey="label"
+ tick={{
+ fontSize: 10,
+ fill: "var(--text-3)",
+ fontFamily: "DM Mono",
+ }}
+ axisLine={false}
+ tickLine={false}
+ interval="preserveStartEnd"
+ />
+ <YAxis
+ yAxisId="left"
+ domain={["auto", "auto"]}
+ tick={{
+ fontSize: 10,
+ fill: "var(--text-3)",
+ fontFamily: "DM Mono",
+ }}
+ axisLine={false}
+ tickLine={false}
+ />
+ <YAxis
+ yAxisId="right"
+ orientation="right"
+ domain={["auto", "auto"]}
+ tick={{
+ fontSize: 10,
+ fill: "var(--text-3)",
+ fontFamily: "DM Mono",
+ }}
+ axisLine={false}
+ tickLine={false}
+ />
+ <Tooltip content={<ChartTooltip />} />
+ <Line
+ yAxisId="left"
+ type="monotone"
+ dataKey="temp"
+ stroke="#60a5fa"
+ strokeWidth={2}
+ dot={false}
+ name={t("chart_temp")}
+ />
+ <Line
+ yAxisId="right"
+ type="monotone"
+ dataKey="humidity"
+ stroke="#a78bfa"
+ strokeWidth={2}
+ dot={false}
+ name={t("chart_humidity")}
+ />
+ </LineChart>
+ </ResponsiveContainer>
+ )}
+ </div>
- {/* Crop Summary Table */}
+ {/* Activity + Radar */}
+ <div
+ style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}
+ >
<div
style={{
borderRadius: 14,
- overflow: "hidden",
+ padding: 20,
background: "var(--surface)",
border: "1px solid var(--border)",
- flexShrink: 0,
}}
>
- <div
- style={{
- padding: "16px 20px",
- borderBottom: "1px solid var(--border)",
- }}
- >
- <SectionHead
- label={t("analytics_per_crop")}
- title={t("analytics_latest_sensor")}
- />
- </div>
- {loading ? (
- <div style={{ padding: 32, textAlign: "center" }}>
- <span
- style={{
- fontSize: 12,
- fontFamily: "DM Mono, monospace",
- color: "var(--text-3)",
- }}
- >
- {t("common_loading")}
- </span>
- </div>
- ) : cropSummaryRows.length === 0 ? (
- <div
- style={{
- padding: 32,
- textAlign: "center",
- fontSize: 12,
- fontFamily: "DM Mono, monospace",
- color: "var(--text-3)",
- }}
- >
- {t("dash_no_crops")}
- </div>
+ <SectionHead
+ label={t("analytics_daily_act")}
+ title={t("analytics_seq_per_day")}
+ />
+ {activityData.length < 2 ? (
+ <EmptyChart height={180} message={t("analytics_no_data_days")} />
) : (
- <table
- className="data-table"
- style={{ width: "100%", borderCollapse: "collapse" }}
- >
- <thead>
- <tr>
- {[
- t("analytics_th_crop_id"),
- t("analytics_th_type"),
- t("analytics_th_stage"),
- t("analytics_th_ph"),
- t("analytics_th_ec"),
- t("analytics_th_temp"),
- t("analytics_th_seq"),
- ].map((h) => (
- <th key={h}>{h}</th>
- ))}
- </tr>
- </thead>
- <tbody>
- {cropSummaryRows.map(({ p, s, key }) => (
- <tr
- key={key}
- style={{ transition: "background 0.12s" }}
- onMouseEnter={(e) =>
- (e.currentTarget.style.background = "var(--bg-3)")
- }
- onMouseLeave={(e) =>
- (e.currentTarget.style.background = "transparent")
- }
- >
- <td
- style={{
- fontFamily: "DM Mono, monospace",
- fontSize: 12,
- color: "var(--text)",
- fontWeight: 600,
- }}
- >
- {p.crop_id || "—"}
- </td>
- <td>{td(p.crop) || "—"}</td>
- <td
- style={{
- color: "var(--text-3)",
- fontSize: 12,
- fontFamily: "DM Mono, monospace",
- }}
- >
- {td(p.stage) || "—"}
- </td>
- <td>
- <span
- className="sensor-value-xs"
- style={{ color: "var(--green)" }}
- >
- {s.ph}
- </span>
- </td>
- <td>
- <span
- className="sensor-value-xs"
- style={{ color: "var(--amber)" }}
- >
- {s.ec}
- </span>
- <span
- style={{
- fontSize: 11,
- color: "var(--text-3)",
- marginLeft: 3,
- }}
- >
- dS/m
- </span>
- </td>
- <td>
- <span
- className="sensor-value-xs"
- style={{ color: "var(--blue)" }}
- >
- {s.temp}
- </span>
- <span
- style={{
- fontSize: 11,
- color: "var(--text-3)",
- marginLeft: 2,
- }}
- >
- °C
- </span>
- </td>
- <td
- style={{
- fontFamily: "DM Mono, monospace",
- fontSize: 13,
- color: "var(--text-2)",
- }}
- >
- {p.sequence_number || 1}
- </td>
- </tr>
- ))}
- </tbody>
- </table>
+ <ResponsiveContainer width="100%" height={180}>
+ <BarChart data={activityData} barGap={4}>
+ <CartesianGrid
+ stroke="var(--border)"
+ strokeDasharray="3 3"
+ vertical={false}
+ />
+ <XAxis
+ dataKey="d"
+ tick={{
+ fontSize: 10,
+ fill: "var(--text-3)",
+ fontFamily: "DM Mono",
+ }}
+ axisLine={false}
+ tickLine={false}
+ />
+ <YAxis
+ tick={{
+ fontSize: 10,
+ fill: "var(--text-3)",
+ fontFamily: "DM Mono",
+ }}
+ axisLine={false}
+ tickLine={false}
+ allowDecimals={false}
+ />
+ <Tooltip content={<ChartTooltip />} />
+ <Bar
+ dataKey="count"
+ fill="#2d7a44"
+ radius={[4, 4, 0, 0]}
+ name={t("chart_sequences")}
+ />
+ </BarChart>
+ </ResponsiveContainer>
)}
</div>
- {/* Agent Activity Table */}
<div
style={{
borderRadius: 14,
- overflow: "hidden",
+ padding: 20,
background: "var(--surface)",
border: "1px solid var(--border)",
- flexShrink: 0,
}}
>
+ <SectionHead
+ label={t("analytics_param_health")}
+ title={t("analytics_in_range_score")}
+ />
+ {radarData.length < 2 ? (
+ <EmptyChart
+ height={180}
+ message={t("analytics_no_data_points")}
+ />
+ ) : (
+ <ResponsiveContainer width="100%" height={180}>
+ <RadarChart
+ data={radarData}
+ cx="50%"
+ cy="50%"
+ outerRadius="65%"
+ >
+ <PolarGrid stroke="var(--border)" />
+ <PolarAngleAxis
+ dataKey="metric"
+ tick={{
+ fontSize: 11,
+ fill: "var(--text-3)",
+ fontFamily: "DM Mono",
+ }}
+ />
+ <Radar
+ dataKey="value"
+ stroke="var(--green)"
+ fill="rgba(74,222,128,0.15)"
+ strokeWidth={2}
+ name={t("chart_in_range")}
+ />
+ <Tooltip content={<ChartTooltip />} />
+ </RadarChart>
+ </ResponsiveContainer>
+ )}
+ </div>
+ </div>
+
+ {/* Crop Summary Table */}
+ <div
+ style={{
+ borderRadius: 14,
+ overflow: "hidden",
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ flexShrink: 0,
+ }}
+ >
+ <div
+ style={{
+ padding: "16px 20px",
+ borderBottom: "1px solid var(--border)",
+ }}
+ >
+ <SectionHead
+ label={t("analytics_per_crop")}
+ title={t("analytics_latest_sensor")}
+ />
+ </div>
+ {loading ? (
+ <div style={{ padding: 32, textAlign: "center" }}>
+ <span
+ style={{
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ }}
+ >
+ {t("common_loading")}
+ </span>
+ </div>
+ ) : cropSummaryRows.length === 0 ? (
<div
style={{
- padding: "16px 20px",
- borderBottom: "1px solid var(--border)",
+ padding: 32,
+ textAlign: "center",
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
}}
>
- <SectionHead
- label={t("analytics_derived_act")}
- title={t("analytics_agent_act")}
- />
+ {t("dash_no_crops")}
</div>
+ ) : (
<table
className="data-table"
style={{ width: "100%", borderCollapse: "collapse" }}
@@ -852,107 +669,236 @@ export default function Analytics() {
<thead>
<tr>
{[
- t("analytics_th_agent"),
- t("analytics_th_apps"),
- t("analytics_th_success"),
- t("analytics_th_status"),
+ t("analytics_th_crop_id"),
+ t("analytics_th_type"),
+ t("analytics_th_stage"),
+ t("analytics_th_ph"),
+ t("analytics_th_ec"),
+ t("analytics_th_temp"),
+ t("analytics_th_seq"),
].map((h) => (
<th key={h}>{h}</th>
))}
</tr>
</thead>
<tbody>
- {agentRows.map(({ name, decisions, accuracy }) => (
+ {cropSummaryRows.map(({ p, s, key }) => (
<tr
- key={name}
+ key={key}
+ style={{ transition: "background 0.12s" }}
onMouseEnter={(e) =>
(e.currentTarget.style.background = "var(--bg-3)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = "transparent")
}
- style={{ transition: "background 0.12s" }}
>
<td
style={{
fontFamily: "DM Mono, monospace",
- fontWeight: 600,
- fontSize: 13,
+ fontSize: 12,
color: "var(--text)",
+ fontWeight: 600,
}}
>
- {name}
+ {p.crop_id || "—"}
</td>
+ <td>{td(p.crop) || "—"}</td>
<td
- style={{ fontFamily: "DM Mono, monospace", fontSize: 13 }}
+ style={{
+ color: "var(--text-3)",
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ }}
>
- {decisions}
+ {td(p.stage) || "—"}
</td>
<td>
- <div
+ <span
+ className="sensor-value-xs"
+ style={{ color: "var(--green)" }}
+ >
+ {s.ph}
+ </span>
+ </td>
+ <td>
+ <span
+ className="sensor-value-xs"
+ style={{ color: "var(--amber)" }}
+ >
+ {s.ec}
+ </span>
+ <span
style={{
- display: "flex",
- alignItems: "center",
- gap: 10,
+ fontSize: 11,
+ color: "var(--text-3)",
+ marginLeft: 3,
}}
>
- <div
- style={{
- height: 6,
- width: 100,
- borderRadius: 3,
- background: "var(--border)",
- }}
- >
- <div
- style={{
- height: "100%",
- borderRadius: 3,
- width: `${accuracy}%`,
- background:
- accuracy > 80
- ? "var(--green)"
- : accuracy > 50
- ? "var(--amber)"
- : "var(--red)",
- transition: "width 0.6s ease",
- }}
- />
- </div>
- <span
- style={{
- fontFamily: "DM Mono, monospace",
- fontSize: 13,
- color: "var(--text-2)",
- minWidth: 36,
- }}
- >
- {accuracy}%
- </span>
- </div>
+ dS/m
+ </span>
</td>
<td>
<span
+ className="sensor-value-xs"
+ style={{ color: "var(--blue)" }}
+ >
+ {s.temp}
+ </span>
+ <span
style={{
fontSize: 11,
- fontFamily: "DM Mono, monospace",
- padding: "3px 10px",
- borderRadius: 20,
- background: "rgba(74,222,128,0.1)",
- color: "var(--green)",
- border: "1px solid rgba(74,222,128,0.2)",
+ color: "var(--text-3)",
+ marginLeft: 2,
}}
>
- {t("analytics_online")}
+ °C
</span>
</td>
+ <td
+ style={{
+ fontFamily: "DM Mono, monospace",
+ fontSize: 13,
+ color: "var(--text-2)",
+ }}
+ >
+ {p.sequence_number || 1}
+ </td>
</tr>
))}
</tbody>
</table>
+ )}
+ </div>
+
+ {/* Agent Activity Table */}
+ <div
+ style={{
+ borderRadius: 14,
+ overflow: "hidden",
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ flexShrink: 0,
+ }}
+ >
+ <div
+ style={{
+ padding: "16px 20px",
+ borderBottom: "1px solid var(--border)",
+ }}
+ >
+ <SectionHead
+ label={t("analytics_derived_act")}
+ title={t("analytics_agent_act")}
+ />
</div>
+ <table
+ className="data-table"
+ style={{ width: "100%", borderCollapse: "collapse" }}
+ >
+ <thead>
+ <tr>
+ {[
+ t("analytics_th_agent"),
+ t("analytics_th_apps"),
+ t("analytics_th_success"),
+ t("analytics_th_status"),
+ ].map((h) => (
+ <th key={h}>{h}</th>
+ ))}
+ </tr>
+ </thead>
+ <tbody>
+ {agentRows.map(({ name, decisions, accuracy }) => (
+ <tr
+ key={name}
+ onMouseEnter={(e) =>
+ (e.currentTarget.style.background = "var(--bg-3)")
+ }
+ onMouseLeave={(e) =>
+ (e.currentTarget.style.background = "transparent")
+ }
+ style={{ transition: "background 0.12s" }}
+ >
+ <td
+ style={{
+ fontFamily: "DM Mono, monospace",
+ fontWeight: 600,
+ fontSize: 13,
+ color: "var(--text)",
+ }}
+ >
+ {name}
+ </td>
+ <td
+ style={{ fontFamily: "DM Mono, monospace", fontSize: 13 }}
+ >
+ {decisions}
+ </td>
+ <td>
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 10,
+ }}
+ >
+ <div
+ style={{
+ height: 6,
+ width: 100,
+ borderRadius: 3,
+ background: "var(--border)",
+ }}
+ >
+ <div
+ style={{
+ height: "100%",
+ borderRadius: 3,
+ width: `${accuracy}%`,
+ background:
+ accuracy > 80
+ ? "var(--green)"
+ : accuracy > 50
+ ? "var(--amber)"
+ : "var(--red)",
+ transition: "width 0.6s ease",
+ }}
+ />
+ </div>
+ <span
+ style={{
+ fontFamily: "DM Mono, monospace",
+ fontSize: 13,
+ color: "var(--text-2)",
+ minWidth: 36,
+ }}
+ >
+ {accuracy}%
+ </span>
+ </div>
+ </td>
+ <td>
+ <span
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ padding: "3px 10px",
+ borderRadius: 20,
+ background: "rgba(74,222,128,0.1)",
+ color: "var(--green)",
+ border: "1px solid rgba(74,222,128,0.2)",
+ }}
+ >
+ {t("analytics_online")}
+ </span>
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
</div>
- </main>
- </div>
+ </div>
+ </PageShell>
);
}
diff --git a/frontend/src/pages/CropDetails.jsx b/frontend/src/pages/CropDetails.jsx
@@ -32,35 +32,16 @@ import {
AgentActionWidget,
AgentOutcomeWidget,
} from "../components/AgentWidgets";
-import Sidebar from "../components/Sidebar";
+import {
+ PageShell,
+ PageHeader,
+ IconButton,
+ ChartTooltip,
+ InlineLabel,
+} from "../components/ui";
import { useSettings } from "../hooks/useSettings";
import { useT } from "../hooks/useTranslation";
-const CustomTooltip = ({ active, payload, label }) => {
- if (!active || !payload?.length) return null;
- return (
- <div
- style={{
- padding: "8px 12px",
- borderRadius: 8,
- fontSize: 12,
- fontFamily: "DM Mono, monospace",
- background: "var(--tooltip-bg)",
- border: "1px solid var(--border)",
- color: "var(--text)",
- boxShadow: "var(--shadow)",
- }}
- >
- <div style={{ color: "var(--text-3)", marginBottom: 4 }}>{label}</div>
- {payload.map((p) => (
- <div key={p.dataKey} style={{ color: p.color, marginTop: 2 }}>
- {p.name}: <strong>{p.value}</strong>
- </div>
- ))}
- </div>
- );
-};
-
function StatBox({ icon: Icon, label, value, color, unit }) {
return (
<div
@@ -152,9 +133,9 @@ function ExplanationLogBlock({ log, t, td }) {
>
<Brain size={14} style={{ color: "#a78bfa" }} />
</div>
- <div className="section-label" style={{ marginBottom: 0 }}>
+ <InlineLabel style={{ marginBottom: 0 }}>
{t("details_ai_reasoning")}
- </div>
+ </InlineLabel>
<span
style={{
fontSize: 9,
@@ -228,9 +209,9 @@ function ExplanationLogBlock({ log, t, td }) {
<Sparkles size={12} style={{ color: "#a78bfa" }} />
</div>
<div style={{ flex: 1 }}>
- <div className="section-label" style={{ marginBottom: 0 }}>
+ <InlineLabel style={{ marginBottom: 0 }}>
{t("details_ai_reasoning")}
- </div>
+ </InlineLabel>
<div
style={{
fontSize: 10,
@@ -424,7 +405,6 @@ export default function CropDetails() {
const [latest, setLatest] = useState(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState("details_tab_overview");
- const [showExp, setShowExp] = useState(false);
useEffect(() => {
fetchCropDetails(cropId).then((data) => {
@@ -448,10 +428,7 @@ export default function CropDetails() {
if (loading)
return (
- <div
- style={{ display: "flex", height: "100vh", background: "var(--bg)" }}
- >
- <Sidebar />
+ <PageShell>
<div
style={{
flex: 1,
@@ -470,27 +447,20 @@ export default function CropDetails() {
{t("common_loading")}
</span>
</div>
- </div>
+ </PageShell>
);
if (!latest)
return (
<div
- style={{ display: "flex", height: "100vh", background: "var(--bg)" }}
+ style={{
+ flex: 1,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
>
- <Sidebar />
- <div
- style={{
- flex: 1,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- }}
- >
- <span style={{ color: "var(--text-3)" }}>
- {t("details_not_found")}
- </span>
- </div>
+ <span style={{ color: "var(--text-3)" }}>{t("details_not_found")}</span>
</div>
);
@@ -511,273 +481,201 @@ export default function CropDetails() {
}));
return (
- <div
- style={{
- display: "flex",
- height: "100vh",
- overflow: "hidden",
- background: "var(--bg)",
- }}
- >
- <Sidebar />
+ <PageShell>
+ {/* Header */}
+ <PageHeader>
+ <IconButton onClick={() => navigate("/dashboard")}>
+ <ArrowLeft size={15} />
+ </IconButton>
- <main
- style={{
- flex: 1,
- display: "flex",
- flexDirection: "column",
- overflow: "hidden",
- }}
- >
- {/* Header */}
- <header
+ <div>
+ <h1 className="page-title">
+ {td(p.crop) || t("common_unknown")}{" "}
+ <span
+ style={{
+ color: "var(--text-3)",
+ fontWeight: 400,
+ fontSize: 16,
+ }}
+ >
+ #{p.sequence_number || 0}
+ </span>
+ </h1>
+ <p className="page-subtitle">
+ {cropId} · {td(p.stage)}
+ </p>
+ </div>
+
+ {/* Live badge */}
+ <div
style={{
- flexShrink: 0,
- padding: "0 24px",
- height: 64,
- borderBottom: "1px solid var(--border)",
- background: "var(--bg-2)",
display: "flex",
alignItems: "center",
- gap: 16,
+ gap: 6,
+ padding: "4px 10px",
+ borderRadius: 20,
+ background: "rgba(74,222,128,0.1)",
+ border: "1px solid rgba(74,222,128,0.25)",
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--green)",
}}
>
- <button
- onClick={() => navigate("/dashboard")}
+ <span
+ className="status-dot"
style={{
- width: 34,
- height: 34,
- borderRadius: 8,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- color: "var(--text-3)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- cursor: "pointer",
+ width: 6,
+ height: 6,
+ borderRadius: "50%",
+ background: "var(--green)",
}}
- >
- <ArrowLeft size={15} />
- </button>
-
- <div>
- <h1 className="page-title">
- {td(p.crop) || t("common_unknown")}{" "}
- <span
- style={{
- color: "var(--text-3)",
- fontWeight: 400,
- fontSize: 16,
- }}
- >
- #{p.sequence_number || 0}
- </span>
- </h1>
- <p className="page-subtitle">
- {cropId} · {td(p.stage)}
- </p>
- </div>
+ />
+ {t("details_live")}
+ </div>
- {/* Live badge */}
- <div
- style={{
- display: "flex",
- alignItems: "center",
- gap: 6,
- padding: "4px 10px",
- borderRadius: 20,
- background: "rgba(74,222,128,0.1)",
- border: "1px solid rgba(74,222,128,0.25)",
- fontSize: 11,
- fontFamily: "DM Mono, monospace",
- color: "var(--green)",
- }}
- >
- <span
- className="status-dot"
+ {/* Tabs */}
+ <div style={{ marginLeft: "auto", display: "flex", gap: 4 }}>
+ {TABS.map((tab) => (
+ <button
+ key={tab}
+ onClick={() => setActiveTab(tab)}
style={{
- width: 6,
- height: 6,
- borderRadius: "50%",
- background: "var(--green)",
+ padding: "5px 14px",
+ borderRadius: 8,
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ textTransform: "capitalize",
+ cursor: "pointer",
+ background:
+ activeTab === tab ? "var(--surface-2)" : "transparent",
+ color: activeTab === tab ? "var(--text)" : "var(--text-3)",
+ border: `1px solid ${activeTab === tab ? "var(--border-bright)" : "transparent"}`,
}}
- />
- {t("details_live")}
- </div>
-
- {/* Tabs */}
- <div style={{ marginLeft: "auto", display: "flex", gap: 4 }}>
- {TABS.map((tab) => (
- <button
- key={tab}
- onClick={() => setActiveTab(tab)}
- style={{
- padding: "5px 14px",
- borderRadius: 8,
- fontSize: 12,
- fontFamily: "DM Mono, monospace",
- textTransform: "capitalize",
- cursor: "pointer",
- background:
- activeTab === tab ? "var(--surface-2)" : "transparent",
- color: activeTab === tab ? "var(--text)" : "var(--text-3)",
- border: `1px solid ${activeTab === tab ? "var(--border-bright)" : "transparent"}`,
- }}
- >
- {t(tab)}
- </button>
- ))}
- </div>
- </header>
-
- {/* Content */}
- <div
- style={{
- flex: 1,
- overflowY: "auto",
- padding: 24,
- display: "flex",
- flexDirection: "column",
- gap: 20,
- }}
- >
- {activeTab === "details_tab_overview" && (
- <>
- {/* Sensor stats */}
- <div
- style={{
- display: "grid",
- gridTemplateColumns: "repeat(4,1fr)",
- gap: 12,
- }}
- >
- <StatBox
- icon={Thermometer}
- label={t("sensor_temp")}
- value={formatNumber(sensors.temp)}
- unit="°C"
- color="var(--blue)"
- />
- <StatBox
- icon={Droplet}
- label={t("sensor_ph")}
- value={formatNumber(sensors.ph)}
- unit=""
- color="var(--green)"
- />
- <StatBox
- icon={Activity}
- label={t("sensor_ec")}
- value={formatNumber(sensors.ec)}
- unit="dS/m"
- color="var(--amber)"
- />
- <StatBox
- icon={Wind}
- label={t("sensor_humidity")}
- value={formatNumber(sensors.humidity)}
- unit="%"
- color="#a78bfa"
- />
- </div>
+ >
+ {t(tab)}
+ </button>
+ ))}
+ </div>
+ </PageHeader>
- {/* Outcome */}
- {p.outcome && p.outcome !== "PENDING_OBSERVATION" && (
- <AgentOutcomeWidget
- outcome={p.outcome}
- rewardScore={p.reward_score}
- strategicIntent={p.strategic_intent}
- />
- )}
- <ExplanationLogBlock log={p.explanation_log} t={t} td={td} />
- <div
- style={{
- borderRadius: 14,
- padding: 20,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- }}
- >
- <div className="section-label">{t("details_hist_ph")}</div>
- <ResponsiveContainer width="100%" height={200}>
- <AreaChart data={chartData}>
- <defs>
- <linearGradient id="phGradCD" x1="0" y1="0" x2="0" y2="1">
- <stop
- offset="0%"
- stopColor="#4ade80"
- stopOpacity={0.3}
- />
- <stop
- offset="100%"
- stopColor="#4ade80"
- stopOpacity={0}
- />
- </linearGradient>
- </defs>
- <CartesianGrid
- stroke="var(--border)"
- strokeDasharray="3 3"
- vertical={false}
- />
- <XAxis
- dataKey="t"
- tick={{
- fontSize: 10,
- fill: "var(--text-3)",
- fontFamily: "DM Mono",
- }}
- axisLine={false}
- tickLine={false}
- />
- <YAxis
- domain={["auto", "auto"]}
- tick={{
- fontSize: 10,
- fill: "var(--text-3)",
- fontFamily: "DM Mono",
- }}
- axisLine={false}
- tickLine={false}
- />
- <Tooltip content={<CustomTooltip />} />
- <Area
- type="monotone"
- dataKey="ph"
- stroke="var(--green)"
- fill="url(#phGradCD)"
- strokeWidth={2}
- dot={false}
- name={t("chart_ph")}
- />
- </AreaChart>
- </ResponsiveContainer>
- </div>
+ {/* Content */}
+ <div
+ style={{
+ flex: 1,
+ overflowY: "auto",
+ padding: 24,
+ display: "flex",
+ flexDirection: "column",
+ gap: 20,
+ }}
+ >
+ {activeTab === "details_tab_overview" && (
+ <>
+ {/* Sensor stats */}
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns: "repeat(4,1fr)",
+ gap: 12,
+ }}
+ >
+ <StatBox
+ icon={Thermometer}
+ label={t("sensor_temp")}
+ value={formatNumber(sensors.temp)}
+ unit="°C"
+ color="var(--blue)"
+ />
+ <StatBox
+ icon={Droplet}
+ label={t("sensor_ph")}
+ value={formatNumber(sensors.ph)}
+ unit=""
+ color="var(--green)"
+ />
+ <StatBox
+ icon={Activity}
+ label={t("sensor_ec")}
+ value={formatNumber(sensors.ec)}
+ unit="dS/m"
+ color="var(--amber)"
+ />
+ <StatBox
+ icon={Wind}
+ label={t("sensor_humidity")}
+ value={formatNumber(sensors.humidity)}
+ unit="%"
+ color="#a78bfa"
+ />
+ </div>
- {/* Latest Action */}
- {p.action_taken && p.action_taken !== "PENDING_ACTION" && (
- <div
- style={{
- borderRadius: 14,
- padding: 20,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- }}
- >
- <div className="section-label" style={{ marginBottom: 16 }}>
- {t("details_latest_cmd")}
- </div>
- <AgentActionWidget
- actionTaken={p.action_taken}
- compact={false}
+ {/* Outcome */}
+ {p.outcome && p.outcome !== "PENDING_OBSERVATION" && (
+ <AgentOutcomeWidget
+ outcome={p.outcome}
+ rewardScore={p.reward_score}
+ strategicIntent={p.strategic_intent}
+ />
+ )}
+ <ExplanationLogBlock log={p.explanation_log} t={t} td={td} />
+ <div
+ style={{
+ borderRadius: 14,
+ padding: 20,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ }}
+ >
+ <InlineLabel>{t("details_hist_ph")}</InlineLabel>
+ <ResponsiveContainer width="100%" height={200}>
+ <AreaChart data={chartData}>
+ <defs>
+ <linearGradient id="phGradCD" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0%" stopColor="#4ade80" stopOpacity={0.3} />
+ <stop offset="100%" stopColor="#4ade80" stopOpacity={0} />
+ </linearGradient>
+ </defs>
+ <CartesianGrid
+ stroke="var(--border)"
+ strokeDasharray="3 3"
+ vertical={false}
/>
- </div>
- )}
- </>
- )}
+ <XAxis
+ dataKey="t"
+ tick={{
+ fontSize: 10,
+ fill: "var(--text-3)",
+ fontFamily: "DM Mono",
+ }}
+ axisLine={false}
+ tickLine={false}
+ />
+ <YAxis
+ domain={["auto", "auto"]}
+ tick={{
+ fontSize: 10,
+ fill: "var(--text-3)",
+ fontFamily: "DM Mono",
+ }}
+ axisLine={false}
+ tickLine={false}
+ />
+ <Tooltip content={<ChartTooltip />} />
+ <Area
+ type="monotone"
+ dataKey="ph"
+ stroke="var(--green)"
+ fill="url(#phGradCD)"
+ strokeWidth={2}
+ dot={false}
+ name={t("chart_ph")}
+ />
+ </AreaChart>
+ </ResponsiveContainer>
+ </div>
- {activeTab === "details_tab_sensors" && (
- <>
+ {/* Latest Action */}
+ {p.action_taken && p.action_taken !== "PENDING_ACTION" && (
<div
style={{
borderRadius: 14,
@@ -786,368 +684,385 @@ export default function CropDetails() {
border: "1px solid var(--border)",
}}
>
- <div className="section-label">{t("details_temp_hum")}</div>
- <ResponsiveContainer width="100%" height={200}>
- <LineChart data={chartData}>
- <CartesianGrid
- stroke="var(--border)"
- strokeDasharray="3 3"
- vertical={false}
- />
- <XAxis
- dataKey="t"
- tick={{
- fontSize: 10,
- fill: "var(--text-3)",
- fontFamily: "DM Mono",
- }}
- axisLine={false}
- tickLine={false}
- />
- <YAxis
- tick={{
- fontSize: 10,
- fill: "var(--text-3)",
- fontFamily: "DM Mono",
- }}
- axisLine={false}
- tickLine={false}
- />
- <Tooltip content={<CustomTooltip />} />
- <Line
- type="monotone"
- dataKey="temp"
- stroke="#60a5fa"
- strokeWidth={2}
- dot={false}
- name={t("chart_temp")}
- />
- <Line
- type="monotone"
- dataKey="humidity"
- stroke="#a78bfa"
- strokeWidth={2}
- dot={false}
- name={t("chart_humidity")}
- />
- </LineChart>
- </ResponsiveContainer>
+ <InlineLabel style={{ marginBottom: 16 }}>
+ {t("details_latest_cmd")}
+ </InlineLabel>
+ <AgentActionWidget
+ actionTaken={p.action_taken}
+ compact={false}
+ />
</div>
+ )}
+ </>
+ )}
- <div
- style={{
- borderRadius: 14,
- padding: 20,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- }}
- >
- <div className="section-label">{t("details_ec_conc")}</div>
- <ResponsiveContainer width="100%" height={180}>
- <AreaChart data={chartData}>
- <defs>
- <linearGradient id="ecGradCD" x1="0" y1="0" x2="0" y2="1">
- <stop
- offset="0%"
- stopColor="#f59e0b"
- stopOpacity={0.25}
- />
- <stop
- offset="100%"
- stopColor="#f59e0b"
- stopOpacity={0}
- />
- </linearGradient>
- </defs>
- <CartesianGrid
- stroke="var(--border)"
- strokeDasharray="3 3"
- vertical={false}
- />
- <XAxis
- dataKey="t"
- tick={{
- fontSize: 10,
- fill: "var(--text-3)",
- fontFamily: "DM Mono",
- }}
- axisLine={false}
- tickLine={false}
- />
- <YAxis
- tick={{
- fontSize: 10,
- fill: "var(--text-3)",
- fontFamily: "DM Mono",
- }}
- axisLine={false}
- tickLine={false}
- />
- <Tooltip content={<CustomTooltip />} />
- <Area
- type="monotone"
- dataKey="ec"
- stroke="var(--amber)"
- fill="url(#ecGradCD)"
- strokeWidth={2}
- dot={false}
- name={t("chart_ec")}
- />
- </AreaChart>
- </ResponsiveContainer>
- </div>
- </>
- )}
+ {activeTab === "details_tab_sensors" && (
+ <>
+ <div
+ style={{
+ borderRadius: 14,
+ padding: 20,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ }}
+ >
+ <InlineLabel>{t("details_temp_hum")}</InlineLabel>
+ <ResponsiveContainer width="100%" height={200}>
+ <LineChart data={chartData}>
+ <CartesianGrid
+ stroke="var(--border)"
+ strokeDasharray="3 3"
+ vertical={false}
+ />
+ <XAxis
+ dataKey="t"
+ tick={{
+ fontSize: 10,
+ fill: "var(--text-3)",
+ fontFamily: "DM Mono",
+ }}
+ axisLine={false}
+ tickLine={false}
+ />
+ <YAxis
+ tick={{
+ fontSize: 10,
+ fill: "var(--text-3)",
+ fontFamily: "DM Mono",
+ }}
+ axisLine={false}
+ tickLine={false}
+ />
+ <Tooltip content={<ChartTooltip />} />
+ <Line
+ type="monotone"
+ dataKey="temp"
+ stroke="#60a5fa"
+ strokeWidth={2}
+ dot={false}
+ name={t("chart_temp")}
+ />
+ <Line
+ type="monotone"
+ dataKey="humidity"
+ stroke="#a78bfa"
+ strokeWidth={2}
+ dot={false}
+ name={t("chart_humidity")}
+ />
+ </LineChart>
+ </ResponsiveContainer>
+ </div>
- {activeTab === "details_tab_log" && (
<div
style={{
borderRadius: 14,
- overflow: "hidden",
+ padding: 20,
background: "var(--surface)",
border: "1px solid var(--border)",
- flexShrink: 0,
}}
>
- {/* Header row */}
- <div
- style={{
- padding: "14px 20px",
- borderBottom: "1px solid var(--border)",
- }}
- >
- <div className="section-label">
- {t("details_event_log", {
- total: history.length,
- limit: logLimit,
- })}
- </div>
+ <div className="section-label">{t("details_ec_conc")}</div>
+ <ResponsiveContainer width="100%" height={180}>
+ <AreaChart data={chartData}>
+ <defs>
+ <linearGradient id="ecGradCD" x1="0" y1="0" x2="0" y2="1">
+ <stop
+ offset="0%"
+ stopColor="#f59e0b"
+ stopOpacity={0.25}
+ />
+ <stop offset="100%" stopColor="#f59e0b" stopOpacity={0} />
+ </linearGradient>
+ </defs>
+ <CartesianGrid
+ stroke="var(--border)"
+ strokeDasharray="3 3"
+ vertical={false}
+ />
+ <XAxis
+ dataKey="t"
+ tick={{
+ fontSize: 10,
+ fill: "var(--text-3)",
+ fontFamily: "DM Mono",
+ }}
+ axisLine={false}
+ tickLine={false}
+ />
+ <YAxis
+ tick={{
+ fontSize: 10,
+ fill: "var(--text-3)",
+ fontFamily: "DM Mono",
+ }}
+ axisLine={false}
+ tickLine={false}
+ />
+ <Tooltip content={<ChartTooltip />} />
+ <Area
+ type="monotone"
+ dataKey="ec"
+ stroke="var(--amber)"
+ fill="url(#ecGradCD)"
+ strokeWidth={2}
+ dot={false}
+ name={t("chart_ec")}
+ />
+ </AreaChart>
+ </ResponsiveContainer>
+ </div>
+ </>
+ )}
+
+ {activeTab === "details_tab_log" && (
+ <div
+ style={{
+ borderRadius: 14,
+ overflow: "hidden",
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ flexShrink: 0,
+ }}
+ >
+ {/* Header row */}
+ <div
+ style={{
+ padding: "14px 20px",
+ borderBottom: "1px solid var(--border)",
+ }}
+ >
+ <div className="section-label">
+ {t("details_event_log", {
+ total: history.length,
+ limit: logLimit,
+ })}
</div>
+ </div>
- {/* Log rows */}
- <div>
- {[...history]
- .reverse()
- .slice(0, logLimit)
- .map((h, i) => {
- const hasExplanation =
- h.payload?.explanation_log &&
- h.payload.explanation_log !== "PENDING_ANALYSIS";
- return (
- <div
- key={i}
- style={{
- borderBottom: "1px solid var(--border)",
- transition: "background 0.12s",
- cursor: "default",
- }}
- onMouseEnter={(e) =>
- (e.currentTarget.style.background =
- "rgba(74,222,128,0.04)")
- }
- onMouseLeave={(e) =>
- (e.currentTarget.style.background = "transparent")
- }
- >
- {/* Row header */}
- <div
- style={{
- display: "flex",
- alignItems: "center",
- gap: 10,
- padding: "10px 20px",
- }}
- >
- <span
- style={{
- width: 7,
- height: 7,
- borderRadius: "50%",
- background: logDotColor(h.payload),
- flexShrink: 0,
- }}
- />
- <span
- style={{
- fontSize: 11,
- fontFamily: "DM Mono, monospace",
- color: "var(--text-3)",
- flexShrink: 0,
- width: 52,
- }}
- >
- {h.payload?.timestamp
- ? new Date(
- h.payload.timestamp,
- ).toLocaleTimeString([], {
- hour: "2-digit",
- minute: "2-digit",
- })
- : "--"}
- </span>
- <span
- style={{
- fontSize: 11,
- fontFamily: "DM Mono, monospace",
- color: "var(--text-3)",
- width: 36,
- flexShrink: 0,
- }}
- >
- #{h.payload?.sequence_number || i}
- </span>
+ {/* Log rows */}
+ <div>
+ {[...history]
+ .reverse()
+ .slice(0, logLimit)
+ .map((h, i) => (
+ <LogRow
+ key={i}
+ h={h}
+ i={i}
+ logLimit={logLimit}
+ t={t}
+ td={td}
+ formatNumber={formatNumber}
+ logDotColor={logDotColor}
+ />
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ </PageShell>
+ );
+}
+
+function LogRow({ h, i, logLimit, t, td, formatNumber, logDotColor }) {
+ const [expanded, setExpanded] = useState(false);
+ const hasExplanation =
+ h.payload?.explanation_log &&
+ h.payload.explanation_log !== "PENDING_ANALYSIS";
+
+ return (
+ <div
+ style={{
+ borderBottom: "1px solid var(--border)",
+ transition: "background 0.12s",
+ cursor: "default",
+ }}
+ onMouseEnter={(e) =>
+ (e.currentTarget.style.background = "rgba(74,222,128,0.04)")
+ }
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
+ >
+ {/* Row header */}
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 10,
+ padding: "10px 20px",
+ }}
+ >
+ <span
+ style={{
+ width: 7,
+ height: 7,
+ borderRadius: "50%",
+ background: logDotColor(h.payload),
+ flexShrink: 0,
+ }}
+ />
+ <span
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ flexShrink: 0,
+ width: 52,
+ }}
+ >
+ {h.payload?.timestamp
+ ? new Date(h.payload.timestamp).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+ : "--"}
+ </span>
+ <span
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ width: 36,
+ flexShrink: 0,
+ }}
+ >
+ #{h.payload?.sequence_number || i}
+ </span>
- {/* Sensor snapshot */}
- <div
- style={{
- display: "flex",
- gap: 12,
- flexWrap: "wrap",
- }}
- >
- {[
- {
- label: "pH",
- value: h.cleanSensors?.ph,
- color: "var(--green)",
- },
- {
- label: "EC",
- value: h.cleanSensors?.ec,
- color: "var(--amber)",
- },
- {
- label: "T",
- value: h.cleanSensors?.temp + "°",
- color: "var(--blue)",
- },
- {
- label: "H",
- value: h.cleanSensors?.humidity + "%",
- color: "#a78bfa",
- },
- ].map(({ label, value, color }) => (
- <span
- key={label}
- style={{
- fontSize: 13,
- fontFamily: "DM Mono, monospace",
- display: "flex",
- alignItems: "baseline",
- gap: 3,
- }}
- >
- <span
- style={{
- color: "var(--text-3)",
- fontSize: 11,
- }}
- >
- {label}
- </span>
- <span style={{ color, fontWeight: 700 }}>
- {formatNumber(value)}
- </span>
- </span>
- ))}
- </div>
+ {/* Sensor snapshot */}
+ <div
+ style={{
+ display: "flex",
+ gap: 12,
+ flexWrap: "wrap",
+ }}
+ >
+ {[
+ {
+ label: "pH",
+ value: h.cleanSensors?.ph,
+ color: "var(--green)",
+ },
+ {
+ label: "EC",
+ value: h.cleanSensors?.ec,
+ color: "var(--amber)",
+ },
+ {
+ label: "T",
+ value: h.cleanSensors?.temp + "°",
+ color: "var(--blue)",
+ },
+ {
+ label: "H",
+ value: h.cleanSensors?.humidity + "%",
+ color: "#a78bfa",
+ },
+ ].map(({ label, value, color }) => (
+ <span
+ key={label}
+ style={{
+ fontSize: 13,
+ fontFamily: "DM Mono, monospace",
+ display: "flex",
+ alignItems: "baseline",
+ gap: 3,
+ }}
+ >
+ <span
+ style={{
+ color: "var(--text-3)",
+ fontSize: 11,
+ }}
+ >
+ {label}
+ </span>
+ <span style={{ color, fontWeight: 700 }}>
+ {formatNumber(value)}
+ </span>
+ </span>
+ ))}
+ </div>
- {/* Outcome badge */}
- {h.payload?.outcome && (
- <span style={{ marginLeft: "auto", flexShrink: 0 }}>
- <AgentOutcomeWidget
- outcome={h.payload.outcome}
- rewardScore={h.payload.reward_score}
- />
- </span>
- )}
+ {/* Outcome badge */}
+ {h.payload?.outcome && (
+ <span style={{ marginLeft: "auto", flexShrink: 0 }}>
+ <AgentOutcomeWidget
+ outcome={h.payload.outcome}
+ rewardScore={h.payload.reward_score}
+ />
+ </span>
+ )}
- {/* Explanation toggle */}
- {hasExplanation && (
- <button
- onClick={() => setShowExp((v) => !v)}
- style={{
- flexShrink: 0,
- display: "flex",
- alignItems: "center",
- gap: 4,
- padding: "3px 8px",
- borderRadius: 6,
- fontSize: 9,
- fontFamily: "DM Mono, monospace",
- cursor: "pointer",
- background: showExp
- ? "rgba(167,139,250,0.12)"
- : "var(--bg-3)",
- border: `1px solid ${showExp ? "rgba(167,139,250,0.3)" : "var(--border)"}`,
- color: showExp ? "#a78bfa" : "var(--text-3)",
- }}
- >
- <Brain size={9} />{" "}
- {showExp ? t("details_hide") : t("details_why")}
- </button>
- )}
- </div>
+ {/* Explanation toggle */}
+ {hasExplanation && (
+ <button
+ onClick={() => setExpanded((v) => !v)}
+ style={{
+ flexShrink: 0,
+ display: "flex",
+ alignItems: "center",
+ gap: 4,
+ padding: "3px 8px",
+ borderRadius: 6,
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ cursor: "pointer",
+ background: expanded ? "rgba(167,139,250,0.12)" : "var(--bg-3)",
+ border: `1px solid ${expanded ? "rgba(167,139,250,0.3)" : "var(--border)"}`,
+ color: expanded ? "#a78bfa" : "var(--text-3)",
+ }}
+ >
+ <Brain size={9} /> {expanded ? t("details_hide") : t("details_why")}
+ </button>
+ )}
+ </div>
- {/* Action row */}
- {h.payload?.action_taken &&
- h.payload.action_taken !== "PENDING_ACTION" && (
- <div style={{ padding: "0 20px 10px 46px" }}>
- <AgentActionWidget
- actionTaken={h.payload.action_taken}
- compact
- />
- </div>
- )}
+ {/* Action row */}
+ {h.payload?.action_taken &&
+ h.payload.action_taken !== "PENDING_ACTION" && (
+ <div style={{ padding: "0 20px 10px 46px" }}>
+ <AgentActionWidget actionTaken={h.payload.action_taken} compact />
+ </div>
+ )}
- {/* Explanation log inline */}
- {hasExplanation && showExp && (
- <div
- className="animate-fade-in"
- style={{
- margin: "0 20px 12px 46px",
- padding: "12px 14px",
- borderRadius: 10,
- background: "rgba(167,139,250,0.05)",
- border: "1px solid rgba(167,139,250,0.15)",
- }}
- >
- <div
- style={{
- fontSize: 9,
- fontFamily: "DM Mono, monospace",
- color: "#a78bfa",
- marginBottom: 8,
- }}
- >
- <Brain
- size={9}
- style={{ display: "inline", marginRight: 5 }}
- />{" "}
- {t("details_ai_reasoning")}
- </div>
- <pre
- style={{
- fontSize: 11,
- fontFamily: "DM Mono, monospace",
- color: "var(--text-2)",
- lineHeight: 1.7,
- whiteSpace: "pre-wrap",
- margin: 0,
- }}
- >
- {td(h.payload.explanation_log)}
- </pre>
- </div>
- )}
- </div>
- );
- })}
- </div>
- </div>
- )}
+ {/* Explanation log inline */}
+ {hasExplanation && expanded && (
+ <div
+ className="animate-fade-in"
+ style={{
+ margin: "0 20px 12px 46px",
+ padding: "12px 14px",
+ borderRadius: 10,
+ background: "rgba(167,139,250,0.05)",
+ border: "1px solid rgba(167,139,250,0.15)",
+ }}
+ >
+ <div
+ style={{
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ color: "#a78bfa",
+ marginBottom: 8,
+ }}
+ >
+ <Brain size={9} style={{ display: "inline", marginRight: 5 }} />{" "}
+ {t("details_ai_reasoning")}
+ </div>
+ <pre
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-2)",
+ lineHeight: 1.7,
+ whiteSpace: "pre-wrap",
+ margin: 0,
+ }}
+ >
+ {td(h.payload.explanation_log)}
+ </pre>
</div>
- </main>
+ )}
</div>
);
}
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
@@ -10,23 +10,31 @@ import {
isReadyToHarvest,
} from "../utils/dataUtils";
import {
- Search,
SlidersHorizontal,
Thermometer,
Droplet,
ArrowUpRight,
Clock,
- ChevronDown,
- X,
RefreshCw,
Leaf,
Activity,
- ChevronLeft,
- ChevronRight,
PlusCircle,
Scissors,
} from "lucide-react";
-import Sidebar from "../components/Sidebar";
+import {
+ PageShell,
+ PageHeader,
+ IconButton,
+ PrimaryButton,
+ SearchBar,
+ FilterBar,
+ FilterPill,
+ SelectField,
+ StatusBadge,
+ Pagination,
+ EmptyState,
+ LoadingShimmer,
+} from "../components/ui";
const STAGES_KEYS = [
"stage_all",
@@ -43,26 +51,7 @@ const STATUSES_KEYS = [
"dash_critical",
];
-const STATUS_COLORS = {
- Healthy: {
- bg: "rgba(74,222,128,0.12)",
- text: "var(--green)",
- border: "rgba(74,222,128,0.3)",
- },
- Attention: {
- bg: "rgba(245,158,11,0.12)",
- text: "var(--amber)",
- border: "rgba(245,158,11,0.3)",
- },
- Critical: {
- bg: "rgba(248,113,113,0.12)",
- text: "var(--red)",
- border: "rgba(248,113,113,0.3)",
- },
-};
-
function CropCard({ data, onClick, t, td }) {
- const st = STATUS_COLORS[data.status] || STATUS_COLORS.Healthy;
const maturity = data.maturity || 40;
// Map backend status to translation key
@@ -165,23 +154,11 @@ function CropCard({ data, onClick, t, td }) {
}}
/>
- {/* Status badge */}
- <div
- style={{
- position: "absolute",
- top: 10,
- right: 10,
- fontSize: 10,
- fontFamily: "DM Mono, monospace",
- padding: "3px 8px",
- borderRadius: 20,
- background: st.bg,
- color: st.text,
- border: `1px solid ${st.border}`,
- }}
- >
- {t(statusKey).toUpperCase()}
- </div>
+ <StatusBadge
+ status={data.status}
+ label={t(statusKey).toUpperCase()}
+ style={{ position: "absolute", top: 10, right: 10 }}
+ />
{/* Seq badge */}
<div
style={{
@@ -504,463 +481,319 @@ export default function Dashboard() {
const STATUS_EN = ["All", "Healthy", "Attention", "Critical"];
return (
- <div
- style={{
- display: "flex",
- height: "100vh",
- overflow: "hidden",
- background: "var(--bg)",
- }}
- >
- <Sidebar />
-
- <main
- style={{
- flex: 1,
- display: "flex",
- flexDirection: "column",
- overflow: "hidden",
- }}
+ <PageShell>
+ {/* Header */}
+ <PageHeader
+ title={t("dash_title")}
+ subtitle={t("dash_subtitle", {
+ filtered: filtered.length,
+ total: crops.length,
+ })}
>
- {/* Header */}
- <header
+ {/* Summary chips */}
+ <div
style={{
- flexShrink: 0,
- padding: "0 24px",
- height: 64,
- borderBottom: "1px solid var(--border)",
- background: "var(--bg-2)",
display: "flex",
alignItems: "center",
- gap: 16,
+ gap: 8,
+ marginLeft: 16,
}}
>
- <div>
- <h1 className="page-title">{t("dash_title")}</h1>
- <p className="page-subtitle">
- {t("dash_subtitle", {
- filtered: filtered.length,
- total: crops.length,
- })}
- </p>
- </div>
+ {[
+ {
+ labelKey: "dash_healthy",
+ count: summary.healthy,
+ color: "var(--green)",
+ },
+ {
+ labelKey: "dash_attention",
+ count: summary.attention,
+ color: "var(--amber)",
+ },
+ {
+ labelKey: "dash_critical",
+ count: summary.critical,
+ color: "var(--red)",
+ },
+ ].map(({ labelKey, count, color }) => (
+ <div
+ key={labelKey}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 5,
+ padding: "3px 10px",
+ borderRadius: 20,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ color,
+ }}
+ >
+ <span style={{ fontWeight: 700 }}>{count}</span>
+ <span style={{ opacity: 0.7 }}>{t(labelKey)}</span>
+ </div>
+ ))}
+ </div>
- {/* Summary chips */}
- <div
+ <PrimaryButton
+ onClick={() => navigate("/add-crop")}
+ icon={PlusCircle}
+ style={{ marginLeft: "auto" }}
+ >
+ {t("dash_add_crop")}
+ </PrimaryButton>
+
+ <IconButton onClick={refreshData}>
+ <RefreshCw size={15} className={loading ? "animate-spin" : ""} />
+ </IconButton>
+ </PageHeader>
+
+ {harvestReadyCrops.length > 0 && (
+ <div
+ className="animate-fade-in"
+ style={{
+ flexShrink: 0,
+ padding: "10px 24px",
+ background: "rgba(245,158,11,0.12)",
+ borderBottom: "1px solid rgba(245,158,11,0.3)",
+ display: "flex",
+ alignItems: "center",
+ gap: 12,
+ }}
+ >
+ <span
+ className="harvest-pulse"
style={{
- display: "flex",
- alignItems: "center",
- gap: 8,
- marginLeft: 16,
+ width: 10,
+ height: 10,
+ borderRadius: "50%",
+ background: "var(--amber)",
+ flexShrink: 0,
}}
- >
- {[
- {
- labelKey: "dash_healthy",
- count: summary.healthy,
- color: "var(--green)",
- },
- {
- labelKey: "dash_attention",
- count: summary.attention,
- color: "var(--amber)",
- },
- {
- labelKey: "dash_critical",
- count: summary.critical,
- color: "var(--red)",
- },
- ].map(({ labelKey, count, color }) => (
- <div
- key={labelKey}
- style={{
- display: "flex",
- alignItems: "center",
- gap: 5,
- padding: "3px 10px",
- borderRadius: 20,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- fontSize: 12,
- fontFamily: "DM Mono, monospace",
- color,
- }}
- >
- <span style={{ fontWeight: 700 }}>{count}</span>
- <span style={{ opacity: 0.7 }}>{t(labelKey)}</span>
- </div>
- ))}
+ />
+ <Scissors
+ size={16}
+ style={{ color: "var(--amber)", flexShrink: 0 }}
+ />
+ <div style={{ flex: 1 }}>
+ <span
+ style={{ fontWeight: 700, fontSize: 13, color: "var(--amber)" }}
+ >
+ {t("dash_harvest_banner", {
+ n: harvestReadyCrops.length,
+ s: harvestReadyCrops.length !== 1 ? "s" : "",
+ })}
+ </span>
+ <span
+ style={{
+ fontSize: 12,
+ color: "var(--text-3)",
+ marginLeft: 10,
+ fontFamily: "DM Mono, monospace",
+ }}
+ >
+ {t("dash_harvest_banner_sub")}
+ </span>
</div>
-
- {/* Add Crop CTA */}
<button
- onClick={() => navigate("/add-crop")}
- style={{
- marginLeft: "auto",
- display: "flex",
- alignItems: "center",
- gap: 7,
- padding: "8px 18px",
- borderRadius: 10,
- fontSize: 13,
- fontWeight: 600,
- background: "var(--green)",
- border: "none",
- color: "var(--btn-on-green)",
- cursor: "pointer",
- boxShadow: "0 0 16px rgba(74,222,128,0.2)",
+ onClick={() => {
+ setFilterReady(true);
+ setFilterStatus("All");
+ setFilterStage("All");
+ setFilterCrop("All");
+ setSearch("");
+ setPage(1);
}}
- >
- <PlusCircle size={15} /> {t("dash_add_crop")}
- </button>
-
- <button
- onClick={refreshData}
style={{
- width: 34,
- height: 34,
+ padding: "5px 14px",
borderRadius: 8,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- color: "var(--text-3)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ background: "rgba(245,158,11,0.2)",
+ border: "1px solid rgba(245,158,11,0.4)",
+ color: "var(--amber)",
cursor: "pointer",
+ fontWeight: 600,
}}
>
- <RefreshCw size={15} className={loading ? "animate-spin" : ""} />
+ {t("dash_harvest_action")}
</button>
- </header>
+ </div>
+ )}
- {harvestReadyCrops.length > 0 && (
- <div
- className="animate-fade-in"
- style={{
- flexShrink: 0,
- padding: "10px 24px",
- background: "rgba(245,158,11,0.12)",
- borderBottom: "1px solid rgba(245,158,11,0.3)",
- display: "flex",
- alignItems: "center",
- gap: 12,
- }}
- >
+ <FilterBar>
+ <SearchBar
+ value={search}
+ onChange={(val) => {
+ setSearch(val);
+ setPage(1);
+ }}
+ onClear={() => setSearch("")}
+ placeholder={t("dash_search_placeholder")}
+ />
+
+ <button
+ onClick={() => setShowFilters(!showFilters)}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ padding: "7px 12px",
+ borderRadius: 8,
+ fontSize: 12,
+ cursor: "pointer",
+ background: showFilters ? "rgba(74,222,128,0.1)" : "var(--surface)",
+ border: `1px solid ${showFilters ? "rgba(74,222,128,0.3)" : "var(--border)"}`,
+ color: showFilters ? "var(--green)" : "var(--text-2)",
+ }}
+ >
+ <SlidersHorizontal size={13} /> {t("dash_filters")}
+ {activeFilters > 0 && (
<span
- className="harvest-pulse"
style={{
- width: 10,
- height: 10,
- borderRadius: "50%",
- background: "var(--amber)",
- flexShrink: 0,
+ padding: "0 5px",
+ borderRadius: 4,
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ background: "var(--green)",
+ color: "var(--btn-on-green)",
}}
- />
- <Scissors
- size={16}
- style={{ color: "var(--amber)", flexShrink: 0 }}
- />
- <div style={{ flex: 1 }}>
- <span
- style={{ fontWeight: 700, fontSize: 13, color: "var(--amber)" }}
- >
- {t("dash_harvest_banner", {
- n: harvestReadyCrops.length,
- s: harvestReadyCrops.length !== 1 ? "s" : "",
- })}
- </span>
- <span
- style={{
- fontSize: 12,
- color: "var(--text-3)",
- marginLeft: 10,
- fontFamily: "DM Mono, monospace",
- }}
- >
- {t("dash_harvest_banner_sub")}
- </span>
- </div>
- <button
+ >
+ {activeFilters}
+ </span>
+ )}
+ </button>
+
+ {/* Quick stage pills */}
+ <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
+ {STAGES_EN.slice(0, 4).map((s, i) => (
+ <FilterPill
+ key={s}
+ active={filterStage === s}
onClick={() => {
- setFilterReady(true);
- setFilterStatus("All");
- setFilterStage("All");
- setFilterCrop("All");
- setSearch("");
+ setFilterStage(filterStage === s ? "All" : s);
setPage(1);
}}
- style={{
- padding: "5px 14px",
- borderRadius: 8,
- fontSize: 12,
- fontFamily: "DM Mono, monospace",
- background: "rgba(245,158,11,0.2)",
- border: "1px solid rgba(245,158,11,0.4)",
- color: "var(--amber)",
- cursor: "pointer",
- fontWeight: 600,
- }}
>
- {t("dash_harvest_action")}
- </button>
- </div>
- )}
+ {t(STAGES_KEYS[i])}
+ </FilterPill>
+ ))}
+ </div>
+ </FilterBar>
+ {/* Expanded filters */}
+ {showFilters && (
<div
+ className="animate-fade-in"
style={{
flexShrink: 0,
padding: "8px 24px",
borderBottom: "1px solid var(--border)",
- background: "var(--bg-2)",
+ background: "var(--bg-3)",
display: "flex",
alignItems: "center",
- gap: 12,
+ gap: 24,
}}
>
- {/* Search */}
- <div style={{ position: "relative", flex: 1, maxWidth: 360 }}>
- <Search
- size={14}
- style={{
- position: "absolute",
- left: 10,
- top: "50%",
- transform: "translateY(-50%)",
- color: "var(--text-3)",
- }}
- />
- <input
- value={search}
- onChange={(e) => {
- setSearch(e.target.value);
+ {[
+ {
+ label: t("add_field_crop_type"),
+ value: filterCrop,
+ set: (v) => {
+ setFilterCrop(v);
setPage(1);
- }}
- placeholder={t("dash_search_placeholder")}
- style={{
- width: "100%",
- paddingLeft: 32,
- paddingRight: search ? 28 : 12,
- paddingTop: 7,
- paddingBottom: 7,
- borderRadius: 8,
- fontSize: 13,
- fontFamily: "DM Mono, monospace",
- background: "var(--surface)",
- border: "1px solid var(--border)",
- color: "var(--text)",
- outline: "none",
- caretColor: "var(--green)",
- }}
- />
- {search && (
- <button
- onClick={() => setSearch("")}
+ },
+ opts: CROPS,
+ optLabels: CROPS.map((o) =>
+ o === "All" ? t("stage_all") : td(o),
+ ),
+ },
+ {
+ label: t("add_field_stage"),
+ value: filterStage,
+ set: (v) => {
+ setFilterStage(v);
+ setPage(1);
+ },
+ opts: STAGES_EN,
+ optLabels: STAGES_KEYS.map((k) => t(k)),
+ },
+ {
+ label: t("analytics_th_status"),
+ value: filterStatus,
+ set: (v) => {
+ setFilterStatus(v);
+ setPage(1);
+ },
+ opts: STATUS_EN,
+ optLabels: STATUSES_KEYS.map((k) => t(k)),
+ },
+ ].map(({ label, value, set, opts, optLabels }) => (
+ <div
+ key={label}
+ style={{ display: "flex", alignItems: "center", gap: 8 }}
+ >
+ <span
style={{
- position: "absolute",
- right: 8,
- top: "50%",
- transform: "translateY(-50%)",
- background: "none",
- border: "none",
- cursor: "pointer",
- padding: 0,
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
color: "var(--text-3)",
}}
>
- <X size={12} />
- </button>
- )}
- </div>
-
- {/* Filter toggle */}
+ {label}
+ </span>
+ <SelectField
+ value={value}
+ onChange={(e) => set(e.target.value)}
+ options={opts.map((o, i) => ({
+ value: o,
+ label: optLabels ? optLabels[i] : o,
+ }))}
+ />
+ </div>
+ ))}
<button
- onClick={() => setShowFilters(!showFilters)}
+ onClick={() => {
+ setSearch("");
+ setFilterStage("All");
+ setFilterCrop("All");
+ setFilterStatus("All");
+ setFilterReady(false);
+ setPage(1);
+ }}
style={{
- display: "flex",
- alignItems: "center",
- gap: 6,
- padding: "7px 12px",
- borderRadius: 8,
- fontSize: 12,
+ marginLeft: "auto",
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ background: "none",
+ border: "none",
cursor: "pointer",
- background: showFilters
- ? "rgba(74,222,128,0.1)"
- : "var(--surface)",
- border: `1px solid ${showFilters ? "rgba(74,222,128,0.3)" : "var(--border)"}`,
- color: showFilters ? "var(--green)" : "var(--text-2)",
+ color: "var(--text-3)",
}}
>
- <SlidersHorizontal size={13} /> {t("dash_filters")}
- {activeFilters > 0 && (
- <span
- style={{
- padding: "0 5px",
- borderRadius: 4,
- fontSize: 10,
- fontFamily: "DM Mono, monospace",
- background: "var(--green)",
- color: "var(--btn-on-green)",
- }}
- >
- {activeFilters}
- </span>
- )}
+ {t("dash_clear_all")}
</button>
-
- {/* Quick stage pills */}
- <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
- {STAGES_EN.slice(0, 4).map((s, i) => (
- <button
- key={s}
- onClick={() => {
- setFilterStage(filterStage === s ? "All" : s);
- setPage(1);
- }}
- style={{
- padding: "5px 12px",
- borderRadius: 20,
- fontSize: 11,
- fontFamily: "DM Mono, monospace",
- cursor: "pointer",
- background:
- filterStage === s
- ? "rgba(74,222,128,0.15)"
- : "var(--surface)",
- border: `1px solid ${filterStage === s ? "rgba(74,222,128,0.4)" : "var(--border)"}`,
- color: filterStage === s ? "var(--green)" : "var(--text-3)",
- }}
- >
- {t(STAGES_KEYS[i])}
- </button>
- ))}
- </div>
</div>
+ )}
- {/* Expanded filters */}
- {showFilters && (
+ {/* Crop grid */}
+ <div style={{ flex: 1, overflowY: "auto", padding: 24 }}>
+ {loading ? (
<div
- className="animate-fade-in"
style={{
- flexShrink: 0,
- padding: "8px 24px",
- borderBottom: "1px solid var(--border)",
- background: "var(--bg-3)",
- display: "flex",
- alignItems: "center",
- gap: 24,
+ display: "grid",
+ gridTemplateColumns: "repeat(auto-fill,minmax(200px,1fr))",
+ gap: 16,
}}
>
- {[
- {
- label: t("add_field_crop_type"),
- value: filterCrop,
- set: (v) => {
- setFilterCrop(v);
- setPage(1);
- },
- opts: CROPS,
- optLabels: CROPS.map((o) =>
- o === "All" ? t("stage_all") : td(o),
- ),
- },
- {
- label: t("add_field_stage"),
- value: filterStage,
- set: (v) => {
- setFilterStage(v);
- setPage(1);
- },
- opts: STAGES_EN,
- optLabels: STAGES_KEYS.map((k) => t(k)),
- },
- {
- label: t("analytics_th_status"),
- value: filterStatus,
- set: (v) => {
- setFilterStatus(v);
- setPage(1);
- },
- opts: STATUS_EN,
- optLabels: STATUSES_KEYS.map((k) => t(k)),
- },
- ].map(({ label, value, set, opts, optLabels }) => (
- <div
- key={label}
- style={{ display: "flex", alignItems: "center", gap: 8 }}
- >
- <span
- style={{
- fontSize: 12,
- fontFamily: "DM Mono, monospace",
- color: "var(--text-3)",
- }}
- >
- {label}
- </span>
- <div style={{ position: "relative" }}>
- <select
- value={value}
- onChange={(e) => set(e.target.value)}
- style={{
- appearance: "none",
- padding: "5px 24px 5px 10px",
- borderRadius: 8,
- fontSize: 12,
- fontFamily: "DM Mono, monospace",
- background: "var(--surface)",
- border: "1px solid var(--border)",
- color: "var(--text-2)",
- cursor: "pointer",
- outline: "none",
- }}
- >
- {opts.map((o, i) => (
- <option key={o} value={o}>
- {optLabels ? optLabels[i] : o}
- </option>
- ))}
- </select>
- <ChevronDown
- size={10}
- style={{
- position: "absolute",
- right: 8,
- top: "50%",
- transform: "translateY(-50%)",
- pointerEvents: "none",
- color: "var(--text-3)",
- }}
- />
- </div>
- </div>
+ {Array.from({ length: 8 }).map((_, i) => (
+ <LoadingShimmer key={i} count={1} height={255} />
))}
- <button
- onClick={() => {
- setSearch("");
- setFilterStage("All");
- setFilterCrop("All");
- setFilterStatus("All");
- setFilterReady(false);
- setPage(1);
- }}
- style={{
- marginLeft: "auto",
- fontSize: 11,
- fontFamily: "DM Mono, monospace",
- background: "none",
- border: "none",
- cursor: "pointer",
- color: "var(--text-3)",
- }}
- >
- {t("dash_clear_all")}
- </button>
</div>
- )}
-
- {/* Crop grid */}
- <div style={{ flex: 1, overflowY: "auto", padding: 24 }}>
- {loading ? (
+ ) : paginated.length > 0 ? (
+ <>
<div
style={{
display: "grid",
@@ -968,206 +801,53 @@ export default function Dashboard() {
gap: 16,
}}
>
- {Array(8)
- .fill(0)
- .map((_, i) => (
- <div
- key={i}
- className="shimmer"
- style={{
- height: 255,
- borderRadius: 16,
- border: "1px solid var(--border)",
- }}
- />
- ))}
+ {paginated.map((crop) => (
+ <CropCard
+ key={crop.id}
+ data={crop}
+ t={t}
+ td={td}
+ onClick={() => navigate(`/crop/${crop.id}`)}
+ />
+ ))}
+ {/* Always visible at end of last page */}
+ {page === totalPages && !filterReady && (
+ <AddCropCard onClick={() => navigate("/add-crop")} t={t} />
+ )}
</div>
- ) : paginated.length > 0 ? (
- <>
- <div
- style={{
- display: "grid",
- gridTemplateColumns: "repeat(auto-fill,minmax(200px,1fr))",
- gap: 16,
- }}
- >
- {paginated.map((crop) => (
- <CropCard
- key={crop.id}
- data={crop}
- t={t}
- td={td}
- onClick={() => navigate(`/crop/${crop.id}`)}
- />
- ))}
- {/* Always visible at end of first page */}
- {page === 1 && !filterReady && (
- <AddCropCard onClick={() => navigate("/add-crop")} t={t} />
- )}
- </div>
- {/* Pagination */}
- {totalPages > 1 && (
- <div
- style={{
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- gap: 8,
- marginTop: 24,
- }}
- >
- <button
- onClick={() => setPage((p) => Math.max(1, p - 1))}
- disabled={page === 1}
- style={{
- width: 32,
- height: 32,
- borderRadius: 8,
- cursor: "pointer",
- background: "var(--surface)",
- border: "1px solid var(--border)",
- color: page === 1 ? "var(--text-3)" : "var(--text-2)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- }}
- >
- <ChevronLeft size={14} />
- </button>
- {Array.from({ length: totalPages }, (_, i) => i + 1).map(
- (n) => (
- <button
- key={n}
- onClick={() => setPage(n)}
- style={{
- width: 32,
- height: 32,
- borderRadius: 8,
- cursor: "pointer",
- fontFamily: "DM Mono, monospace",
- fontSize: 12,
- background:
- n === page ? "var(--green)" : "var(--surface)",
- border: `1px solid ${n === page ? "transparent" : "var(--border)"}`,
- color:
- n === page
- ? "var(--btn-on-green)"
- : "var(--text-2)",
- fontWeight: n === page ? 700 : 400,
- }}
- >
- {n}
- </button>
- ),
- )}
- <button
- onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
- disabled={page === totalPages}
- style={{
- width: 32,
- height: 32,
- borderRadius: 8,
- cursor: "pointer",
- background: "var(--surface)",
- border: "1px solid var(--border)",
- color:
- page === totalPages ? "var(--text-3)" : "var(--text-2)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- }}
- >
- <ChevronRight size={14} />
- </button>
- </div>
- )}
- </>
- ) : (
- <div
- style={{
- display: "flex",
- flexDirection: "column",
- alignItems: "center",
- justifyContent: "center",
- height: "100%",
- gap: 16,
- }}
- >
- {crops.length === 0 ? (
- <>
- <div
- style={{
- width: 64,
- height: 64,
- borderRadius: 20,
- background: "rgba(74,222,128,0.1)",
- border: "1px solid rgba(74,222,128,0.2)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- }}
- >
- <PlusCircle size={28} style={{ color: "var(--green)" }} />
- </div>
- <div style={{ textAlign: "center" }}>
- <div
- style={{
- fontWeight: 700,
- fontSize: 15,
- color: "var(--text-2)",
- }}
- >
- {t("dash_no_crops")}
- </div>
- <div
- style={{
- fontSize: 12,
- color: "var(--text-3)",
- marginTop: 6,
- }}
- >
- {t("dash_no_crops_sub")}
- </div>
- </div>
- <button
- onClick={() => navigate("/add-crop")}
- style={{
- display: "flex",
- alignItems: "center",
- gap: 7,
- padding: "10px 22px",
- borderRadius: 10,
- fontSize: 13,
- fontWeight: 600,
- background: "var(--green)",
- border: "none",
- color: "var(--btn-on-green)",
- cursor: "pointer",
- }}
- >
- <PlusCircle size={15} /> {t("dash_add_first")}
- </button>
- </>
- ) : (
- <>
- <div
- style={{
- width: 56,
- height: 56,
- borderRadius: 16,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- }}
- >
- <Activity size={24} style={{ color: "var(--text-3)" }} />
- </div>
- <div style={{ color: "var(--text-2)", fontSize: 14 }}>
- {t("dash_no_match")}
- </div>
+ {/* Pagination */}
+ {totalPages > 1 && (
+ <Pagination
+ page={page}
+ totalPages={totalPages}
+ setPage={setPage}
+ style={{ marginTop: 24 }}
+ />
+ )}
+ </>
+ ) : (
+ <div
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ height: "100%",
+ gap: 16,
+ }}
+ >
+ {crops.length === 0 ? (
+ <EmptyState
+ icon={PlusCircle}
+ title={t("dash_no_crops")}
+ description={t("dash_no_crops_sub")}
+ />
+ ) : (
+ <EmptyState
+ icon={Activity}
+ title={t("dash_no_match")}
+ description={
<button
onClick={() => {
setSearch("");
@@ -1186,16 +866,17 @@ export default function Dashboard() {
border: "1px solid var(--border)",
color: "var(--text-3)",
cursor: "pointer",
+ marginTop: 16,
}}
>
{t("dash_clear_filters")}
</button>
- </>
- )}
- </div>
- )}
- </div>
- </main>
- </div>
+ }
+ />
+ )}
+ </div>
+ )}
+ </div>
+ </PageShell>
);
}
diff --git a/frontend/src/pages/FarmIntelligence.jsx b/frontend/src/pages/FarmIntelligence.jsx
@@ -28,7 +28,7 @@ import {
AgentActionWidget,
AgentOutcomeWidget,
} from "../components/AgentWidgets";
-import Sidebar from "../components/Sidebar";
+import { PageShell, PageHeader } from "../components/ui";
import { useFarmData } from "../hooks/useFarmData";
// Suggestion banks
@@ -1232,16 +1232,7 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
: getGlobalSuggestions(t);
return (
- <div
- style={{
- display: "flex",
- height: "100vh",
- overflow: "hidden",
- background: "var(--bg)",
- }}
- >
- <Sidebar />
-
+ <PageShell>
{toast && (
<div
className="animate-fade-in"
@@ -1275,37 +1266,13 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
}}
>
{/* Header */}
- <header
- style={{
- flexShrink: 0,
- padding: "0 24px",
- height: 64,
- borderBottom: "1px solid var(--border)",
- background: "var(--bg-2)",
- display: "flex",
- alignItems: "center",
- gap: 12,
- }}
+ <PageHeader
+ title={t("intel_title")}
+ subtitle={t("intel_subtitle")}
+ icon={Sparkles}
+ iconColor="#a78bfa"
+ iconBg="rgba(167,139,250,0.1)"
>
- <div
- style={{
- width: 32,
- height: 32,
- borderRadius: 8,
- background: "rgba(167,139,250,0.1)",
- border: "1px solid rgba(167,139,250,0.2)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- }}
- >
- <Sparkles size={15} style={{ color: "#a78bfa" }} />
- </div>
- <div>
- <h1 className="page-title">{t("intel_title")}</h1>
- <p className="page-subtitle">{t("intel_subtitle")}</p>
- </div>
-
{/* Mode toggle */}
<div
style={{
@@ -1319,8 +1286,8 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
}}
>
{[
- { key: "search", label: t("intel_search"), icon: Search },
{ key: "ask", label: t("intel_ask_ai"), icon: MessageSquare },
+ { key: "search", label: t("intel_search"), icon: Search },
].map(({ key, label, icon: Icon }) => (
<button
key={key}
@@ -1353,9 +1320,11 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
</button>
))}
</div>
- </header>
+ </PageHeader>
<div
+ key={mode}
+ className="animate-fade-in"
style={{
flex: 1,
overflowY: "auto",
@@ -1973,6 +1942,6 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
)}
</div>
</main>
- </div>
+ </PageShell>
);
}
diff --git a/frontend/src/pages/Help.jsx b/frontend/src/pages/Help.jsx
@@ -22,6 +22,7 @@ import {
Zap,
} from "lucide-react";
import { useT } from "../hooks/useTranslation";
+import { PrimaryButton } from "../components/ui";
// Data
@@ -1048,6 +1049,7 @@ export default function Help() {
return (
<div
+ className="animate-fade-scale"
style={{
flex: 1,
overflowY: "auto",
@@ -1124,8 +1126,16 @@ export default function Help() {
</div>
</div>
- {/* ── Section 1: Sensor Terms ── */}
- <section style={{ marginBottom: 36 }}>
+ {/* Section 1: Sensor Terms */}
+ <section
+ className="animate-fade-up"
+ style={{
+ marginBottom: 36,
+ animationDelay: "0.1s",
+ opacity: 0,
+ animationFillMode: "forwards",
+ }}
+ >
<SectionHeader>{dict["help_section_terms"]}</SectionHeader>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{TERMS.map((term) => (
@@ -1134,8 +1144,16 @@ export default function Help() {
</div>
</section>
- {/* ── Section 2: AI Agents ── */}
- <section style={{ marginBottom: 36 }}>
+ {/* Section 2: AI Agents */}
+ <section
+ className="animate-fade-up"
+ style={{
+ marginBottom: 36,
+ animationDelay: "0.2s",
+ opacity: 0,
+ animationFillMode: "forwards",
+ }}
+ >
<SectionHeader>{dict["help_section_agents"]}</SectionHeader>
{/* Pipeline flow visualization */}
@@ -1183,8 +1201,16 @@ export default function Help() {
</div>
</section>
- {/* ── Section 3: Growth Stages ── */}
- <section style={{ marginBottom: 36 }}>
+ {/* Section 3: Growth Stages */}
+ <section
+ className="animate-fade-up"
+ style={{
+ marginBottom: 36,
+ animationDelay: "0.3s",
+ opacity: 0,
+ animationFillMode: "forwards",
+ }}
+ >
<SectionHeader>{dict["help_section_stages"]}</SectionHeader>
<div
style={{
@@ -1199,8 +1225,16 @@ export default function Help() {
</div>
</section>
- {/* ── Section 4: Manual Intervention ── */}
- <section style={{ marginBottom: 36 }}>
+ {/* Section 4: Manual Intervention */}
+ <section
+ className="animate-fade-up"
+ style={{
+ marginBottom: 36,
+ animationDelay: "0.4s",
+ opacity: 0,
+ animationFillMode: "forwards",
+ }}
+ >
<SectionHeader>{dict["help_section_manual"]}</SectionHeader>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{MANUAL_SCENARIOS.map((s) => (
@@ -1209,8 +1243,9 @@ export default function Help() {
</div>
</section>
- {/* ── AI CTA ── */}
+ {/* AI CTA */}
<div
+ className="animate-fade-scale"
style={{
padding: "24px",
borderRadius: 16,
@@ -1223,6 +1258,9 @@ export default function Help() {
gap: 16,
flexWrap: "wrap",
marginBottom: 8,
+ animationDelay: "0.5s",
+ opacity: 0,
+ animationFillMode: "forwards",
}}
>
<div>
@@ -1240,26 +1278,12 @@ export default function Help() {
{dict["help_ai_cta_desc"]}
</div>
</div>
- <button
+ <PrimaryButton
onClick={() => navigate("/intelligence")}
- style={{
- padding: "10px 20px",
- borderRadius: 10,
- background: "var(--green)",
- border: "none",
- color: "#0c1a0e",
- fontWeight: 700,
- fontSize: 13,
- cursor: "pointer",
- display: "flex",
- alignItems: "center",
- gap: 6,
- flexShrink: 0,
- }}
+ icon={Sparkles}
>
- <Sparkles size={14} />
{dict["help_ai_cta_btn"]}
- </button>
+ </PrimaryButton>
</div>
</div>
);
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
@@ -14,7 +14,7 @@ import {
Globe,
HelpCircle,
} from "lucide-react";
-import Sidebar from "../components/Sidebar";
+import { PageShell, PageHeader, SectionCard } from "../components/ui";
import { useSettings } from "../hooks/useSettings";
import { useT } from "../hooks/useTranslation";
import { USE_MOCK_DATA } from "../data/mockData";
@@ -59,22 +59,6 @@ function SectionHeader({ icon: Icon, title, sub }) {
);
}
-function Card({ children, style }) {
- return (
- <div
- style={{
- borderRadius: 16,
- padding: 24,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- ...style,
- }}
- >
- {children}
- </div>
- );
-}
-
function FieldRow({ label, hint, children }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
@@ -227,16 +211,7 @@ export default function SettingsPage() {
);
return (
- <div
- style={{
- display: "flex",
- height: "100vh",
- overflow: "hidden",
- background: "var(--bg)",
- }}
- >
- <Sidebar />
-
+ <PageShell>
{showOnboarding && (
<Onboarding
onDone={() => {
@@ -255,23 +230,11 @@ export default function SettingsPage() {
}}
>
{/* Header */}
- <header
- style={{
- flexShrink: 0,
- padding: "0 28px",
- height: 64,
- borderBottom: "1px solid var(--border)",
- background: "var(--bg-2)",
- display: "flex",
- alignItems: "center",
- justifyContent: "space-between",
- }}
+ <PageHeader
+ title={t("settings_title")}
+ subtitle={t("settings_subtitle")}
>
- <div>
- <h1 className="page-title">{t("settings_title")}</h1>
- <p className="page-subtitle">{t("settings_subtitle")}</p>
- </div>
- <div style={{ display: "flex", gap: 10 }}>
+ <div style={{ display: "flex", gap: 10, marginLeft: "auto" }}>
<button
onClick={handleReset}
style={{
@@ -317,7 +280,7 @@ export default function SettingsPage() {
)}
</button>
</div>
- </header>
+ </PageHeader>
<div style={{ flex: 1, overflowY: "auto", padding: 28 }}>
<div
@@ -329,7 +292,7 @@ export default function SettingsPage() {
}}
>
{/* Profile */}
- <Card>
+ <SectionCard>
<SectionHeader
icon={User}
title={t("settings_profile")}
@@ -376,10 +339,10 @@ export default function SettingsPage() {
/>
</FieldRow>
</div>
- </Card>
+ </SectionCard>
{/* Appearance */}
- <Card>
+ <SectionCard>
<SectionHeader
icon={Sun}
title={t("settings_appearance")}
@@ -421,10 +384,10 @@ export default function SettingsPage() {
/>
</FieldRow>
</div>
- </Card>
+ </SectionCard>
{/* Language */}
- <Card>
+ <SectionCard>
<SectionHeader
icon={Globe}
title={t("settings_language")}
@@ -450,10 +413,10 @@ export default function SettingsPage() {
हिंदी भाषा चुनी गई है। सहेजने के बाद पूरा ऐप हिंदी में दिखेगा।
</div>
)}
- </Card>
+ </SectionCard>
{/* Help & Onboarding */}
- <Card>
+ <SectionCard>
<SectionHeader
icon={HelpCircle}
title={t("settings_onboarding")}
@@ -481,10 +444,10 @@ export default function SettingsPage() {
<HelpCircle size={14} />
{t("settings_restart_onboarding")}
</button>
- </Card>
+ </SectionCard>
{/* Display */}
- <Card>
+ <SectionCard>
<SectionHeader
icon={LayoutGrid}
title={t("settings_display_section")}
@@ -534,10 +497,10 @@ export default function SettingsPage() {
</select>
</FieldRow>
</div>
- </Card>
+ </SectionCard>
{/* Alerts */}
- <Card>
+ <SectionCard>
<SectionHeader
icon={Bell}
title={t("settings_alerts_section")}
@@ -551,10 +514,10 @@ export default function SettingsPage() {
disabledLabel={t("common_hiding_acked")}
/>
</FieldRow>
- </Card>
+ </SectionCard>
{/* Data Source */}
- <Card>
+ <SectionCard>
<SectionHeader
icon={Database}
title={t("settings_data_source")}
@@ -605,10 +568,10 @@ export default function SettingsPage() {
</div>
</div>
</div>
- </Card>
+ </SectionCard>
</div>
</div>
</main>
- </div>
+ </PageShell>
);
}
diff --git a/frontend/src/utils/translations.js b/frontend/src/utils/translations.js
@@ -174,6 +174,7 @@ const en = {
alerts_subtitle_loading: "Analyzing sensor history…",
alerts_subtitle: "{unacked} unacknowledged · {total} total",
alerts_ack_all: "Ack all",
+ alerts_ack_all_btn: "Acknowledge All",
alerts_unacked: "UNACKNOWLEDGED · {n}",
alerts_acknowledged: "ACKNOWLEDGED · {n}",
alerts_empty_connected: "All clear for the selected filter",
@@ -619,6 +620,7 @@ const hi = {
alerts_subtitle_loading: "सेंसर इतिहास की जांच हो रही है…",
alerts_subtitle: "{unacked} अनदेखे · {total} कुल",
alerts_ack_all: "सभी देखे",
+ alerts_ack_all_btn: "सभी स्वीकार करें",
alerts_unacked: "अनदेखे · {n}",
alerts_acknowledged: "देखे गए · {n}",
alerts_empty_connected: "चुने फ़िल्टर के लिए सब ठीक है",