demeter

Autonomous Hydroponic Intelligence

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}