UPDATE
This commit is contained in:
@@ -640,6 +640,9 @@
|
||||
"plantSelection": {
|
||||
"title": "انتخاب محصول"
|
||||
},
|
||||
"growthStage": {
|
||||
"title": "مرحله رشد"
|
||||
},
|
||||
"crops": {
|
||||
"wheat": "گندم",
|
||||
"corn": "ذرت",
|
||||
|
||||
@@ -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<FertilizationRecommendResponse> {
|
||||
return unwrap(
|
||||
apiClient.post<ApiResponse<FertilizationRecommendResponse>>(
|
||||
`${PREFIX}/recommend/`,
|
||||
`${RECOMMEND_PREFIX}/recommend/`,
|
||||
payload ?? {},
|
||||
),
|
||||
).then((response) =>
|
||||
|
||||
@@ -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<FarmInfo>;
|
||||
soilType?: string;
|
||||
@@ -156,7 +160,7 @@ export const irrigationRecommendationService = {
|
||||
): Promise<IrrigationRecommendResponse> {
|
||||
return unwrap(
|
||||
apiClient.post<ApiResponse<IrrigationRecommendResponse>>(
|
||||
`${PREFIX}/recommend/`,
|
||||
`${RECOMMEND_PREFIX}/recommend/`,
|
||||
payload ?? {},
|
||||
),
|
||||
).then((response) =>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { apiClient } from "../client";
|
||||
|
||||
const PREFIX = "/api/plants";
|
||||
|
||||
export interface SelectedPlant {
|
||||
name: string;
|
||||
icon: string;
|
||||
growth_stages: string[];
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
async function unwrap<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
|
||||
const res = await promise;
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export const selectedPlantsService = {
|
||||
getSelected(farmUuid: string): Promise<SelectedPlant[]> {
|
||||
return unwrap(
|
||||
apiClient.get<ApiResponse<SelectedPlant[]>>(
|
||||
`${PREFIX}/selected/?farm_uuid=${encodeURIComponent(farmUuid)}`,
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
+282
-253
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<FarmData>(DEFAULT_FARM_DATA);
|
||||
const [growthStages, setGrowthStages] = useState<GrowthStage[]>(
|
||||
DEFAULT_GROWTH_STAGES,
|
||||
);
|
||||
const [cropOptions, setCropOptions] =
|
||||
useState<CropOption[]>(DEFAULT_CROP_OPTIONS);
|
||||
const [growthStages, setGrowthStages] = useState<GrowthStage[]>([]);
|
||||
const [cropOptions, setCropOptions] = useState<CropOption[]>([]);
|
||||
const [configLoading, setConfigLoading] = useState(true);
|
||||
const [configError, setConfigError] = useState<string | null>(null);
|
||||
const [growthStage, setGrowthStage] = useState<string>(
|
||||
DEFAULT_GROWTH_STAGES[0].id,
|
||||
);
|
||||
const [growthStage, setGrowthStage] = useState<string>("");
|
||||
const [selectedCrop, setSelectedCrop] = useState<string | null>(null);
|
||||
const [plan, setPlan] = useState<FertilizationPlan | null>(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 (
|
||||
<Box
|
||||
@@ -189,8 +241,182 @@ export default function SmartFertilizationRecommendation() {
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<Box className="max-w-lg mx-auto px-4 py-6 sm:py-8">
|
||||
{/* 1) Header */}
|
||||
<Box
|
||||
className={`max-w-lg mx-auto px-4 ${plan ? "py-0 sm:py-0" : "py-6 sm:py-8"}`}
|
||||
>
|
||||
{plan ? (
|
||||
<Box className="animate-fade-in">
|
||||
<Box
|
||||
position="sticky"
|
||||
top={0}
|
||||
zIndex={50}
|
||||
bgcolor="background.paper"
|
||||
className="-mx-4 mb-6 px-4 py-3"
|
||||
sx={{
|
||||
borderBottom: `1px solid ${alpha(primaryMain, 0.12)}`,
|
||||
boxShadow: `0 6px 20px ${alpha(theme.palette.common.black, 0.04)}`,
|
||||
}}
|
||||
>
|
||||
<Box className="flex items-center gap-2">
|
||||
<IconButton
|
||||
onClick={handleBackToForm}
|
||||
sx={{
|
||||
border: `1px solid ${alpha(primaryMain, 0.14)}`,
|
||||
bgcolor: alpha(primaryMain, 0.04),
|
||||
borderRadius: "16px",
|
||||
}}
|
||||
>
|
||||
<i
|
||||
className="tabler-arrow-right text-xl"
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
</IconButton>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
{resultContext}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className="pb-28">
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: "28px",
|
||||
background: `linear-gradient(160deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 40%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||
boxShadow: `0 8px 32px ${alpha(primaryMain, 0.12)}, 0 4px 16px ${alpha(primaryMain, 0.06)}, 0 2px 8px rgba(0,0,0,0.04)`,
|
||||
border: `1px solid ${alpha(primaryMain, 0.15)}`,
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<Box className="flex items-center gap-2 mbe-5">
|
||||
<i
|
||||
className="tabler-prescription text-2xl"
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
<Typography variant="h6" fontWeight={700} color="text.primary">
|
||||
{t("result.title")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box className="space-y-3">
|
||||
<PrescriptionRow
|
||||
icon="tabler-atom-2"
|
||||
label={t("result.fertilizerType")}
|
||||
value={plan.npkRatio}
|
||||
/>
|
||||
<PrescriptionRow
|
||||
icon="tabler-scale"
|
||||
label={t("result.amountPerHectare")}
|
||||
value={plan.amountPerHectare}
|
||||
/>
|
||||
<PrescriptionRow
|
||||
icon="tabler-spray"
|
||||
label={t("result.applicationMethod")}
|
||||
value={plan.applicationMethod}
|
||||
/>
|
||||
<PrescriptionRow
|
||||
icon="tabler-calendar-repeat"
|
||||
label={t("result.applicationInterval")}
|
||||
value={plan.applicationInterval}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
className="mt-5 rounded-2xl overflow-hidden transition-all duration-300"
|
||||
sx={{
|
||||
border: `1px solid ${alpha(primaryMain, 0.15)}`,
|
||||
background: alpha(primaryMain, 0.04),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => 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) } }}
|
||||
>
|
||||
<Box className="flex items-center gap-2">
|
||||
<i
|
||||
className="tabler-brain text-lg"
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={600}
|
||||
color="text.primary"
|
||||
>
|
||||
{t("result.whyRecommendation")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<i
|
||||
className={`tabler-chevron-down text-xl transition-transform duration-300 ${
|
||||
reasoningExpanded ? "rotate-180" : ""
|
||||
}`}
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
</Box>
|
||||
<Collapse in={reasoningExpanded}>
|
||||
<Box className="px-4 pb-4">
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ lineHeight: 1.7 }}
|
||||
>
|
||||
{plan.reasoning}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
position="fixed"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
zIndex={50}
|
||||
bgcolor="background.paper"
|
||||
sx={{
|
||||
boxShadow: 3,
|
||||
borderTop: `1px solid ${alpha(primaryMain, 0.12)}`,
|
||||
}}
|
||||
>
|
||||
<Box className="mx-auto flex max-w-lg items-center gap-3 px-4 py-3">
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
className="rounded-2xl py-3 font-semibold"
|
||||
sx={{
|
||||
flex: 1,
|
||||
backgroundColor: primaryMain,
|
||||
"&:hover": { backgroundColor: primaryDark },
|
||||
}}
|
||||
>
|
||||
ذخیره این نسخه
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
className="shrink-0 rounded-2xl px-4 py-3"
|
||||
startIcon={<i className="tabler-bell text-lg" />}
|
||||
sx={{
|
||||
color: primaryMain,
|
||||
borderColor: alpha(primaryMain, 0.3),
|
||||
"&:hover": {
|
||||
borderColor: primaryMain,
|
||||
backgroundColor: alpha(primaryMain, 0.04),
|
||||
},
|
||||
}}
|
||||
>
|
||||
تنظیم یادآور
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box className="mb-8">
|
||||
<Typography
|
||||
variant="h4"
|
||||
@@ -214,61 +440,8 @@ export default function SmartFertilizationRecommendation() {
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 2) Farm Data Card */}
|
||||
<Card
|
||||
elevation={0}
|
||||
className="mb-6 overflow-hidden transition-all duration-300 hover:shadow-lg animate-fade-in"
|
||||
sx={{
|
||||
borderRadius: "28px",
|
||||
background: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 50%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||
boxShadow: `0 4px 24px ${alpha(primaryMain, 0.08)}, 0 4px 12px ${alpha(primaryMain, 0.04)}, 0 1px 3px rgba(0,0,0,0.04)`,
|
||||
border: `1px solid ${alpha(primaryMain, 0.12)}`,
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
<Box className="flex items-center justify-between mbe-4">
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={600}
|
||||
color="text.secondary"
|
||||
>
|
||||
{t("farmData.title")}
|
||||
</Typography>
|
||||
<Box
|
||||
className="px-2.5 py-1 rounded-full text-xs font-medium flex items-center gap-1.5"
|
||||
sx={{
|
||||
background: (th) =>
|
||||
`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)}`,
|
||||
}}
|
||||
>
|
||||
<i className="tabler-circle-check text-sm" />
|
||||
{t("verifiedBadge")}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box className="flex flex-wrap gap-3">
|
||||
<FarmBadge
|
||||
icon="tabler-seedling"
|
||||
label={t("farmData.soilType")}
|
||||
value={farmData.soilType}
|
||||
/>
|
||||
<FarmBadge
|
||||
icon="tabler-atom-2"
|
||||
label={t("farmData.organicMatter")}
|
||||
value={farmData.organicMatter}
|
||||
/>
|
||||
<FarmBadge
|
||||
icon="tabler-droplet"
|
||||
label={t("farmData.waterEC")}
|
||||
value={farmData.waterEC}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3) Growth Stage Selector */}
|
||||
{!!growthStages.length && (
|
||||
<>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={600}
|
||||
@@ -328,14 +501,15 @@ export default function SmartFertilizationRecommendation() {
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{t(`growthStage.${stage.id}`)}
|
||||
{stage.label ?? formatStageLabel(stage.id)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 4) Plant Selection */}
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={600}
|
||||
@@ -358,22 +532,19 @@ export default function SmartFertilizationRecommendation() {
|
||||
<CropCard
|
||||
key={crop.id}
|
||||
crop={crop}
|
||||
label={t(`crops.${crop.labelKey}`)}
|
||||
label={crop.name ?? (crop.labelKey ? t(`crops.${crop.labelKey}`) : crop.id)}
|
||||
selected={selectedCrop === crop.id}
|
||||
onClick={() =>
|
||||
setSelectedCrop((prev) => (prev === crop.id ? null : crop.id))
|
||||
}
|
||||
onClick={() => handleCropSelect(crop)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 5) Primary CTA Button - End of form */}
|
||||
<Box className="mb-8">
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
disabled={!selectedCrop || loading || configLoading}
|
||||
disabled={!selectedCrop || !growthStage || loading || configLoading}
|
||||
onClick={handleGenerate}
|
||||
startIcon={<i className="tabler-sparkles text-xl" />}
|
||||
className="rounded-2xl py-3.5 text-base font-semibold shadow-lg transition-all duration-300 hover:shadow-xl hover:scale-[1.01] active:scale-[0.99]"
|
||||
@@ -400,111 +571,9 @@ export default function SmartFertilizationRecommendation() {
|
||||
{requestError}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 6) Result Section - Prescription style */}
|
||||
{plan && (
|
||||
<Box className="mb-6 animate-fade-in">
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: "28px",
|
||||
background: `linear-gradient(160deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 40%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||
boxShadow: `0 8px 32px ${alpha(primaryMain, 0.12)}, 0 4px 16px ${alpha(primaryMain, 0.06)}, 0 2px 8px rgba(0,0,0,0.04)`,
|
||||
border: `1px solid ${alpha(primaryMain, 0.15)}`,
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<Box className="flex items-center gap-2 mbe-5">
|
||||
<i
|
||||
className="tabler-prescription text-2xl"
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={700}
|
||||
color="text.primary"
|
||||
>
|
||||
{t("result.title")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box className="space-y-3">
|
||||
<PrescriptionRow
|
||||
icon="tabler-atom-2"
|
||||
label={t("result.fertilizerType")}
|
||||
value={plan.npkRatio}
|
||||
/>
|
||||
<PrescriptionRow
|
||||
icon="tabler-scale"
|
||||
label={t("result.amountPerHectare")}
|
||||
value={plan.amountPerHectare}
|
||||
/>
|
||||
<PrescriptionRow
|
||||
icon="tabler-spray"
|
||||
label={t("result.applicationMethod")}
|
||||
value={plan.applicationMethod}
|
||||
/>
|
||||
<PrescriptionRow
|
||||
icon="tabler-calendar-repeat"
|
||||
label={t("result.applicationInterval")}
|
||||
value={plan.applicationInterval}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Expandable "Why this recommendation?" */}
|
||||
<Box
|
||||
className="mt-5 rounded-2xl overflow-hidden transition-all duration-300"
|
||||
sx={{
|
||||
border: `1px solid ${alpha(primaryMain, 0.15)}`,
|
||||
background: alpha(primaryMain, 0.04),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => 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) } }}
|
||||
>
|
||||
<Box className="flex items-center gap-2">
|
||||
<i
|
||||
className="tabler-brain text-lg"
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={600}
|
||||
color="text.primary"
|
||||
>
|
||||
{t("result.whyRecommendation")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<i
|
||||
className={`tabler-chevron-down text-xl transition-transform duration-300 ${
|
||||
reasoningExpanded ? "rotate-180" : ""
|
||||
}`}
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
</Box>
|
||||
<Collapse in={reasoningExpanded}>
|
||||
<Box className="px-4 pb-4">
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ lineHeight: 1.7 }}
|
||||
>
|
||||
{plan.reasoning}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<Card
|
||||
elevation={0}
|
||||
@@ -539,46 +608,6 @@ export default function SmartFertilizationRecommendation() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
function FarmBadge({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const primaryMain = theme.palette.primary.main;
|
||||
return (
|
||||
<Box
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-2xl transition-all duration-200 hover:scale-[1.02] hover:shadow-md"
|
||||
sx={{
|
||||
background: `linear-gradient(145deg, ${alpha(primaryMain, 0.1)} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||
border: `1px solid ${alpha(primaryMain, 0.15)}`,
|
||||
boxShadow: "inset 0 1px 2px rgba(255,255,255,0.5)",
|
||||
}}
|
||||
>
|
||||
<i className={`${icon} text-xl`} style={{ color: primaryMain }} />
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
display="block"
|
||||
lineHeight={1.2}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={600} color="text.primary">
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CropCard({
|
||||
crop,
|
||||
label,
|
||||
|
||||
@@ -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<string, string> = {
|
||||
initial: "شروع رشد",
|
||||
vegetative: "رشد رویشی",
|
||||
flowering: "گلدهی",
|
||||
fruiting: "باردهی",
|
||||
maturity: "رسیدگی",
|
||||
};
|
||||
|
||||
const PLANT_ICON_MAP: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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<FarmInfo>(DEFAULT_FARM_INFO);
|
||||
const [cropOptions, setCropOptions] = useState<CropOption[]>([]);
|
||||
const [configLoading, setConfigLoading] = useState(true);
|
||||
const [configError, setConfigError] = useState<string | null>(null);
|
||||
const [selectedCrop, setSelectedCrop] = useState<string | null>(null);
|
||||
const [growthStages, setGrowthStages] = useState<string[]>([]);
|
||||
const [selectedGrowthStage, setSelectedGrowthStage] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [plan, setPlan] = useState<IrrigationPlan | null>(null);
|
||||
const [waterBalance, setWaterBalance] = useState<WaterBalance | null>(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 (
|
||||
<Box
|
||||
className="min-bs-screen"
|
||||
@@ -187,69 +242,74 @@ export default function SmartIrrigationRecommendation() {
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 2) Farm Info Card */}
|
||||
<Card
|
||||
elevation={0}
|
||||
className="mb-6 overflow-hidden transition-all duration-300 hover:shadow-lg"
|
||||
sx={{
|
||||
borderRadius: "24px",
|
||||
background: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||
boxShadow: `0 4px 24px ${alpha(primaryMain, 0.08)}, 0 1px 3px rgba(0,0,0,0.04)`,
|
||||
border: `1px solid ${alpha(primaryMain, 0.12)}`,
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
<Box className="flex items-center justify-between mbe-4">
|
||||
{/* 2) Growth Stage Selector */}
|
||||
{!!growthStages.length && (
|
||||
<>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={600}
|
||||
color="text.secondary"
|
||||
className="mbe-3"
|
||||
>
|
||||
{t("farmInfo.title")}
|
||||
{t("growthStage.title")}
|
||||
</Typography>
|
||||
<Box className="flex items-center gap-2">
|
||||
<Box className="flex gap-2 mb-6 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-hide">
|
||||
{growthStages.map((stage) => {
|
||||
const isSelected = selectedGrowthStage === stage;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="px-2.5 py-1 rounded-full text-xs font-medium"
|
||||
key={stage}
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => 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={{
|
||||
background: (t) =>
|
||||
`linear-gradient(135deg, ${t.palette.success.main} 0%, ${t.palette.success.dark} 100%)`,
|
||||
color: "white",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
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)}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<i className="tabler-circle-check text-sm" />
|
||||
{t("verifiedBadge")}
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{ color: "text.secondary" }}
|
||||
aria-label={t("editFarmInfo")}
|
||||
<Box
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300"
|
||||
sx={{
|
||||
background: isSelected
|
||||
? `linear-gradient(135deg, ${primaryMain} 0%, ${primaryDark} 100%)`
|
||||
: alpha(primaryMain, 0.08),
|
||||
}}
|
||||
>
|
||||
<i className="tabler-pencil text-lg" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box className="flex flex-wrap gap-3">
|
||||
<FarmBadge
|
||||
icon="tabler-seedling"
|
||||
label={t("farmInfo.soilType")}
|
||||
value={farmInfo.soilType}
|
||||
/>
|
||||
<FarmBadge
|
||||
icon="tabler-droplet"
|
||||
label={t("farmInfo.waterQuality")}
|
||||
value={farmInfo.waterQuality}
|
||||
/>
|
||||
<FarmBadge
|
||||
icon="tabler-temperature"
|
||||
label={t("farmInfo.climateZone")}
|
||||
value={farmInfo.climateZone}
|
||||
<i
|
||||
className={`${getGrowthStageIcon(stage)} text-xl ${isSelected ? "text-white" : ""}`}
|
||||
style={!isSelected ? { color: primaryMain } : undefined}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
sx={{
|
||||
color: isSelected ? "primary.main" : "text.secondary",
|
||||
textAlign: "center",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{formatStageLabel(stage)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 3) Plant Selection Section */}
|
||||
<Typography
|
||||
@@ -274,11 +334,9 @@ export default function SmartIrrigationRecommendation() {
|
||||
<CropCard
|
||||
key={crop.id}
|
||||
crop={crop}
|
||||
label={t(`crops.${crop.labelKey}`)}
|
||||
label={crop.name ?? (crop.labelKey ? t(`crops.${crop.labelKey}`) : crop.id)}
|
||||
selected={selectedCrop === crop.id}
|
||||
onClick={() =>
|
||||
setSelectedCrop((prev) => (prev === crop.id ? null : crop.id))
|
||||
}
|
||||
onClick={() => handleCropSelect(crop)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
@@ -289,7 +347,12 @@ export default function SmartIrrigationRecommendation() {
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
disabled={!selectedCrop || loading || configLoading}
|
||||
disabled={
|
||||
!selectedCrop ||
|
||||
(growthStages.length > 0 && !selectedGrowthStage) ||
|
||||
loading ||
|
||||
configLoading
|
||||
}
|
||||
onClick={handleGenerate}
|
||||
startIcon={<i className="tabler-sparkles text-xl" />}
|
||||
className="rounded-2xl py-3.5 text-base font-semibold shadow-lg transition-all duration-300 hover:shadow-xl hover:scale-[1.01] active:scale-[0.99]"
|
||||
@@ -531,44 +594,6 @@ export default function SmartIrrigationRecommendation() {
|
||||
|
||||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
function FarmBadge({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const primaryMain = theme.palette.primary.main;
|
||||
return (
|
||||
<Box
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-2xl transition-transform duration-200 hover:scale-[1.02]"
|
||||
sx={{
|
||||
background: `linear-gradient(145deg, ${alpha(primaryMain, 0.08)} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||
border: `1px solid ${alpha(primaryMain, 0.15)}`,
|
||||
boxShadow: "inset 0 1px 2px rgba(255,255,255,0.5)",
|
||||
}}
|
||||
>
|
||||
<i className={`${icon} text-xl`} style={{ color: primaryMain }} />
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
display="block"
|
||||
lineHeight={1.2}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={600} color="text.primary">
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CropCard({
|
||||
crop,
|
||||
label,
|
||||
|
||||
Reference in New Issue
Block a user