demeter
Autonomous Hydroponic Intelligencegit clone https://github.com/maydayv7/demeter
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}