From e737e4c47d1ae2aa03260036043738fa8d7b6649 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Tue, 28 Apr 2026 02:09:04 +0330 Subject: [PATCH] UPDATE --- messages/fa.json | 3 + .../fertilizationRecommendationService.ts | 8 +- .../irrigationRecommendationService.ts | 8 +- .../api/services/selectedPlantsService.ts | 30 + .../SmartFertilizationRecommendation.tsx | 809 +++++++++--------- .../SmartIrrigationRecommendation.tsx | 277 +++--- 6 files changed, 615 insertions(+), 520 deletions(-) create mode 100644 src/libs/api/services/selectedPlantsService.ts diff --git a/messages/fa.json b/messages/fa.json index 27b3929..02cf21d 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -640,6 +640,9 @@ "plantSelection": { "title": "انتخاب محصول" }, + "growthStage": { + "title": "مرحله رشد" + }, "crops": { "wheat": "گندم", "corn": "ذرت", diff --git a/src/libs/api/services/fertilizationRecommendationService.ts b/src/libs/api/services/fertilizationRecommendationService.ts index 19d32b7..886d580 100644 --- a/src/libs/api/services/fertilizationRecommendationService.ts +++ b/src/libs/api/services/fertilizationRecommendationService.ts @@ -11,6 +11,7 @@ import type { import { normalizeRecommendationTaskStatus } from "./recommendationTask"; const PREFIX = "/api/fertilization-recommendation"; +const RECOMMEND_PREFIX = "/api/fertilization"; export interface FarmData { soilType: string; @@ -21,12 +22,15 @@ export interface FarmData { export interface GrowthStage { id: string; icon: string; + label?: string; } export interface CropOption { id: string; - labelKey: string; + labelKey?: string; + name?: string; icon: string; + growthStages?: string[]; } export interface FertilizationConfigResponse { @@ -107,7 +111,7 @@ export const fertilizationRecommendationService = { ): Promise { return unwrap( apiClient.post>( - `${PREFIX}/recommend/`, + `${RECOMMEND_PREFIX}/recommend/`, payload ?? {}, ), ).then((response) => diff --git a/src/libs/api/services/irrigationRecommendationService.ts b/src/libs/api/services/irrigationRecommendationService.ts index 6950390..1b50ab6 100644 --- a/src/libs/api/services/irrigationRecommendationService.ts +++ b/src/libs/api/services/irrigationRecommendationService.ts @@ -11,6 +11,7 @@ import type { import { normalizeRecommendationTaskStatus } from "./recommendationTask"; const PREFIX = "/api/irrigation-recommendation"; +const RECOMMEND_PREFIX = "/api/irrigation"; export interface FarmInfo { soilType: string; @@ -20,8 +21,10 @@ export interface FarmInfo { export interface CropOption { id: string; - labelKey: string; + labelKey?: string; + name?: string; icon: string; + growthStages?: string[]; } export interface IrrigationMethod { @@ -48,6 +51,7 @@ export interface IrrigationPlan { export interface IrrigationRecommendPayload { farm_uuid: string; crop_id?: string; + growth_stage?: string; irrigation_method_id?: string; farm_data?: Partial; soilType?: string; @@ -156,7 +160,7 @@ export const irrigationRecommendationService = { ): Promise { return unwrap( apiClient.post>( - `${PREFIX}/recommend/`, + `${RECOMMEND_PREFIX}/recommend/`, payload ?? {}, ), ).then((response) => diff --git a/src/libs/api/services/selectedPlantsService.ts b/src/libs/api/services/selectedPlantsService.ts new file mode 100644 index 0000000..387ea4a --- /dev/null +++ b/src/libs/api/services/selectedPlantsService.ts @@ -0,0 +1,30 @@ +import { apiClient } from "../client"; + +const PREFIX = "/api/plants"; + +export interface SelectedPlant { + name: string; + icon: string; + growth_stages: string[]; +} + +interface ApiResponse { + code: number; + msg: string; + data: T; +} + +async function unwrap(promise: Promise>): Promise { + const res = await promise; + return res.data; +} + +export const selectedPlantsService = { + getSelected(farmUuid: string): Promise { + return unwrap( + apiClient.get>( + `${PREFIX}/selected/?farm_uuid=${encodeURIComponent(farmUuid)}`, + ), + ); + }, +}; diff --git a/src/views/dashboards/farm/smartFertilization/SmartFertilizationRecommendation.tsx b/src/views/dashboards/farm/smartFertilization/SmartFertilizationRecommendation.tsx index 5c115e4..6ad6ed3 100644 --- a/src/views/dashboards/farm/smartFertilization/SmartFertilizationRecommendation.tsx +++ b/src/views/dashboards/farm/smartFertilization/SmartFertilizationRecommendation.tsx @@ -9,39 +9,54 @@ import Typography from "@mui/material/Typography"; import Button from "@mui/material/Button"; import Collapse from "@mui/material/Collapse"; import CircularProgress from "@mui/material/CircularProgress"; +import IconButton from "@mui/material/IconButton"; import { useTheme, alpha } from "@mui/material/styles"; import { useFarmHub } from "@/hooks/useFarmHub"; import type { - FarmData, GrowthStage, CropOption, FertilizationPlan, } from "@/libs/api/services/fertilizationRecommendationService"; import { fertilizationRecommendationService } from "@/libs/api/services/fertilizationRecommendationService"; import { isRecommendationTaskRunning } from "@/libs/api/services/recommendationTask"; +import { selectedPlantsService } from "@/libs/api/services/selectedPlantsService"; -const DEFAULT_FARM_DATA: FarmData = { - soilType: "Loamy", - organicMatter: "Medium (2.5%)", - waterEC: "1.2 dS/m", +const GROWTH_STAGE_LABELS: Record = { + initial: "شروع رشد", + vegetative: "رشد رویشی", + flowering: "گلدهی", + fruiting: "باردهی", + maturity: "رسیدگی", }; -const DEFAULT_GROWTH_STAGES: GrowthStage[] = [ - { id: "prePlanting", icon: "tabler-seedling" }, - { id: "earlyGrowth", icon: "tabler-leaf" }, - { id: "flowering", icon: "tabler-flower" }, - { id: "fruiting", icon: "tabler-apple" }, - { id: "postHarvest", icon: "tabler-basket" }, -]; +const PLANT_ICON_MAP: Record = { + corn: "tabler-plant-2", + wheat: "tabler-wheat", + cotton: "tabler-flower", + saffron: "tabler-flower-2", + canola: "tabler-leaf", + vegetables: "tabler-carrot", +}; -const DEFAULT_CROP_OPTIONS: CropOption[] = [ - { id: "wheat", labelKey: "wheat", icon: "tabler-wheat" }, - { id: "corn", labelKey: "corn", icon: "tabler-plant-2" }, - { id: "cotton", labelKey: "cotton", icon: "tabler-flower" }, - { id: "saffron", labelKey: "saffron", icon: "tabler-flower-2" }, - { id: "canola", labelKey: "canola", icon: "tabler-leaf" }, - { id: "vegetables", labelKey: "vegetables", icon: "tabler-carrot" }, -]; +const GROWTH_STAGE_ICON_MAP: Record = { + initial: "tabler-seedling", + vegetative: "tabler-leaf", + flowering: "tabler-flower", + fruiting: "tabler-apple", + maturity: "tabler-basket", +}; + +const formatStageLabel = (stage: string) => + GROWTH_STAGE_LABELS[stage] ?? + stage + .split(/[_-]/) + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + +const getPlantIcon = (icon: string) => PLANT_ICON_MAP[icon] ?? "tabler-leaf"; +const getGrowthStageIcon = (stage: string) => + GROWTH_STAGE_ICON_MAP[stage] ?? "tabler-circle-dot"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const getErrorMessage = (error: unknown, fallback: string) => @@ -61,17 +76,11 @@ export default function SmartFertilizationRecommendation() { const primaryLight = theme.palette.primary.light; const primaryDark = theme.palette.primary.dark; const paperBg = theme.palette.background.paper; - const [farmData, setFarmData] = useState(DEFAULT_FARM_DATA); - const [growthStages, setGrowthStages] = useState( - DEFAULT_GROWTH_STAGES, - ); - const [cropOptions, setCropOptions] = - useState(DEFAULT_CROP_OPTIONS); + const [growthStages, setGrowthStages] = useState([]); + const [cropOptions, setCropOptions] = useState([]); const [configLoading, setConfigLoading] = useState(true); const [configError, setConfigError] = useState(null); - const [growthStage, setGrowthStage] = useState( - DEFAULT_GROWTH_STAGES[0].id, - ); + const [growthStage, setGrowthStage] = useState(""); const [selectedCrop, setSelectedCrop] = useState(null); const [plan, setPlan] = useState(null); const [loading, setLoading] = useState(false); @@ -82,6 +91,9 @@ export default function SmartFertilizationRecommendation() { useEffect(() => { setPlan(null); setRequestError(null); + setSelectedCrop(null); + setGrowthStages([]); + setGrowthStage(""); if (!farmUuid) { setConfigError(t("errors.noFarm")); @@ -91,18 +103,35 @@ export default function SmartFertilizationRecommendation() { setConfigLoading(true); setConfigError(null); - fertilizationRecommendationService - .getConfig(farmUuid) - .then(({ farmData: farm, growthStages: stages, cropOptions: crops }) => { - if (farm) setFarmData(farm); - if (stages?.length) { + selectedPlantsService + .getSelected(farmUuid) + .then((plants) => { + const crops = plants.map((plant) => ({ + id: plant.name, + name: plant.name, + icon: getPlantIcon(plant.icon), + growthStages: plant.growth_stages, + })); + + setCropOptions(crops); + + const firstCrop = crops[0]; + if (firstCrop) { + setSelectedCrop(firstCrop.id); + const stages = + firstCrop.growthStages?.map((stage) => ({ + id: stage, + icon: getGrowthStageIcon(stage), + label: formatStageLabel(stage), + })) ?? + []; + setGrowthStages(stages); - setGrowthStage(stages[0].id); + setGrowthStage(stages[0]?.id ?? ""); } - if (crops?.length) setCropOptions(crops); }) .catch((err: { message?: string }) => { - setConfigError(err?.message ?? "Failed to load config"); + setConfigError(err?.message ?? "Failed to load plants"); }) .finally(() => setConfigLoading(false)); }, [farmUuid, t]); @@ -120,14 +149,6 @@ export default function SmartFertilizationRecommendation() { farm_uuid: farmUuid, crop_id: selectedCrop, growth_stage: growthStage, - farm_data: { - soilType: farmData.soilType, - organicMatter: farmData.organicMatter, - waterEC: farmData.waterEC, - }, - soilType: farmData.soilType, - organicMatter: farmData.organicMatter, - waterEC: farmData.waterEC, }, ); @@ -179,6 +200,37 @@ export default function SmartFertilizationRecommendation() { }; const stageIndex = growthStages.findIndex((s) => s.id === growthStage); + const selectedCropOption = + cropOptions.find((option) => option.id === selectedCrop) ?? null; + const selectedGrowthStage = + growthStages.find((stage) => stage.id === growthStage) ?? null; + const resultContext = `${selectedCropOption?.name ?? selectedCrop ?? ""} | ${ + selectedGrowthStage?.label ?? formatStageLabel(growthStage) + }`; + + const handleCropSelect = (crop: CropOption) => { + setSelectedCrop((prev) => { + const nextCrop = prev === crop.id ? null : crop.id; + const nextStages = + nextCrop && crop.growthStages?.length + ? crop.growthStages.map((stage) => ({ + id: stage, + icon: getGrowthStageIcon(stage), + label: formatStageLabel(stage), + })) + : []; + + setGrowthStages(nextStages); + setGrowthStage(nextStages[0]?.id ?? ""); + + return nextCrop; + }); + }; + + const handleBackToForm = () => { + setPlan(null); + setReasoningExpanded(false); + }; return ( - - {/* 1) Header */} - - - {t("title")} - - - {t("subtitle")} - - - - {/* 2) Farm Data Card */} - - - - - {t("farmData.title")} - - - `linear-gradient(135deg, ${th.palette.success.main} 0%, ${th.palette.success.dark} 100%)`, - color: "white", - boxShadow: (th) => - `0 2px 8px ${alpha(th.palette.success.main, 0.3)}`, - }} - > - - {t("verifiedBadge")} + + {plan ? ( + + + + + + + + {resultContext} + - - - - - - - - {/* 3) Growth Stage Selector */} - - {t("growthStage.title")} - - - {growthStages.map((stage, idx) => { - const isSelected = growthStage === stage.id; - const isPast = idx < stageIndex; - return ( - setGrowthStage(stage.id)} - className="flex flex-col items-center gap-1.5 px-4 py-3 rounded-2xl shrink-0 transition-all duration-300 ease-out border-2 cursor-pointer min-w-[72px]" + + + + + + + {t("result.title")} + + + + + + + + + + + + setReasoningExpanded(!reasoningExpanded)} + className="w-full flex items-center justify-between px-4 py-3 text-start cursor-pointer" + sx={{ "&:hover": { bgcolor: alpha(primaryMain, 0.06) } }} + > + + + + {t("result.whyRecommendation")} + + + + + + + + {plan.reasoning} + + + + + + + + + + + + + + + + ) : ( + <> + + + {t("title")} + + + {t("subtitle")} + + + + {!!growthStages.length && ( + <> + + {t("growthStage.title")} + + + {growthStages.map((stage, idx) => { + const isSelected = growthStage === stage.id; + const isPast = idx < stageIndex; + return ( + setGrowthStage(stage.id)} + className="flex flex-col items-center gap-1.5 px-4 py-3 rounded-2xl shrink-0 transition-all duration-300 ease-out border-2 cursor-pointer min-w-[72px]" + sx={{ + borderColor: isSelected ? primaryMain : "transparent", + background: isSelected + ? `linear-gradient(145deg, ${alpha(primaryMain, 0.15)} 0%, ${alpha(primaryMain, 0.06)} 100%)` + : `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`, + boxShadow: isSelected + ? `0 4px 20px ${alpha(primaryMain, 0.2)}, inset 0 1px 0 rgba(255,255,255,0.8)` + : "0 2px 8px rgba(0,0,0,0.04)", + "&:hover": { + transform: "translateY(-2px)", + boxShadow: isSelected + ? `0 6px 24px ${alpha(primaryMain, 0.25)}` + : `0 4px 16px ${alpha(primaryMain, 0.1)}`, + }, + }} + > + + + + + {stage.label ?? formatStageLabel(stage.id)} + + + ); + })} + + + )} + + + {t("plantSelection.title")} + + {configLoading ? ( + + + + ) : configError ? ( + + {configError} + + ) : ( + + {cropOptions.map((crop) => ( + handleCropSelect(crop)} + /> + ))} + + )} + + + + - {/* 4) Plant Selection */} - - {t("plantSelection.title")} - - {configLoading ? ( - - - - ) : configError ? ( - - {configError} - - ) : ( - - {cropOptions.map((crop) => ( - - setSelectedCrop((prev) => (prev === crop.id ? null : crop.id)) - } - /> - ))} - + {requestError && !loading && ( + + {requestError} + + )} + )} - {/* 5) Primary CTA Button - End of form */} - - - - - {requestError && !loading && ( - - {requestError} - - )} - - {/* 6) Result Section - Prescription style */} - {plan && ( - - - - - - - {t("result.title")} - - - - - - - - - - - {/* Expandable "Why this recommendation?" */} - - setReasoningExpanded(!reasoningExpanded)} - className="w-full flex items-center justify-between px-4 py-3 text-start cursor-pointer" - sx={{ "&:hover": { bgcolor: alpha(primaryMain, 0.06) } }} - > - - - - {t("result.whyRecommendation")} - - - - - - - - {plan.reasoning} - - - - - - - - )} - - {/* Loading state */} {loading && ( - - - - {label} - - - {value} - - - - ); -} - function CropCard({ crop, label, diff --git a/src/views/dashboards/farm/smartIrrigation/SmartIrrigationRecommendation.tsx b/src/views/dashboards/farm/smartIrrigation/SmartIrrigationRecommendation.tsx index 8178640..0f00fc6 100644 --- a/src/views/dashboards/farm/smartIrrigation/SmartIrrigationRecommendation.tsx +++ b/src/views/dashboards/farm/smartIrrigation/SmartIrrigationRecommendation.tsx @@ -7,25 +7,55 @@ import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; import Typography from "@mui/material/Typography"; import Button from "@mui/material/Button"; -import IconButton from "@mui/material/IconButton"; import CircularProgress from "@mui/material/CircularProgress"; import { useTheme, alpha } from "@mui/material/styles"; import { useFarmHub } from "@/hooks/useFarmHub"; import type { - FarmInfo, CropOption, IrrigationPlan, WaterBalance, } from "@/libs/api/services/irrigationRecommendationService"; import { irrigationRecommendationService } from "@/libs/api/services/irrigationRecommendationService"; import { isRecommendationTaskRunning } from "@/libs/api/services/recommendationTask"; +import { selectedPlantsService } from "@/libs/api/services/selectedPlantsService"; -const DEFAULT_FARM_INFO: FarmInfo = { - soilType: "Loamy", - waterQuality: "Medium EC", - climateZone: "Temperate", +const GROWTH_STAGE_LABELS: Record = { + initial: "شروع رشد", + vegetative: "رشد رویشی", + flowering: "گلدهی", + fruiting: "باردهی", + maturity: "رسیدگی", }; +const PLANT_ICON_MAP: Record = { + corn: "tabler-plant-2", + wheat: "tabler-wheat", + cotton: "tabler-flower", + saffron: "tabler-flower-2", + canola: "tabler-leaf", + vegetables: "tabler-carrot", +}; + +const GROWTH_STAGE_ICON_MAP: Record = { + initial: "tabler-seedling", + vegetative: "tabler-leaf", + flowering: "tabler-flower", + fruiting: "tabler-apple", + maturity: "tabler-basket", +}; + +const formatStageLabel = (stage: string) => + GROWTH_STAGE_LABELS[stage] ?? + stage + .split(/[_-]/) + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + +const getPlantIcon = (icon: string) => PLANT_ICON_MAP[icon] ?? "tabler-leaf"; +const getGrowthStageIcon = (stage: string) => + GROWTH_STAGE_ICON_MAP[stage] ?? "tabler-circle-dot"; + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const getErrorMessage = (error: unknown, fallback: string) => typeof error === "object" && @@ -40,11 +70,14 @@ export default function SmartIrrigationRecommendation() { const theme = useTheme(); const { farmHub } = useFarmHub(); const farmUuid = farmHub?.farm_uuid; - const [farmInfo, setFarmInfo] = useState(DEFAULT_FARM_INFO); const [cropOptions, setCropOptions] = useState([]); const [configLoading, setConfigLoading] = useState(true); const [configError, setConfigError] = useState(null); const [selectedCrop, setSelectedCrop] = useState(null); + const [growthStages, setGrowthStages] = useState([]); + const [selectedGrowthStage, setSelectedGrowthStage] = useState( + null, + ); const [plan, setPlan] = useState(null); const [waterBalance, setWaterBalance] = useState(null); const [loading, setLoading] = useState(false); @@ -59,6 +92,9 @@ export default function SmartIrrigationRecommendation() { setPlan(null); setWaterBalance(null); setRequestError(null); + setSelectedCrop(null); + setGrowthStages([]); + setSelectedGrowthStage(null); if (!farmUuid) { setConfigError(t("errors.noFarm")); @@ -68,14 +104,27 @@ export default function SmartIrrigationRecommendation() { setConfigLoading(true); setConfigError(null); - irrigationRecommendationService - .getConfig(farmUuid) - .then(({ farmInfo: info, cropOptions: crops }) => { - setFarmInfo(info); - setCropOptions(crops.length > 0 ? crops : []); + selectedPlantsService + .getSelected(farmUuid) + .then((plants) => { + const crops = plants.map((plant) => ({ + id: plant.name, + name: plant.name, + icon: getPlantIcon(plant.icon), + growthStages: plant.growth_stages, + })); + + setCropOptions(crops); + + const firstPlant = crops[0]; + if (firstPlant) { + setSelectedCrop(firstPlant.id); + setGrowthStages(firstPlant.growthStages ?? []); + setSelectedGrowthStage(firstPlant.growthStages?.[0] ?? null); + } }) .catch((err: { message?: string }) => { - setConfigError(err?.message ?? "Failed to load config"); + setConfigError(err?.message ?? "Failed to load plants"); }) .finally(() => setConfigLoading(false)); }, [farmUuid, t]); @@ -91,14 +140,7 @@ export default function SmartIrrigationRecommendation() { const recommendation = await irrigationRecommendationService.recommend({ farm_uuid: farmUuid, crop_id: selectedCrop, - farm_data: { - soilType: farmInfo.soilType, - waterQuality: farmInfo.waterQuality, - climateZone: farmInfo.climateZone, - }, - soilType: farmInfo.soilType, - waterQuality: farmInfo.waterQuality, - climateZone: farmInfo.climateZone, + growth_stage: selectedGrowthStage ?? undefined, }); if ("task_id" in recommendation) { @@ -157,6 +199,19 @@ export default function SmartIrrigationRecommendation() { const hasNumericMoistureLevel = Number.isFinite(moistureLevelValue); const nextWaterBalanceDay = waterBalance?.daily?.[0]; + const handleCropSelect = (crop: CropOption) => { + setSelectedCrop((prev) => { + const nextCrop = prev === crop.id ? null : crop.id; + const nextStages = + cropOptions.find((option) => option.id === nextCrop)?.growthStages ?? []; + + setGrowthStages(nextCrop ? nextStages : []); + setSelectedGrowthStage(nextCrop ? nextStages[0] ?? null : null); + + return nextCrop; + }); + }; + return ( - {/* 2) Farm Info Card */} - - - - - {t("farmInfo.title")} - - - - `linear-gradient(135deg, ${t.palette.success.main} 0%, ${t.palette.success.dark} 100%)`, - color: "white", - display: "flex", - alignItems: "center", - gap: 4, - }} - > - - {t("verifiedBadge")} - - - - - + {/* 2) Growth Stage Selector */} + {!!growthStages.length && ( + <> + + {t("growthStage.title")} + + + {growthStages.map((stage) => { + const isSelected = selectedGrowthStage === stage; + + return ( + setSelectedGrowthStage(stage)} + className="flex flex-col items-center gap-1.5 px-4 py-3 rounded-2xl shrink-0 transition-all duration-300 ease-out border-2 cursor-pointer min-w-[84px]" + sx={{ + borderColor: isSelected ? primaryMain : "transparent", + background: isSelected + ? `linear-gradient(145deg, ${alpha(primaryMain, 0.15)} 0%, ${alpha(primaryMain, 0.06)} 100%)` + : `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`, + boxShadow: isSelected + ? `0 4px 20px ${alpha(primaryMain, 0.2)}, inset 0 1px 0 rgba(255,255,255,0.8)` + : "0 2px 8px rgba(0,0,0,0.04)", + "&:hover": { + transform: "translateY(-2px)", + boxShadow: isSelected + ? `0 6px 24px ${alpha(primaryMain, 0.25)}` + : `0 4px 16px ${alpha(primaryMain, 0.1)}`, + }, + }} + > + + + + + {formatStageLabel(stage)} + + + ); + })} - - - - - - - + + )} {/* 3) Plant Selection Section */} - setSelectedCrop((prev) => (prev === crop.id ? null : crop.id)) - } + onClick={() => handleCropSelect(crop)} /> ))} @@ -289,7 +347,12 @@ export default function SmartIrrigationRecommendation() {