demeter
Autonomous Hydroponic Intelligencegit clone https://github.com/maydayv7/demeter
frontend/src/pages/Dashboard.jsx (25385B)
1import React, { useState, useEffect, useMemo } from "react";
2import { useNavigate } from "react-router-dom";
3import { useFarmData } from "../hooks/useFarmData";
4import { useSettings } from "../hooks/useSettings";
5import { useT } from "../hooks/useTranslation";
6import {
7 extractSensors,
8 calculateMaturity,
9 deriveCropStatus,
10 isReadyToHarvest,
11 getDaysRemaining,
12 SUPPORTED_CROPS,
13} from "../utils/dataUtils";
14import {
15 SlidersHorizontal,
16 Thermometer,
17 Droplet,
18 ArrowUpRight,
19 Clock,
20 RefreshCw,
21 Leaf,
22 Activity,
23 PlusCircle,
24 Scissors,
25} from "lucide-react";
26import {
27 PageShell,
28 PageHeader,
29 IconButton,
30 PrimaryButton,
31 SearchBar,
32 FilterBar,
33 FilterPill,
34 SelectField,
35 StatusBadge,
36 Pagination,
37 EmptyState,
38 LoadingShimmer,
39} from "../components/ui";
40import tomatoImg from "../assets/tomato.jpg";
41import basilImg from "../assets/basil.jpg";
42import strawberryImg from "../assets/strawberry.jpg";
43import lettuceImg from "../assets/lettuce.jpg";
44
45const STAGES_KEYS = [
46 "stage_all",
47 "stage_seedling",
48 "stage_vegetative",
49 "stage_flowering",
50 "stage_fruiting",
51];
52// Only 4 supported crops (+ All)
53const CROPS = ["All", ...SUPPORTED_CROPS];
54const STATUSES_KEYS = [
55 "stage_all",
56 "dash_healthy",
57 "dash_attention",
58 "dash_critical",
59];
60
61function CropCard({ data, onClick, t, td }) {
62 const maturity = data.maturity || 40;
63 const statusKey =
64 data.status === "Healthy"
65 ? "dash_healthy"
66 : data.status === "Attention"
67 ? "dash_attention"
68 : "dash_critical";
69
70 return (
71 <div
72 onClick={onClick}
73 className="card-hover"
74 style={{
75 borderRadius: 16,
76 overflow: "hidden",
77 cursor: "pointer",
78 background: "var(--surface)",
79 border: data.harvestReady
80 ? "2px solid rgba(245,158,11,0.5)"
81 : "1px solid var(--border)",
82 position: "relative",
83 }}
84 >
85 {/* Harvest Ready Banner */}
86 {data.harvestReady && (
87 <div
88 className="harvest-badge"
89 style={{
90 position: "absolute",
91 top: 0,
92 left: 0,
93 right: 0,
94 zIndex: 5,
95 padding: "5px 10px",
96 background: "rgba(245,158,11,0.92)",
97 display: "flex",
98 alignItems: "center",
99 justifyContent: "center",
100 gap: 6,
101 fontSize: 11,
102 fontFamily: "DM Mono, monospace",
103 fontWeight: 700,
104 color: "#1a0a00",
105 }}
106 >
107 <Scissors size={11} />
108 {t("dash_harvest_badge")}
109 </div>
110 )}
111
112 {/* Image header */}
113 <div
114 style={{
115 position: "relative",
116 height: 130,
117 background: "var(--bg-3)",
118 marginTop: data.harvestReady ? 27 : 0,
119 }}
120 >
121 <div
122 style={{
123 position: "absolute",
124 inset: 0,
125 display: "flex",
126 alignItems: "center",
127 justifyContent: "center",
128 }}
129 >
130 <Leaf
131 size={38}
132 style={{ color: "var(--border-bright)", opacity: 0.4 }}
133 />
134 </div>
135 {data.image && (
136 <img
137 src={data.image}
138 alt={data.name}
139 style={{
140 position: "absolute",
141 inset: 0,
142 width: "100%",
143 height: "100%",
144 objectFit: "cover",
145 opacity: 0.7,
146 }}
147 onError={(e) => {
148 e.target.style.display = "none";
149 }}
150 />
151 )}
152 {/* Bottom fade */}
153 <div
154 style={{
155 position: "absolute",
156 inset: 0,
157 background:
158 "linear-gradient(to top, var(--surface) 0%, transparent 60%)",
159 }}
160 />
161
162 <StatusBadge
163 status={data.status}
164 label={t(statusKey).toUpperCase()}
165 style={{ position: "absolute", top: 10, right: 10 }}
166 />
167 {/* Seq badge */}
168 <div
169 style={{
170 position: "absolute",
171 top: 10,
172 left: 10,
173 fontSize: 10,
174 fontFamily: "DM Mono, monospace",
175 padding: "2px 6px",
176 borderRadius: 4,
177 background: "rgba(0,0,0,0.5)",
178 color: "var(--text-3)",
179 }}
180 >
181 #{data.seq || 1}
182 </div>
183 </div>
184
185 <div
186 style={{
187 padding: "12px 14px",
188 display: "flex",
189 flexDirection: "column",
190 gap: 10,
191 }}
192 >
193 {/* Name */}
194 <div>
195 <div style={{ fontWeight: 700, fontSize: 14, color: "var(--text)" }}>
196 {td(data.name)}
197 </div>
198 <div
199 style={{
200 fontSize: 11,
201 marginTop: 2,
202 fontFamily: "DM Mono, monospace",
203 color: "var(--text-3)",
204 }}
205 >
206 {data.cropId} · {td(data.statusMsg)}
207 </div>
208 </div>
209
210 {/* Maturity bar */}
211 <div>
212 <div
213 style={{
214 display: "flex",
215 justifyContent: "space-between",
216 marginBottom: 4,
217 }}
218 >
219 <span
220 style={{
221 fontSize: 10,
222 fontFamily: "DM Mono, monospace",
223 color: "var(--text-3)",
224 }}
225 >
226 {t("dash_maturity")}
227 </span>
228 <span
229 style={{
230 fontSize: 11,
231 fontFamily: "DM Mono, monospace",
232 color: maturity >= 80 ? "var(--amber)" : "var(--green)",
233 fontWeight: 600,
234 }}
235 >
236 {maturity}%
237 </span>
238 </div>
239 <div
240 style={{ height: 3, borderRadius: 2, background: "var(--border)" }}
241 >
242 <div
243 className="progress-fill"
244 style={{
245 width: `${maturity}%`,
246 height: "100%",
247 borderRadius: 2,
248 background:
249 maturity >= 80
250 ? "var(--amber)"
251 : maturity > 40
252 ? "var(--green)"
253 : "var(--text-3)",
254 }}
255 />
256 </div>
257 </div>
258
259 {/* Sensor pair */}
260 <div
261 style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}
262 >
263 <div style={{ display: "flex", alignItems: "center", gap: 5 }}>
264 <Thermometer
265 size={12}
266 style={{ color: "var(--blue)", flexShrink: 0 }}
267 />
268 <span className="sensor-value-xs" style={{ color: "var(--blue)" }}>
269 {data.sensors.temp}°C
270 </span>
271 </div>
272 <div style={{ display: "flex", alignItems: "center", gap: 5 }}>
273 <Droplet
274 size={12}
275 style={{ color: "var(--green)", flexShrink: 0 }}
276 />
277 <span className="sensor-value-xs" style={{ color: "var(--green)" }}>
278 pH {data.sensors.ph}
279 </span>
280 </div>
281 </div>
282
283 {/* Footer */}
284 <div
285 style={{
286 display: "flex",
287 alignItems: "center",
288 justifyContent: "space-between",
289 paddingTop: 8,
290 borderTop: "1px solid var(--border)",
291 }}
292 >
293 <div
294 style={{
295 display: "flex",
296 alignItems: "center",
297 gap: 4,
298 fontSize: 11,
299 color: data.harvestReady ? "var(--amber)" : "var(--text-3)",
300 fontWeight: data.harvestReady ? 600 : 400,
301 }}
302 >
303 {data.harvestReady ? (
304 <>
305 <Scissors size={10} />
306 {t("dash_ready")}
307 </>
308 ) : (
309 <>
310 <Clock size={10} />
311 {data.daysLeft !== null && data.daysLeft > 0
312 ? t("dash_days_left", { n: data.daysLeft })
313 : t("dash_ready")}
314 </>
315 )}
316 </div>
317 <ArrowUpRight size={14} style={{ color: "var(--text-3)" }} />
318 </div>
319 </div>
320 </div>
321 );
322}
323
324function AddCropCard({ onClick, t }) {
325 return (
326 <div
327 onClick={onClick}
328 className="card-hover"
329 style={{
330 borderRadius: 16,
331 overflow: "hidden",
332 cursor: "pointer",
333 background: "rgba(74,222,128,0.04)",
334 border: "2px dashed rgba(74,222,128,0.25)",
335 display: "flex",
336 flexDirection: "column",
337 alignItems: "center",
338 justifyContent: "center",
339 minHeight: 280,
340 gap: 14,
341 padding: 24,
342 transition: "all 0.2s",
343 }}
344 onMouseEnter={(e) => {
345 e.currentTarget.style.borderColor = "rgba(74,222,128,0.5)";
346 e.currentTarget.style.background = "rgba(74,222,128,0.07)";
347 }}
348 onMouseLeave={(e) => {
349 e.currentTarget.style.borderColor = "rgba(74,222,128,0.25)";
350 e.currentTarget.style.background = "rgba(74,222,128,0.04)";
351 }}
352 >
353 <div
354 style={{
355 width: 52,
356 height: 52,
357 borderRadius: 16,
358 background: "rgba(74,222,128,0.12)",
359 border: "1px solid rgba(74,222,128,0.3)",
360 display: "flex",
361 alignItems: "center",
362 justifyContent: "center",
363 }}
364 >
365 <PlusCircle size={24} style={{ color: "var(--green)" }} />
366 </div>
367 <div style={{ textAlign: "center" }}>
368 <div style={{ fontWeight: 700, fontSize: 14, color: "var(--green)" }}>
369 {t("dash_add_crop")}
370 </div>
371 <div
372 style={{
373 fontSize: 11,
374 color: "var(--text-3)",
375 marginTop: 4,
376 fontFamily: "DM Mono, monospace",
377 }}
378 >
379 {t("add_subtitle")}
380 </div>
381 </div>
382 </div>
383 );
384}
385
386export default function Dashboard() {
387 const navigate = useNavigate();
388 const { dashboard, loading, refreshData } = useFarmData();
389 const { settings } = useSettings();
390 const { t, td } = useT();
391 const pageSize = settings.maxResultsPerPage || 12;
392
393 const [crops, setCrops] = useState([]);
394 const [search, setSearch] = useState("");
395 const [filterStage, setFilterStage] = useState("All");
396 const [filterCrop, setFilterCrop] = useState("All");
397 const [filterStatus, setFilterStatus] = useState("All");
398 const [filterReady, setFilterReady] = useState(false);
399 const [showFilters, setShowFilters] = useState(false);
400 const [page, setPage] = useState(1);
401
402 const getImg = (name) => {
403 if (!name) return null;
404 const n = name.toLowerCase();
405 if (n.includes("tomato")) return tomatoImg;
406 if (n.includes("basil")) return basilImg;
407 if (n.includes("strawberry")) return strawberryImg;
408 return lettuceImg;
409 };
410
411 useEffect(() => {
412 if (dashboard) {
413 setCrops(
414 dashboard.map((item) => {
415 // Dashboard items are normalized: { id, payload: {...} }
416 const p = item.payload || {};
417 const sensors = extractSensors(p);
418 const rawStage = p.stage || "";
419 const daysLeft = getDaysRemaining(p);
420
421 return {
422 id: p.crop_id || item.id,
423 cropId: p.crop_id || "-",
424 name: p.crop || t("common_unknown"),
425 statusMsg: rawStage || t("dash_status_growing"),
426 image: getImg(p.crop) || p.image_url,
427 status: deriveCropStatus(p),
428 maturity: calculateMaturity(p),
429 harvestReady: isReadyToHarvest(p),
430 seq: p.sequence_number,
431 daysLeft: daysLeft,
432 sensors: { temp: sensors.temp, ph: sensors.ph },
433 stage: rawStage,
434 rawCrop: (p.crop || "").trim(),
435 };
436 }),
437 );
438 setPage(1);
439 }
440 }, [dashboard, t]);
441
442 const harvestReadyCrops = useMemo(
443 () => crops.filter((c) => c.harvestReady),
444 [crops],
445 );
446
447 const filtered = useMemo(
448 () =>
449 crops.filter((c) => {
450 const q = search.toLowerCase();
451 if (
452 q &&
453 !c.name.toLowerCase().includes(q) &&
454 !c.cropId.toLowerCase().includes(q) &&
455 !td(c.statusMsg).toLowerCase().includes(q)
456 )
457 return false;
458 if (
459 filterStage !== "All" &&
460 c.stage.toLowerCase() !== filterStage.toLowerCase()
461 )
462 return false;
463 if (filterCrop !== "All" && c.rawCrop !== filterCrop) return false;
464 if (filterStatus !== "All" && c.status !== filterStatus) return false;
465 if (filterReady && !c.harvestReady) return false;
466 return true;
467 }),
468 [crops, search, filterStage, filterCrop, filterStatus, filterReady, td],
469 );
470
471 const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
472 const paginated = filtered.slice((page - 1) * pageSize, page * pageSize);
473 const activeFilters =
474 [filterStage, filterCrop, filterStatus].filter((f) => f !== "All").length +
475 (filterReady ? 1 : 0);
476
477 const summary = useMemo(
478 () => ({
479 total: crops.length,
480 healthy: crops.filter((c) => c.status === "Healthy").length,
481 attention: crops.filter((c) => c.status === "Attention").length,
482 critical: crops.filter((c) => c.status === "Critical").length,
483 }),
484 [crops],
485 );
486
487 const STAGES_EN = ["All", "Seedling", "Vegetative", "Flowering", "Fruiting"];
488 const STATUS_EN = ["All", "Healthy", "Attention", "Critical"];
489
490 return (
491 <PageShell>
492 {/* Header */}
493 <PageHeader
494 title={t("dash_title")}
495 subtitle={t("dash_subtitle", {
496 filtered: filtered.length,
497 total: crops.length,
498 })}
499 >
500 {/* Summary chips */}
501 <div
502 style={{
503 display: "flex",
504 alignItems: "center",
505 gap: 8,
506 marginLeft: 16,
507 }}
508 >
509 {[
510 {
511 labelKey: "dash_healthy",
512 count: summary.healthy,
513 color: "var(--green)",
514 },
515 {
516 labelKey: "dash_attention",
517 count: summary.attention,
518 color: "var(--amber)",
519 },
520 {
521 labelKey: "dash_critical",
522 count: summary.critical,
523 color: "var(--red)",
524 },
525 ].map(({ labelKey, count, color }) => (
526 <div
527 key={labelKey}
528 style={{
529 display: "flex",
530 alignItems: "center",
531 gap: 5,
532 padding: "3px 10px",
533 borderRadius: 20,
534 background: "var(--surface)",
535 border: "1px solid var(--border)",
536 fontSize: 12,
537 fontFamily: "DM Mono, monospace",
538 color,
539 }}
540 >
541 <span style={{ fontWeight: 700 }}>{count}</span>
542 <span style={{ opacity: 0.7 }}>{t(labelKey)}</span>
543 </div>
544 ))}
545 </div>
546
547 <PrimaryButton
548 onClick={() => navigate("/add-crop")}
549 icon={PlusCircle}
550 style={{ marginLeft: "auto" }}
551 >
552 {t("dash_add_crop")}
553 </PrimaryButton>
554
555 <IconButton onClick={refreshData}>
556 <RefreshCw size={15} className={loading ? "animate-spin" : ""} />
557 </IconButton>
558 </PageHeader>
559
560 {harvestReadyCrops.length > 0 && (
561 <div
562 className="animate-fade-in"
563 style={{
564 flexShrink: 0,
565 padding: "10px 24px",
566 background: "rgba(245,158,11,0.12)",
567 borderBottom: "1px solid rgba(245,158,11,0.3)",
568 display: "flex",
569 alignItems: "center",
570 gap: 12,
571 }}
572 >
573 <span
574 className="harvest-pulse"
575 style={{
576 width: 10,
577 height: 10,
578 borderRadius: "50%",
579 background: "var(--amber)",
580 flexShrink: 0,
581 }}
582 />
583 <Scissors
584 size={16}
585 style={{ color: "var(--amber)", flexShrink: 0 }}
586 />
587 <div style={{ flex: 1 }}>
588 <span
589 style={{ fontWeight: 700, fontSize: 13, color: "var(--amber)" }}
590 >
591 {t("dash_harvest_banner", {
592 n: harvestReadyCrops.length,
593 s: harvestReadyCrops.length !== 1 ? "s" : "",
594 })}
595 </span>
596 <span
597 style={{
598 fontSize: 12,
599 color: "var(--text-3)",
600 marginLeft: 10,
601 fontFamily: "DM Mono, monospace",
602 }}
603 >
604 {t("dash_harvest_banner_sub")}
605 </span>
606 </div>
607 <button
608 onClick={() => {
609 setFilterReady(true);
610 setFilterStatus("All");
611 setFilterStage("All");
612 setFilterCrop("All");
613 setSearch("");
614 setPage(1);
615 }}
616 style={{
617 padding: "5px 14px",
618 borderRadius: 8,
619 fontSize: 12,
620 fontFamily: "DM Mono, monospace",
621 background: "rgba(245,158,11,0.2)",
622 border: "1px solid rgba(245,158,11,0.4)",
623 color: "var(--amber)",
624 cursor: "pointer",
625 fontWeight: 600,
626 }}
627 >
628 {t("dash_harvest_action")}
629 </button>
630 </div>
631 )}
632
633 <FilterBar>
634 <SearchBar
635 value={search}
636 onChange={(val) => {
637 setSearch(val);
638 setPage(1);
639 }}
640 onClear={() => setSearch("")}
641 placeholder={t("dash_search_placeholder")}
642 />
643
644 <button
645 onClick={() => setShowFilters(!showFilters)}
646 style={{
647 display: "flex",
648 alignItems: "center",
649 gap: 6,
650 padding: "7px 12px",
651 borderRadius: 8,
652 fontSize: 12,
653 cursor: "pointer",
654 background: showFilters ? "rgba(74,222,128,0.1)" : "var(--surface)",
655 border: `1px solid ${showFilters ? "rgba(74,222,128,0.3)" : "var(--border)"}`,
656 color: showFilters ? "var(--green)" : "var(--text-2)",
657 }}
658 >
659 <SlidersHorizontal size={13} /> {t("dash_filters")}
660 {activeFilters > 0 && (
661 <span
662 style={{
663 padding: "0 5px",
664 borderRadius: 4,
665 fontSize: 10,
666 fontFamily: "DM Mono, monospace",
667 background: "var(--green)",
668 color: "var(--btn-on-green)",
669 }}
670 >
671 {activeFilters}
672 </span>
673 )}
674 </button>
675
676 {/* Quick stage pills */}
677 <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
678 {STAGES_EN.slice(0, 4).map((s, i) => (
679 <FilterPill
680 key={s}
681 active={filterStage === s}
682 onClick={() => {
683 setFilterStage(filterStage === s ? "All" : s);
684 setPage(1);
685 }}
686 >
687 {t(STAGES_KEYS[i])}
688 </FilterPill>
689 ))}
690 </div>
691 </FilterBar>
692
693 {/* Expanded filters */}
694 {showFilters && (
695 <div
696 className="animate-fade-in"
697 style={{
698 flexShrink: 0,
699 padding: "8px 24px",
700 borderBottom: "1px solid var(--border)",
701 background: "var(--bg-3)",
702 display: "flex",
703 alignItems: "center",
704 gap: 24,
705 }}
706 >
707 {[
708 {
709 label: t("add_field_crop_type"),
710 value: filterCrop,
711 set: (v) => {
712 setFilterCrop(v);
713 setPage(1);
714 },
715 opts: CROPS,
716 optLabels: CROPS.map((o) =>
717 o === "All" ? t("stage_all") : td(o),
718 ),
719 },
720 {
721 label: t("add_field_stage"),
722 value: filterStage,
723 set: (v) => {
724 setFilterStage(v);
725 setPage(1);
726 },
727 opts: STAGES_EN,
728 optLabels: STAGES_KEYS.map((k) => t(k)),
729 },
730 {
731 label: t("analytics_th_status"),
732 value: filterStatus,
733 set: (v) => {
734 setFilterStatus(v);
735 setPage(1);
736 },
737 opts: STATUS_EN,
738 optLabels: STATUSES_KEYS.map((k) => t(k)),
739 },
740 ].map(({ label, value, set, opts, optLabels }) => (
741 <div
742 key={label}
743 style={{ display: "flex", alignItems: "center", gap: 8 }}
744 >
745 <span
746 style={{
747 fontSize: 12,
748 fontFamily: "DM Mono, monospace",
749 color: "var(--text-3)",
750 }}
751 >
752 {label}
753 </span>
754 <SelectField
755 value={value}
756 onChange={(e) => set(e.target.value)}
757 options={opts.map((o, i) => ({
758 value: o,
759 label: optLabels ? optLabels[i] : o,
760 }))}
761 />
762 </div>
763 ))}
764 <button
765 onClick={() => {
766 setSearch("");
767 setFilterStage("All");
768 setFilterCrop("All");
769 setFilterStatus("All");
770 setFilterReady(false);
771 setPage(1);
772 }}
773 style={{
774 marginLeft: "auto",
775 fontSize: 11,
776 fontFamily: "DM Mono, monospace",
777 background: "none",
778 border: "none",
779 cursor: "pointer",
780 color: "var(--text-3)",
781 }}
782 >
783 {t("dash_clear_all")}
784 </button>
785 </div>
786 )}
787
788 <div style={{ flex: 1, overflowY: "auto", padding: 24 }}>
789 {loading ? (
790 <div
791 style={{
792 display: "grid",
793 gridTemplateColumns: "repeat(auto-fill,minmax(200px,1fr))",
794 gap: 16,
795 }}
796 >
797 {Array.from({ length: 8 }).map((_, i) => (
798 <LoadingShimmer key={i} count={1} height={255} />
799 ))}
800 </div>
801 ) : paginated.length > 0 ? (
802 <>
803 <div
804 style={{
805 display: "grid",
806 gridTemplateColumns: "repeat(auto-fill,minmax(200px,1fr))",
807 gap: 16,
808 }}
809 >
810 {paginated.map((crop) => (
811 <CropCard
812 key={crop.id}
813 data={crop}
814 t={t}
815 td={td}
816 onClick={() => navigate(`/crop/${crop.id}`)}
817 />
818 ))}
819 {/* Always visible at end of last page */}
820 {page === totalPages && !filterReady && (
821 <AddCropCard onClick={() => navigate("/add-crop")} t={t} />
822 )}
823 </div>
824
825 {/* Pagination */}
826 {totalPages > 1 && (
827 <Pagination
828 page={page}
829 totalPages={totalPages}
830 setPage={setPage}
831 style={{ marginTop: 24 }}
832 />
833 )}
834 </>
835 ) : (
836 <div
837 style={{
838 display: "flex",
839 flexDirection: "column",
840 alignItems: "center",
841 justifyContent: "center",
842 height: "100%",
843 gap: 16,
844 }}
845 >
846 {crops.length === 0 ? (
847 <EmptyState
848 icon={PlusCircle}
849 title={t("dash_no_crops")}
850 description={t("dash_no_crops_sub")}
851 />
852 ) : (
853 <EmptyState
854 icon={Activity}
855 title={t("dash_no_match")}
856 description={
857 <button
858 onClick={() => {
859 setSearch("");
860 setFilterStage("All");
861 setFilterCrop("All");
862 setFilterStatus("All");
863 setFilterReady(false);
864 setPage(1);
865 }}
866 style={{
867 fontSize: 12,
868 fontFamily: "DM Mono, monospace",
869 padding: "6px 16px",
870 borderRadius: 8,
871 background: "var(--surface)",
872 border: "1px solid var(--border)",
873 color: "var(--text-3)",
874 cursor: "pointer",
875 marginTop: 16,
876 }}
877 >
878 {t("dash_clear_filters")}
879 </button>
880 }
881 />
882 )}
883 </div>
884 )}
885 </div>
886 </PageShell>
887 );
888}