demeter

Autonomous Hydroponic Intelligence

frontend/src/pages/AddCrop.jsx (24934B)


  1import { useState, useEffect } from "react";
  2import { useNavigate } from "react-router-dom";
  3import { useFarmData } from "../hooks/useFarmData";
  4import { useT } from "../hooks/useTranslation";
  5import {
  6  ArrowLeft,
  7  CheckCircle2,
  8  Database,
  9  MapPin,
 10  StickyNote,
 11  Upload,
 12  Sprout,
 13  Clock,
 14  ChevronRight,
 15  LayoutGrid,
 16  Leaf,
 17} from "lucide-react";
 18import {
 19  PageShell,
 20  PageHeader,
 21  IconButton,
 22  SectionCard,
 23} from "../components/ui";
 24import { createCrop, fetchDashboardData } from "../api/farmApi";
 25import {
 26  CROP_LIFECYCLES,
 27  CROP_CYCLE_HOURS,
 28  SUPPORTED_CROPS,
 29} from "../utils/dataUtils";
 30
 31// Lifecycle Timeline Component
 32function LifecycleTimeline({ cropName }) {
 33  const lc = CROP_LIFECYCLES[cropName?.toLowerCase()];
 34  if (!lc) return null;
 35
 36  const total = lc.totalHours;
 37  const colors = [
 38    "var(--text-3)",
 39    "var(--green)",
 40    "var(--amber)",
 41    "var(--red)",
 42  ];
 43
 44  return (
 45    <div style={{ marginTop: 12 }}>
 46      <div
 47        style={{
 48          display: "flex",
 49          gap: 0,
 50          borderRadius: 6,
 51          overflow: "hidden",
 52          height: 10,
 53          marginBottom: 8,
 54        }}
 55      >
 56        {lc.stages.map((s, i) => {
 57          const endH = s.endH ?? total;
 58          const width = ((endH - s.startH) / total) * 100;
 59          return (
 60            <div
 61              key={s.name}
 62              style={{
 63                width: `${width}%`,
 64                background: colors[i % colors.length],
 65                opacity: 0.7,
 66              }}
 67              title={`${s.name}: ${s.startH}h–${s.endH ?? "harvest"}h`}
 68            />
 69          );
 70        })}
 71      </div>
 72      <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
 73        {lc.stages.map((s, i) => (
 74          <div
 75            key={s.name}
 76            style={{ display: "flex", alignItems: "center", gap: 5 }}
 77          >
 78            <span
 79              style={{
 80                width: 8,
 81                height: 8,
 82                borderRadius: "50%",
 83                background: colors[i % colors.length],
 84                display: "inline-block",
 85                opacity: 0.8,
 86              }}
 87            />
 88            <span
 89              style={{
 90                fontSize: 10,
 91                fontFamily: "DM Mono, monospace",
 92                color: "var(--text-3)",
 93                textTransform: "capitalize",
 94              }}
 95            >
 96              {s.name}
 97              {s.endH
 98                ? ` (${Math.round((s.endH - s.startH) / 24)}d)`
 99                : " (harvest)"}
100            </span>
101          </div>
102        ))}
103        <span
104          style={{
105            marginLeft: "auto",
106            fontSize: 10,
107            fontFamily: "DM Mono, monospace",
108            color: "var(--text-3)",
109          }}
110        >
111          Total: ~{lc.totalDays} days
112        </span>
113      </div>
114    </div>
115  );
116}
117
118// MAIN
119
120export default function AddCrop() {
121  const navigate = useNavigate();
122  const { refreshData } = useFarmData();
123  const { t } = useT();
124
125  const [form, setForm] = useState({
126    crop_id: "",
127    crop: "Lettuce",
128    location: "",
129    notes: "",
130  });
131
132  const [sensorIds, setSensorIds] = useState({
133    ph_sensor: "",
134    ec_sensor: "",
135    temp_sensor: "",
136    humidity_sensor: "",
137  });
138
139  const [imageFile, setImageFile] = useState(null);
140  const [imagePreview, setImagePreview] = useState(null);
141
142  const [submitting, setSubmitting] = useState(false);
143  const [error, setError] = useState("");
144  const [success, setSuccess] = useState(false);
145  const [createdId, setCreatedId] = useState("");
146
147  // Auto-generate crop_id suggestion when crop type changes
148  useEffect(() => {
149    if (!form.crop_id) {
150      const year = new Date().getFullYear();
151      const letter = String.fromCharCode(65 + Math.floor(Math.random() * 26));
152      setForm((f) => ({
153        ...f,
154        crop_id: `Batch_${form.crop}_${year}${letter}`,
155      }));
156    }
157  }, [form.crop]); // eslint-disable-line react-hooks/exhaustive-deps
158
159  const cycleDuration = CROP_CYCLE_HOURS[form.crop?.toLowerCase()] || 1;
160
161  const handleImageChange = (e) => {
162    const file = e.target.files?.[0];
163    if (!file) return;
164    setImageFile(file);
165    const reader = new FileReader();
166    reader.onload = (ev) => setImagePreview(ev.target.result);
167    reader.readAsDataURL(file);
168  };
169
170  const handleSubmit = async () => {
171    setError("");
172    if (!form.crop_id.trim()) {
173      setError("Crop ID is required.");
174      return;
175    }
176    if (!form.crop) {
177      setError("Please select a crop type.");
178      return;
179    }
180
181    setSubmitting(true);
182    try {
183      // Check uniqueness
184      const existing = await fetchDashboardData();
185      const ids = (existing || [])
186        .map((d) => d.crop_id || d.payload?.crop_id)
187        .filter(Boolean);
188      if (ids.includes(form.crop_id.trim())) {
189        setError(t("add_crop_id_exists"));
190        setSubmitting(false);
191        return;
192      }
193
194      const payload = {
195        crop_id: form.crop_id.trim(),
196        crop: form.crop,
197        location: form.location,
198        notes: form.notes,
199        sensor_ids: sensorIds,
200        // image_url: upload to storage
201      };
202
203      await createCrop(payload);
204      await refreshData();
205      setCreatedId(form.crop_id.trim());
206      setSuccess(true);
207    } catch (err) {
208      setError(err.message || "Failed to register crop.");
209    } finally {
210      setSubmitting(false);
211    }
212  };
213
214  if (success) {
215    return (
216      <PageShell>
217        <PageHeader>
218          <IconButton onClick={() => navigate("/dashboard")}>
219            <ArrowLeft size={15} />
220          </IconButton>
221          <h1 className="page-title">{t("add_title")}</h1>
222        </PageHeader>
223        <div
224          style={{
225            flex: 1,
226            display: "flex",
227            alignItems: "center",
228            justifyContent: "center",
229            padding: 32,
230          }}
231        >
232          <div style={{ maxWidth: 420, width: "100%", textAlign: "center" }}>
233            <div
234              style={{
235                width: 72,
236                height: 72,
237                borderRadius: "50%",
238                background: "rgba(74,222,128,0.12)",
239                border: "1px solid rgba(74,222,128,0.3)",
240                display: "flex",
241                alignItems: "center",
242                justifyContent: "center",
243                margin: "0 auto 20px",
244              }}
245            >
246              <CheckCircle2 size={34} style={{ color: "var(--green)" }} />
247            </div>
248            <h2
249              style={{
250                fontSize: 22,
251                fontWeight: 700,
252                color: "var(--text)",
253                marginBottom: 8,
254              }}
255            >
256              {t("add_register_success")}
257            </h2>
258            <p
259              style={{
260                fontSize: 13,
261                fontFamily: "DM Mono, monospace",
262                color: "var(--text-3)",
263                marginBottom: 32,
264              }}
265            >
266              {createdId}
267            </p>
268            <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
269              <button
270                onClick={() => navigate(`/run-cycle/${createdId}`)}
271                style={{
272                  padding: "14px 24px",
273                  borderRadius: 12,
274                  background: "var(--green)",
275                  border: "none",
276                  color: "var(--btn-on-green)",
277                  fontWeight: 700,
278                  fontSize: 14,
279                  cursor: "pointer",
280                  display: "flex",
281                  alignItems: "center",
282                  justifyContent: "center",
283                  gap: 8,
284                }}
285              >
286                {t("add_run_first_cycle")} <ChevronRight size={16} />
287              </button>
288              <button
289                onClick={() => navigate("/dashboard")}
290                style={{
291                  padding: "12px 24px",
292                  borderRadius: 12,
293                  background: "var(--surface)",
294                  border: "1px solid var(--border)",
295                  color: "var(--text-2)",
296                  fontWeight: 600,
297                  fontSize: 13,
298                  cursor: "pointer",
299                  display: "flex",
300                  alignItems: "center",
301                  justifyContent: "center",
302                  gap: 8,
303                }}
304              >
305                <LayoutGrid size={15} /> {t("add_view_dashboard")}
306              </button>
307            </div>
308          </div>
309        </div>
310      </PageShell>
311    );
312  }
313
314  // Form state
315  return (
316    <PageShell>
317      <PageHeader>
318        <IconButton onClick={() => navigate("/dashboard")}>
319          <ArrowLeft size={15} />
320        </IconButton>
321        <div>
322          <h1 className="page-title">{t("add_title")}</h1>
323          <p className="page-subtitle">{t("add_subtitle")}</p>
324        </div>
325      </PageHeader>
326
327      <div
328        style={{
329          flex: 1,
330          overflowY: "auto",
331          padding: 24,
332          display: "flex",
333          flexDirection: "column",
334          gap: 20,
335          width: "100%",
336        }}
337      >
338        {/* Crop Type */}
339        <SectionCard>
340          <div className="section-label" style={{ marginBottom: 16 }}>
341            {t("add_field_crop_type")}
342          </div>
343          <div
344            style={{
345              display: "grid",
346              gridTemplateColumns: "repeat(2, 1fr)",
347              gap: 10,
348            }}
349          >
350            {SUPPORTED_CROPS.map((c) => (
351              <button
352                key={c}
353                onClick={() => setForm((f) => ({ ...f, crop: c, crop_id: "" }))}
354                style={{
355                  padding: "12px 16px",
356                  borderRadius: 12,
357                  border: `2px solid ${form.crop === c ? "var(--green)" : "var(--border)"}`,
358                  background:
359                    form.crop === c
360                      ? "rgba(74,222,128,0.08)"
361                      : "var(--surface)",
362                  color: form.crop === c ? "var(--green)" : "var(--text-2)",
363                  fontWeight: form.crop === c ? 700 : 500,
364                  fontSize: 13,
365                  cursor: "pointer",
366                  display: "flex",
367                  alignItems: "center",
368                  gap: 8,
369                  transition: "all 0.15s",
370                }}
371              >
372                <Leaf size={14} />
373                {c}
374                {form.crop === c && (
375                  <span
376                    style={{
377                      marginLeft: "auto",
378                      fontSize: 10,
379                      fontFamily: "DM Mono, monospace",
380                      opacity: 0.7,
381                    }}
382                  >
383                    {CROP_CYCLE_HOURS[c.toLowerCase()]}h/cycle
384                  </span>
385                )}
386              </button>
387            ))}
388          </div>
389        </SectionCard>
390
391        {/* Growth Timeline */}
392        {form.crop && (
393          <SectionCard>
394            <div className="section-label" style={{ marginBottom: 8 }}>
395              {t("add_lifecycle_label")}
396            </div>
397            <LifecycleTimeline cropName={form.crop} />
398          </SectionCard>
399        )}
400
401        {/* Crop ID */}
402        <SectionCard>
403          <div className="section-label" style={{ marginBottom: 12 }}>
404            {t("add_field_crop_id")}
405          </div>
406          <div
407            style={{
408              display: "flex",
409              alignItems: "center",
410              gap: 10,
411              padding: "10px 14px",
412              borderRadius: 10,
413              background: "var(--bg-3)",
414              border: `1px solid ${error.includes("ID") ? "var(--red)" : "var(--border)"}`,
415            }}
416          >
417            <Database
418              size={14}
419              style={{ color: "var(--text-3)", flexShrink: 0 }}
420            />
421            <input
422              type="text"
423              value={form.crop_id}
424              onChange={(e) =>
425                setForm((f) => ({ ...f, crop_id: e.target.value }))
426              }
427              placeholder={`e.g. Batch_${form.crop}_2025A`}
428              style={{
429                flex: 1,
430                background: "none",
431                border: "none",
432                outline: "none",
433                color: "var(--text)",
434                fontSize: 13,
435                fontFamily: "DM Mono, monospace",
436              }}
437            />
438          </div>
439          <p
440            style={{
441              fontSize: 11,
442              color: "var(--text-3)",
443              marginTop: 6,
444              fontFamily: "DM Mono, monospace",
445            }}
446          >
447            {t("add_field_crop_id_hint")}
448          </p>
449          {error.includes("ID") && (
450            <p
451              style={{
452                fontSize: 11,
453                color: "var(--red)",
454                marginTop: 4,
455                fontFamily: "DM Mono, monospace",
456              }}
457            >
458              {error}
459            </p>
460          )}
461        </SectionCard>
462
463        {/* Sensor Assignment */}
464        <SectionCard>
465          <div className="section-label" style={{ marginBottom: 12 }}>
466            Sensor Assignment
467          </div>
468          <div
469            style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}
470          >
471            {[
472              { key: "ph_sensor", label: "pH Sensor", color: "var(--green)" },
473              { key: "ec_sensor", label: "EC Sensor", color: "var(--amber)" },
474              { key: "temp_sensor", label: "Temp Sensor", color: "var(--red)" },
475              {
476                key: "humidity_sensor",
477                label: "Humidity Sensor",
478                color: "#a78bfa",
479              },
480            ].map(({ key, label, color }) => (
481              <div
482                key={key}
483                style={{
484                  padding: "10px 14px",
485                  borderRadius: 10,
486                  background: "var(--bg-3)",
487                  border: "1px solid var(--border)",
488                }}
489              >
490                <div
491                  style={{
492                    fontSize: 10,
493                    fontFamily: "DM Mono, monospace",
494                    color: "var(--text-3)",
495                    marginBottom: 6,
496                  }}
497                >
498                  {label}
499                </div>
500                <input
501                  type="text"
502                  value={sensorIds[key]}
503                  onChange={(e) =>
504                    setSensorIds((s) => ({ ...s, [key]: e.target.value }))
505                  }
506                  style={{
507                    width: "100%",
508                    background: "none",
509                    border: "none",
510                    outline: "none",
511                    color,
512                    fontFamily: "DM Mono, monospace",
513                    fontSize: 13,
514                    fontWeight: 700,
515                  }}
516                />
517              </div>
518            ))}
519          </div>
520          <p
521            style={{
522              fontSize: 11,
523              color: "var(--text-3)",
524              marginTop: 8,
525              fontFamily: "DM Mono, monospace",
526            }}
527          >
528            Leave blank to auto-assign, or enter your physical sensor labels.
529          </p>
530        </SectionCard>
531
532        {/* Auto: cycle duration + stage */}
533        <SectionCard>
534          <div className="section-label" style={{ marginBottom: 12 }}>
535            {t("add_auto_cycle_duration")}
536          </div>
537          <div style={{ display: "flex", gap: 12 }}>
538            <div
539              style={{
540                flex: 1,
541                padding: "12px 14px",
542                borderRadius: 10,
543                background: "rgba(74,222,128,0.06)",
544                border: "1px solid rgba(74,222,128,0.2)",
545              }}
546            >
547              <div
548                style={{
549                  display: "flex",
550                  alignItems: "center",
551                  gap: 8,
552                  marginBottom: 4,
553                }}
554              >
555                <Clock size={12} style={{ color: "var(--green)" }} />
556                <span
557                  style={{
558                    fontSize: 10,
559                    fontFamily: "DM Mono, monospace",
560                    color: "var(--text-3)",
561                  }}
562                >
563                  {t("add_auto_cycle_duration")}
564                </span>
565              </div>
566              <span
567                style={{
568                  fontSize: 20,
569                  fontWeight: 700,
570                  color: "var(--green)",
571                  fontFamily: "DM Mono, monospace",
572                }}
573              >
574                {cycleDuration}h
575              </span>
576            </div>
577            <div
578              style={{
579                flex: 1,
580                padding: "12px 14px",
581                borderRadius: 10,
582                background: "var(--bg-3)",
583                border: "1px solid var(--border)",
584              }}
585            >
586              <div
587                style={{
588                  display: "flex",
589                  alignItems: "center",
590                  gap: 8,
591                  marginBottom: 4,
592                }}
593              >
594                <Sprout size={12} style={{ color: "var(--text-3)" }} />
595                <span
596                  style={{
597                    fontSize: 10,
598                    fontFamily: "DM Mono, monospace",
599                    color: "var(--text-3)",
600                  }}
601                >
602                  Initial Stage
603                </span>
604              </div>
605              <span
606                style={{
607                  fontSize: 14,
608                  fontWeight: 700,
609                  color: "var(--text-2)",
610                  fontFamily: "DM Mono, monospace",
611                  textTransform: "capitalize",
612                }}
613              >
614                Seedling
615              </span>
616            </div>
617          </div>
618          <p
619            style={{
620              fontSize: 11,
621              color: "var(--text-3)",
622              marginTop: 8,
623              fontFamily: "DM Mono, monospace",
624            }}
625          >
626            {t("add_auto_cycle_hint", {
627              crop: form.crop,
628              hours: cycleDuration,
629            })}
630          </p>
631        </SectionCard>
632
633        {/* Location */}
634        <SectionCard>
635          <div className="section-label" style={{ marginBottom: 12 }}>
636            {t("add_field_location")}
637          </div>
638          <div
639            style={{
640              display: "flex",
641              alignItems: "center",
642              gap: 10,
643              padding: "10px 14px",
644              borderRadius: 10,
645              background: "var(--bg-3)",
646              border: "1px solid var(--border)",
647            }}
648          >
649            <MapPin
650              size={14}
651              style={{ color: "var(--text-3)", flexShrink: 0 }}
652            />
653            <input
654              type="text"
655              value={form.location}
656              onChange={(e) =>
657                setForm((f) => ({ ...f, location: e.target.value }))
658              }
659              placeholder={t("add_field_location_placeholder")}
660              style={{
661                flex: 1,
662                background: "none",
663                border: "none",
664                outline: "none",
665                color: "var(--text)",
666                fontSize: 13,
667                fontFamily: "DM Mono, monospace",
668              }}
669            />
670          </div>
671          <p
672            style={{
673              fontSize: 11,
674              color: "var(--text-3)",
675              marginTop: 6,
676              fontFamily: "DM Mono, monospace",
677            }}
678          >
679            {t("add_field_location_hint")}
680          </p>
681        </SectionCard>
682
683        {/* Notes */}
684        <SectionCard>
685          <div className="section-label" style={{ marginBottom: 12 }}>
686            {t("add_field_notes")}
687          </div>
688          <div
689            style={{
690              display: "flex",
691              gap: 10,
692              padding: "10px 14px",
693              borderRadius: 10,
694              background: "var(--bg-3)",
695              border: "1px solid var(--border)",
696            }}
697          >
698            <StickyNote
699              size={14}
700              style={{ color: "var(--text-3)", flexShrink: 0, marginTop: 2 }}
701            />
702            <textarea
703              rows={3}
704              value={form.notes}
705              onChange={(e) =>
706                setForm((f) => ({ ...f, notes: e.target.value }))
707              }
708              placeholder={t("add_field_notes_placeholder")}
709              style={{
710                flex: 1,
711                background: "none",
712                border: "none",
713                outline: "none",
714                color: "var(--text)",
715                fontSize: 13,
716                fontFamily: "DM Mono, monospace",
717                resize: "vertical",
718                lineHeight: 1.6,
719              }}
720            />
721          </div>
722        </SectionCard>
723
724        {/* Image (optional) */}
725        <SectionCard>
726          <div className="section-label" style={{ marginBottom: 12 }}>
727            {t("add_plant_image")}
728          </div>
729          {imagePreview ? (
730            <div
731              style={{
732                position: "relative",
733                borderRadius: 12,
734                overflow: "hidden",
735                height: 160,
736              }}
737            >
738              <img
739                src={imagePreview}
740                alt="crop preview"
741                style={{ width: "100%", height: "100%", objectFit: "cover" }}
742              />
743              <button
744                onClick={() => {
745                  setImageFile(null);
746                  setImagePreview(null);
747                }}
748                style={{
749                  position: "absolute",
750                  top: 8,
751                  right: 8,
752                  padding: "4px 10px",
753                  borderRadius: 8,
754                  background: "rgba(0,0,0,0.6)",
755                  border: "none",
756                  color: "#fff",
757                  fontSize: 11,
758                  cursor: "pointer",
759                }}
760              >
761                Remove
762              </button>
763            </div>
764          ) : (
765            <label
766              style={{
767                display: "flex",
768                flexDirection: "column",
769                alignItems: "center",
770                justifyContent: "center",
771                gap: 8,
772                padding: 24,
773                borderRadius: 12,
774                border: "2px dashed var(--border)",
775                cursor: "pointer",
776                background: "var(--bg-3)",
777              }}
778            >
779              <Upload size={22} style={{ color: "var(--text-3)" }} />
780              <span
781                style={{
782                  fontSize: 12,
783                  fontFamily: "DM Mono, monospace",
784                  color: "var(--text-3)",
785                }}
786              >
787                {t("add_drop_image")}
788              </span>
789              <span
790                style={{
791                  fontSize: 10,
792                  fontFamily: "DM Mono, monospace",
793                  color: "var(--text-3)",
794                  opacity: 0.6,
795                }}
796              >
797                {t("add_image_hint")}
798              </span>
799              <input
800                type="file"
801                accept="image/*"
802                style={{ display: "none" }}
803                onChange={handleImageChange}
804              />
805            </label>
806          )}
807        </SectionCard>
808
809        {/* Error */}
810        {error && !error.includes("ID") && (
811          <div
812            style={{
813              padding: "10px 14px",
814              borderRadius: 10,
815              background: "rgba(248,113,113,0.1)",
816              border: "1px solid rgba(248,113,113,0.3)",
817              fontSize: 12,
818              fontFamily: "DM Mono, monospace",
819              color: "var(--red)",
820            }}
821          >
822            {error}
823          </div>
824        )}
825
826        {/* Submit */}
827        <button
828          onClick={handleSubmit}
829          disabled={submitting}
830          style={{
831            padding: "15px 24px",
832            borderRadius: 12,
833            background: submitting ? "var(--border)" : "var(--green)",
834            border: "none",
835            color: submitting ? "var(--text-3)" : "var(--btn-on-green)",
836            fontWeight: 700,
837            fontSize: 14,
838            cursor: submitting ? "not-allowed" : "pointer",
839            display: "flex",
840            alignItems: "center",
841            justifyContent: "center",
842            gap: 8,
843            transition: "all 0.2s",
844            marginBottom: 24,
845          }}
846        >
847          <Leaf size={15} />
848          {submitting ? t("add_registering") : t("add_register_btn")}
849        </button>
850      </div>
851    </PageShell>
852  );
853}