UPDATE
This commit is contained in:
@@ -640,6 +640,9 @@
|
|||||||
"plantSelection": {
|
"plantSelection": {
|
||||||
"title": "انتخاب محصول"
|
"title": "انتخاب محصول"
|
||||||
},
|
},
|
||||||
|
"growthStage": {
|
||||||
|
"title": "مرحله رشد"
|
||||||
|
},
|
||||||
"crops": {
|
"crops": {
|
||||||
"wheat": "گندم",
|
"wheat": "گندم",
|
||||||
"corn": "ذرت",
|
"corn": "ذرت",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
import { normalizeRecommendationTaskStatus } from "./recommendationTask";
|
import { normalizeRecommendationTaskStatus } from "./recommendationTask";
|
||||||
|
|
||||||
const PREFIX = "/api/fertilization-recommendation";
|
const PREFIX = "/api/fertilization-recommendation";
|
||||||
|
const RECOMMEND_PREFIX = "/api/fertilization";
|
||||||
|
|
||||||
export interface FarmData {
|
export interface FarmData {
|
||||||
soilType: string;
|
soilType: string;
|
||||||
@@ -21,12 +22,15 @@ export interface FarmData {
|
|||||||
export interface GrowthStage {
|
export interface GrowthStage {
|
||||||
id: string;
|
id: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CropOption {
|
export interface CropOption {
|
||||||
id: string;
|
id: string;
|
||||||
labelKey: string;
|
labelKey?: string;
|
||||||
|
name?: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
growthStages?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FertilizationConfigResponse {
|
export interface FertilizationConfigResponse {
|
||||||
@@ -107,7 +111,7 @@ export const fertilizationRecommendationService = {
|
|||||||
): Promise<FertilizationRecommendResponse> {
|
): Promise<FertilizationRecommendResponse> {
|
||||||
return unwrap(
|
return unwrap(
|
||||||
apiClient.post<ApiResponse<FertilizationRecommendResponse>>(
|
apiClient.post<ApiResponse<FertilizationRecommendResponse>>(
|
||||||
`${PREFIX}/recommend/`,
|
`${RECOMMEND_PREFIX}/recommend/`,
|
||||||
payload ?? {},
|
payload ?? {},
|
||||||
),
|
),
|
||||||
).then((response) =>
|
).then((response) =>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
import { normalizeRecommendationTaskStatus } from "./recommendationTask";
|
import { normalizeRecommendationTaskStatus } from "./recommendationTask";
|
||||||
|
|
||||||
const PREFIX = "/api/irrigation-recommendation";
|
const PREFIX = "/api/irrigation-recommendation";
|
||||||
|
const RECOMMEND_PREFIX = "/api/irrigation";
|
||||||
|
|
||||||
export interface FarmInfo {
|
export interface FarmInfo {
|
||||||
soilType: string;
|
soilType: string;
|
||||||
@@ -20,8 +21,10 @@ export interface FarmInfo {
|
|||||||
|
|
||||||
export interface CropOption {
|
export interface CropOption {
|
||||||
id: string;
|
id: string;
|
||||||
labelKey: string;
|
labelKey?: string;
|
||||||
|
name?: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
growthStages?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IrrigationMethod {
|
export interface IrrigationMethod {
|
||||||
@@ -48,6 +51,7 @@ export interface IrrigationPlan {
|
|||||||
export interface IrrigationRecommendPayload {
|
export interface IrrigationRecommendPayload {
|
||||||
farm_uuid: string;
|
farm_uuid: string;
|
||||||
crop_id?: string;
|
crop_id?: string;
|
||||||
|
growth_stage?: string;
|
||||||
irrigation_method_id?: string;
|
irrigation_method_id?: string;
|
||||||
farm_data?: Partial<FarmInfo>;
|
farm_data?: Partial<FarmInfo>;
|
||||||
soilType?: string;
|
soilType?: string;
|
||||||
@@ -156,7 +160,7 @@ export const irrigationRecommendationService = {
|
|||||||
): Promise<IrrigationRecommendResponse> {
|
): Promise<IrrigationRecommendResponse> {
|
||||||
return unwrap(
|
return unwrap(
|
||||||
apiClient.post<ApiResponse<IrrigationRecommendResponse>>(
|
apiClient.post<ApiResponse<IrrigationRecommendResponse>>(
|
||||||
`${PREFIX}/recommend/`,
|
`${RECOMMEND_PREFIX}/recommend/`,
|
||||||
payload ?? {},
|
payload ?? {},
|
||||||
),
|
),
|
||||||
).then((response) =>
|
).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)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
+419
-390
@@ -9,39 +9,54 @@ import Typography from "@mui/material/Typography";
|
|||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Collapse from "@mui/material/Collapse";
|
import Collapse from "@mui/material/Collapse";
|
||||||
import CircularProgress from "@mui/material/CircularProgress";
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
import { useTheme, alpha } from "@mui/material/styles";
|
import { useTheme, alpha } from "@mui/material/styles";
|
||||||
import { useFarmHub } from "@/hooks/useFarmHub";
|
import { useFarmHub } from "@/hooks/useFarmHub";
|
||||||
import type {
|
import type {
|
||||||
FarmData,
|
|
||||||
GrowthStage,
|
GrowthStage,
|
||||||
CropOption,
|
CropOption,
|
||||||
FertilizationPlan,
|
FertilizationPlan,
|
||||||
} from "@/libs/api/services/fertilizationRecommendationService";
|
} from "@/libs/api/services/fertilizationRecommendationService";
|
||||||
import { fertilizationRecommendationService } from "@/libs/api/services/fertilizationRecommendationService";
|
import { fertilizationRecommendationService } from "@/libs/api/services/fertilizationRecommendationService";
|
||||||
import { isRecommendationTaskRunning } from "@/libs/api/services/recommendationTask";
|
import { isRecommendationTaskRunning } from "@/libs/api/services/recommendationTask";
|
||||||
|
import { selectedPlantsService } from "@/libs/api/services/selectedPlantsService";
|
||||||
|
|
||||||
const DEFAULT_FARM_DATA: FarmData = {
|
const GROWTH_STAGE_LABELS: Record<string, string> = {
|
||||||
soilType: "Loamy",
|
initial: "شروع رشد",
|
||||||
organicMatter: "Medium (2.5%)",
|
vegetative: "رشد رویشی",
|
||||||
waterEC: "1.2 dS/m",
|
flowering: "گلدهی",
|
||||||
|
fruiting: "باردهی",
|
||||||
|
maturity: "رسیدگی",
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_GROWTH_STAGES: GrowthStage[] = [
|
const PLANT_ICON_MAP: Record<string, string> = {
|
||||||
{ id: "prePlanting", icon: "tabler-seedling" },
|
corn: "tabler-plant-2",
|
||||||
{ id: "earlyGrowth", icon: "tabler-leaf" },
|
wheat: "tabler-wheat",
|
||||||
{ id: "flowering", icon: "tabler-flower" },
|
cotton: "tabler-flower",
|
||||||
{ id: "fruiting", icon: "tabler-apple" },
|
saffron: "tabler-flower-2",
|
||||||
{ id: "postHarvest", icon: "tabler-basket" },
|
canola: "tabler-leaf",
|
||||||
];
|
vegetables: "tabler-carrot",
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_CROP_OPTIONS: CropOption[] = [
|
const GROWTH_STAGE_ICON_MAP: Record<string, string> = {
|
||||||
{ id: "wheat", labelKey: "wheat", icon: "tabler-wheat" },
|
initial: "tabler-seedling",
|
||||||
{ id: "corn", labelKey: "corn", icon: "tabler-plant-2" },
|
vegetative: "tabler-leaf",
|
||||||
{ id: "cotton", labelKey: "cotton", icon: "tabler-flower" },
|
flowering: "tabler-flower",
|
||||||
{ id: "saffron", labelKey: "saffron", icon: "tabler-flower-2" },
|
fruiting: "tabler-apple",
|
||||||
{ id: "canola", labelKey: "canola", icon: "tabler-leaf" },
|
maturity: "tabler-basket",
|
||||||
{ id: "vegetables", labelKey: "vegetables", icon: "tabler-carrot" },
|
};
|
||||||
];
|
|
||||||
|
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 sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
const getErrorMessage = (error: unknown, fallback: string) =>
|
const getErrorMessage = (error: unknown, fallback: string) =>
|
||||||
@@ -61,17 +76,11 @@ export default function SmartFertilizationRecommendation() {
|
|||||||
const primaryLight = theme.palette.primary.light;
|
const primaryLight = theme.palette.primary.light;
|
||||||
const primaryDark = theme.palette.primary.dark;
|
const primaryDark = theme.palette.primary.dark;
|
||||||
const paperBg = theme.palette.background.paper;
|
const paperBg = theme.palette.background.paper;
|
||||||
const [farmData, setFarmData] = useState<FarmData>(DEFAULT_FARM_DATA);
|
const [growthStages, setGrowthStages] = useState<GrowthStage[]>([]);
|
||||||
const [growthStages, setGrowthStages] = useState<GrowthStage[]>(
|
const [cropOptions, setCropOptions] = useState<CropOption[]>([]);
|
||||||
DEFAULT_GROWTH_STAGES,
|
|
||||||
);
|
|
||||||
const [cropOptions, setCropOptions] =
|
|
||||||
useState<CropOption[]>(DEFAULT_CROP_OPTIONS);
|
|
||||||
const [configLoading, setConfigLoading] = useState(true);
|
const [configLoading, setConfigLoading] = useState(true);
|
||||||
const [configError, setConfigError] = useState<string | null>(null);
|
const [configError, setConfigError] = useState<string | null>(null);
|
||||||
const [growthStage, setGrowthStage] = useState<string>(
|
const [growthStage, setGrowthStage] = useState<string>("");
|
||||||
DEFAULT_GROWTH_STAGES[0].id,
|
|
||||||
);
|
|
||||||
const [selectedCrop, setSelectedCrop] = useState<string | null>(null);
|
const [selectedCrop, setSelectedCrop] = useState<string | null>(null);
|
||||||
const [plan, setPlan] = useState<FertilizationPlan | null>(null);
|
const [plan, setPlan] = useState<FertilizationPlan | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -82,6 +91,9 @@ export default function SmartFertilizationRecommendation() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPlan(null);
|
setPlan(null);
|
||||||
setRequestError(null);
|
setRequestError(null);
|
||||||
|
setSelectedCrop(null);
|
||||||
|
setGrowthStages([]);
|
||||||
|
setGrowthStage("");
|
||||||
|
|
||||||
if (!farmUuid) {
|
if (!farmUuid) {
|
||||||
setConfigError(t("errors.noFarm"));
|
setConfigError(t("errors.noFarm"));
|
||||||
@@ -91,18 +103,35 @@ export default function SmartFertilizationRecommendation() {
|
|||||||
|
|
||||||
setConfigLoading(true);
|
setConfigLoading(true);
|
||||||
setConfigError(null);
|
setConfigError(null);
|
||||||
fertilizationRecommendationService
|
selectedPlantsService
|
||||||
.getConfig(farmUuid)
|
.getSelected(farmUuid)
|
||||||
.then(({ farmData: farm, growthStages: stages, cropOptions: crops }) => {
|
.then((plants) => {
|
||||||
if (farm) setFarmData(farm);
|
const crops = plants.map((plant) => ({
|
||||||
if (stages?.length) {
|
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);
|
setGrowthStages(stages);
|
||||||
setGrowthStage(stages[0].id);
|
setGrowthStage(stages[0]?.id ?? "");
|
||||||
}
|
}
|
||||||
if (crops?.length) setCropOptions(crops);
|
|
||||||
})
|
})
|
||||||
.catch((err: { message?: string }) => {
|
.catch((err: { message?: string }) => {
|
||||||
setConfigError(err?.message ?? "Failed to load config");
|
setConfigError(err?.message ?? "Failed to load plants");
|
||||||
})
|
})
|
||||||
.finally(() => setConfigLoading(false));
|
.finally(() => setConfigLoading(false));
|
||||||
}, [farmUuid, t]);
|
}, [farmUuid, t]);
|
||||||
@@ -120,14 +149,6 @@ export default function SmartFertilizationRecommendation() {
|
|||||||
farm_uuid: farmUuid,
|
farm_uuid: farmUuid,
|
||||||
crop_id: selectedCrop,
|
crop_id: selectedCrop,
|
||||||
growth_stage: growthStage,
|
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 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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -189,322 +241,339 @@ export default function SmartFertilizationRecommendation() {
|
|||||||
minHeight: "100vh",
|
minHeight: "100vh",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box className="max-w-lg mx-auto px-4 py-6 sm:py-8">
|
<Box
|
||||||
{/* 1) Header */}
|
className={`max-w-lg mx-auto px-4 ${plan ? "py-0 sm:py-0" : "py-6 sm:py-8"}`}
|
||||||
<Box className="mb-8">
|
>
|
||||||
<Typography
|
{plan ? (
|
||||||
variant="h4"
|
<Box className="animate-fade-in">
|
||||||
className="font-bold tracking-tight"
|
<Box
|
||||||
sx={{
|
position="sticky"
|
||||||
background: `linear-gradient(135deg, ${primaryDark} 0%, ${primaryMain} 40%, ${primaryLight} 100%)`,
|
top={0}
|
||||||
backgroundClip: "text",
|
zIndex={50}
|
||||||
WebkitBackgroundClip: "text",
|
bgcolor="background.paper"
|
||||||
color: "transparent",
|
className="-mx-4 mb-6 px-4 py-3"
|
||||||
fontSize: { xs: "1.5rem", sm: "1.75rem" },
|
sx={{
|
||||||
}}
|
borderBottom: `1px solid ${alpha(primaryMain, 0.12)}`,
|
||||||
>
|
boxShadow: `0 6px 20px ${alpha(theme.palette.common.black, 0.04)}`,
|
||||||
{t("title")}
|
}}
|
||||||
</Typography>
|
>
|
||||||
<Typography
|
<Box className="flex items-center gap-2">
|
||||||
variant="body2"
|
<IconButton
|
||||||
color="text.secondary"
|
onClick={handleBackToForm}
|
||||||
className="mt-1 transition-colors duration-300"
|
sx={{
|
||||||
>
|
border: `1px solid ${alpha(primaryMain, 0.14)}`,
|
||||||
{t("subtitle")}
|
bgcolor: alpha(primaryMain, 0.04),
|
||||||
</Typography>
|
borderRadius: "16px",
|
||||||
</Box>
|
}}
|
||||||
|
>
|
||||||
{/* 2) Farm Data Card */}
|
<i
|
||||||
<Card
|
className="tabler-arrow-right text-xl"
|
||||||
elevation={0}
|
style={{ color: primaryMain }}
|
||||||
className="mb-6 overflow-hidden transition-all duration-300 hover:shadow-lg animate-fade-in"
|
/>
|
||||||
sx={{
|
</IconButton>
|
||||||
borderRadius: "28px",
|
<Typography variant="subtitle1" fontWeight="bold">
|
||||||
background: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 50%, ${alpha(primaryMain, 0.04)} 100%)`,
|
{resultContext}
|
||||||
boxShadow: `0 4px 24px ${alpha(primaryMain, 0.08)}, 0 4px 12px ${alpha(primaryMain, 0.04)}, 0 1px 3px rgba(0,0,0,0.04)`,
|
</Typography>
|
||||||
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>
|
</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 */}
|
<Box className="pb-28">
|
||||||
<Typography
|
<Card
|
||||||
variant="subtitle2"
|
elevation={0}
|
||||||
fontWeight={600}
|
|
||||||
color="text.secondary"
|
|
||||||
className="mbe-3"
|
|
||||||
>
|
|
||||||
{t("growthStage.title")}
|
|
||||||
</Typography>
|
|
||||||
<Box className="flex gap-2 mb-6 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-hide">
|
|
||||||
{growthStages.map((stage, idx) => {
|
|
||||||
const isSelected = growthStage === stage.id;
|
|
||||||
const isPast = idx < stageIndex;
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={stage.id}
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => 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={{
|
sx={{
|
||||||
borderColor: isSelected ? primaryMain : "transparent",
|
borderRadius: "28px",
|
||||||
background: isSelected
|
background: `linear-gradient(160deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 40%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||||
? `linear-gradient(145deg, ${alpha(primaryMain, 0.15)} 0%, ${alpha(primaryMain, 0.06)} 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)`,
|
||||||
: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
border: `1px solid ${alpha(primaryMain, 0.15)}`,
|
||||||
boxShadow: isSelected
|
overflow: "visible",
|
||||||
? `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)",
|
>
|
||||||
|
<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"
|
||||||
|
className="font-bold tracking-tight"
|
||||||
|
sx={{
|
||||||
|
background: `linear-gradient(135deg, ${primaryDark} 0%, ${primaryMain} 40%, ${primaryLight} 100%)`,
|
||||||
|
backgroundClip: "text",
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
color: "transparent",
|
||||||
|
fontSize: { xs: "1.5rem", sm: "1.75rem" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("title")}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
className="mt-1 transition-colors duration-300"
|
||||||
|
>
|
||||||
|
{t("subtitle")}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!!growthStages.length && (
|
||||||
|
<>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
fontWeight={600}
|
||||||
|
color="text.secondary"
|
||||||
|
className="mbe-3"
|
||||||
|
>
|
||||||
|
{t("growthStage.title")}
|
||||||
|
</Typography>
|
||||||
|
<Box className="flex gap-2 mb-6 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-hide">
|
||||||
|
{growthStages.map((stage, idx) => {
|
||||||
|
const isSelected = growthStage === stage.id;
|
||||||
|
const isPast = idx < stageIndex;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={stage.id}
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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)}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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%)`
|
||||||
|
: isPast
|
||||||
|
? `linear-gradient(145deg, ${alpha(primaryMain, 0.2)} 0%, ${alpha(primaryMain, 0.1)} 100%)`
|
||||||
|
: alpha(primaryMain, 0.08),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={`${stage.icon} text-xl transition-colors duration-300 ${isSelected ? "text-white" : ""}`}
|
||||||
|
style={!isSelected ? { color: primaryMain } : undefined}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
fontWeight={600}
|
||||||
|
sx={{
|
||||||
|
color: isSelected ? "primary.main" : "text.secondary",
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stage.label ?? formatStageLabel(stage.id)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
fontWeight={600}
|
||||||
|
color="text.secondary"
|
||||||
|
className="mbe-3"
|
||||||
|
>
|
||||||
|
{t("plantSelection.title")}
|
||||||
|
</Typography>
|
||||||
|
{configLoading ? (
|
||||||
|
<Box className="flex justify-center py-8 mb-6">
|
||||||
|
<CircularProgress size={32} sx={{ color: "primary.main" }} />
|
||||||
|
</Box>
|
||||||
|
) : configError ? (
|
||||||
|
<Typography variant="body2" color="error" className="mb-6">
|
||||||
|
{configError}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box className="flex flex-wrap gap-3 mb-6">
|
||||||
|
{cropOptions.map((crop) => (
|
||||||
|
<CropCard
|
||||||
|
key={crop.id}
|
||||||
|
crop={crop}
|
||||||
|
label={crop.name ?? (crop.labelKey ? t(`crops.${crop.labelKey}`) : crop.id)}
|
||||||
|
selected={selectedCrop === crop.id}
|
||||||
|
onClick={() => handleCropSelect(crop)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box className="mb-8">
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
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]"
|
||||||
|
sx={{
|
||||||
|
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
||||||
|
boxShadow: `0 4px 20px ${alpha(primaryMain, 0.4)}`,
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
transform: "translateY(-2px)",
|
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
||||||
boxShadow: isSelected
|
boxShadow: `0 6px 28px ${alpha(primaryMain, 0.5)}`,
|
||||||
? `0 6px 24px ${alpha(primaryMain, 0.25)}`
|
filter: "brightness(1.05)",
|
||||||
: `0 4px 16px ${alpha(primaryMain, 0.1)}`,
|
},
|
||||||
|
"&:disabled": {
|
||||||
|
background: "action.disabledBackground",
|
||||||
|
color: "action.disabled",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
{t("generateCta")}
|
||||||
className="w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300"
|
</Button>
|
||||||
sx={{
|
</Box>
|
||||||
background: isSelected
|
|
||||||
? `linear-gradient(135deg, ${primaryMain} 0%, ${primaryDark} 100%)`
|
|
||||||
: isPast
|
|
||||||
? `linear-gradient(145deg, ${alpha(primaryMain, 0.2)} 0%, ${alpha(primaryMain, 0.1)} 100%)`
|
|
||||||
: alpha(primaryMain, 0.08),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className={`${stage.icon} text-xl transition-colors duration-300 ${isSelected ? "text-white" : ""}`}
|
|
||||||
style={!isSelected ? { color: primaryMain } : undefined}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
fontWeight={600}
|
|
||||||
sx={{
|
|
||||||
color: isSelected ? "primary.main" : "text.secondary",
|
|
||||||
textAlign: "center",
|
|
||||||
lineHeight: 1.2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t(`growthStage.${stage.id}`)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 4) Plant Selection */}
|
{requestError && !loading && (
|
||||||
<Typography
|
<Typography variant="body2" color="error" className="mb-6">
|
||||||
variant="subtitle2"
|
{requestError}
|
||||||
fontWeight={600}
|
</Typography>
|
||||||
color="text.secondary"
|
)}
|
||||||
className="mbe-3"
|
</>
|
||||||
>
|
|
||||||
{t("plantSelection.title")}
|
|
||||||
</Typography>
|
|
||||||
{configLoading ? (
|
|
||||||
<Box className="flex justify-center py-8 mb-6">
|
|
||||||
<CircularProgress size={32} sx={{ color: "primary.main" }} />
|
|
||||||
</Box>
|
|
||||||
) : configError ? (
|
|
||||||
<Typography variant="body2" color="error" className="mb-6">
|
|
||||||
{configError}
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Box className="flex flex-wrap gap-3 mb-6">
|
|
||||||
{cropOptions.map((crop) => (
|
|
||||||
<CropCard
|
|
||||||
key={crop.id}
|
|
||||||
crop={crop}
|
|
||||||
label={t(`crops.${crop.labelKey}`)}
|
|
||||||
selected={selectedCrop === crop.id}
|
|
||||||
onClick={() =>
|
|
||||||
setSelectedCrop((prev) => (prev === crop.id ? null : crop.id))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 5) Primary CTA Button - End of form */}
|
|
||||||
<Box className="mb-8">
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="contained"
|
|
||||||
disabled={!selectedCrop || 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]"
|
|
||||||
sx={{
|
|
||||||
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
|
||||||
boxShadow: `0 4px 20px ${alpha(primaryMain, 0.4)}`,
|
|
||||||
"&:hover": {
|
|
||||||
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
|
||||||
boxShadow: `0 6px 28px ${alpha(primaryMain, 0.5)}`,
|
|
||||||
filter: "brightness(1.05)",
|
|
||||||
},
|
|
||||||
"&:disabled": {
|
|
||||||
background: "action.disabledBackground",
|
|
||||||
color: "action.disabled",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("generateCta")}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{requestError && !loading && (
|
|
||||||
<Typography variant="body2" color="error" className="mb-6">
|
|
||||||
{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 && (
|
{loading && (
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
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({
|
function CropCard({
|
||||||
crop,
|
crop,
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -7,25 +7,55 @@ import Card from "@mui/material/Card";
|
|||||||
import CardContent from "@mui/material/CardContent";
|
import CardContent from "@mui/material/CardContent";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import CircularProgress from "@mui/material/CircularProgress";
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
import { useTheme, alpha } from "@mui/material/styles";
|
import { useTheme, alpha } from "@mui/material/styles";
|
||||||
import { useFarmHub } from "@/hooks/useFarmHub";
|
import { useFarmHub } from "@/hooks/useFarmHub";
|
||||||
import type {
|
import type {
|
||||||
FarmInfo,
|
|
||||||
CropOption,
|
CropOption,
|
||||||
IrrigationPlan,
|
IrrigationPlan,
|
||||||
WaterBalance,
|
WaterBalance,
|
||||||
} from "@/libs/api/services/irrigationRecommendationService";
|
} from "@/libs/api/services/irrigationRecommendationService";
|
||||||
import { irrigationRecommendationService } from "@/libs/api/services/irrigationRecommendationService";
|
import { irrigationRecommendationService } from "@/libs/api/services/irrigationRecommendationService";
|
||||||
import { isRecommendationTaskRunning } from "@/libs/api/services/recommendationTask";
|
import { isRecommendationTaskRunning } from "@/libs/api/services/recommendationTask";
|
||||||
|
import { selectedPlantsService } from "@/libs/api/services/selectedPlantsService";
|
||||||
|
|
||||||
const DEFAULT_FARM_INFO: FarmInfo = {
|
const GROWTH_STAGE_LABELS: Record<string, string> = {
|
||||||
soilType: "Loamy",
|
initial: "شروع رشد",
|
||||||
waterQuality: "Medium EC",
|
vegetative: "رشد رویشی",
|
||||||
climateZone: "Temperate",
|
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 sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
const getErrorMessage = (error: unknown, fallback: string) =>
|
const getErrorMessage = (error: unknown, fallback: string) =>
|
||||||
typeof error === "object" &&
|
typeof error === "object" &&
|
||||||
@@ -40,11 +70,14 @@ export default function SmartIrrigationRecommendation() {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { farmHub } = useFarmHub();
|
const { farmHub } = useFarmHub();
|
||||||
const farmUuid = farmHub?.farm_uuid;
|
const farmUuid = farmHub?.farm_uuid;
|
||||||
const [farmInfo, setFarmInfo] = useState<FarmInfo>(DEFAULT_FARM_INFO);
|
|
||||||
const [cropOptions, setCropOptions] = useState<CropOption[]>([]);
|
const [cropOptions, setCropOptions] = useState<CropOption[]>([]);
|
||||||
const [configLoading, setConfigLoading] = useState(true);
|
const [configLoading, setConfigLoading] = useState(true);
|
||||||
const [configError, setConfigError] = useState<string | null>(null);
|
const [configError, setConfigError] = useState<string | null>(null);
|
||||||
const [selectedCrop, setSelectedCrop] = 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 [plan, setPlan] = useState<IrrigationPlan | null>(null);
|
||||||
const [waterBalance, setWaterBalance] = useState<WaterBalance | null>(null);
|
const [waterBalance, setWaterBalance] = useState<WaterBalance | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -59,6 +92,9 @@ export default function SmartIrrigationRecommendation() {
|
|||||||
setPlan(null);
|
setPlan(null);
|
||||||
setWaterBalance(null);
|
setWaterBalance(null);
|
||||||
setRequestError(null);
|
setRequestError(null);
|
||||||
|
setSelectedCrop(null);
|
||||||
|
setGrowthStages([]);
|
||||||
|
setSelectedGrowthStage(null);
|
||||||
|
|
||||||
if (!farmUuid) {
|
if (!farmUuid) {
|
||||||
setConfigError(t("errors.noFarm"));
|
setConfigError(t("errors.noFarm"));
|
||||||
@@ -68,14 +104,27 @@ export default function SmartIrrigationRecommendation() {
|
|||||||
|
|
||||||
setConfigLoading(true);
|
setConfigLoading(true);
|
||||||
setConfigError(null);
|
setConfigError(null);
|
||||||
irrigationRecommendationService
|
selectedPlantsService
|
||||||
.getConfig(farmUuid)
|
.getSelected(farmUuid)
|
||||||
.then(({ farmInfo: info, cropOptions: crops }) => {
|
.then((plants) => {
|
||||||
setFarmInfo(info);
|
const crops = plants.map((plant) => ({
|
||||||
setCropOptions(crops.length > 0 ? crops : []);
|
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 }) => {
|
.catch((err: { message?: string }) => {
|
||||||
setConfigError(err?.message ?? "Failed to load config");
|
setConfigError(err?.message ?? "Failed to load plants");
|
||||||
})
|
})
|
||||||
.finally(() => setConfigLoading(false));
|
.finally(() => setConfigLoading(false));
|
||||||
}, [farmUuid, t]);
|
}, [farmUuid, t]);
|
||||||
@@ -91,14 +140,7 @@ export default function SmartIrrigationRecommendation() {
|
|||||||
const recommendation = await irrigationRecommendationService.recommend({
|
const recommendation = await irrigationRecommendationService.recommend({
|
||||||
farm_uuid: farmUuid,
|
farm_uuid: farmUuid,
|
||||||
crop_id: selectedCrop,
|
crop_id: selectedCrop,
|
||||||
farm_data: {
|
growth_stage: selectedGrowthStage ?? undefined,
|
||||||
soilType: farmInfo.soilType,
|
|
||||||
waterQuality: farmInfo.waterQuality,
|
|
||||||
climateZone: farmInfo.climateZone,
|
|
||||||
},
|
|
||||||
soilType: farmInfo.soilType,
|
|
||||||
waterQuality: farmInfo.waterQuality,
|
|
||||||
climateZone: farmInfo.climateZone,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if ("task_id" in recommendation) {
|
if ("task_id" in recommendation) {
|
||||||
@@ -157,6 +199,19 @@ export default function SmartIrrigationRecommendation() {
|
|||||||
const hasNumericMoistureLevel = Number.isFinite(moistureLevelValue);
|
const hasNumericMoistureLevel = Number.isFinite(moistureLevelValue);
|
||||||
const nextWaterBalanceDay = waterBalance?.daily?.[0];
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className="min-bs-screen"
|
className="min-bs-screen"
|
||||||
@@ -187,69 +242,74 @@ export default function SmartIrrigationRecommendation() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 2) Farm Info Card */}
|
{/* 2) Growth Stage Selector */}
|
||||||
<Card
|
{!!growthStages.length && (
|
||||||
elevation={0}
|
<>
|
||||||
className="mb-6 overflow-hidden transition-all duration-300 hover:shadow-lg"
|
<Typography
|
||||||
sx={{
|
variant="subtitle2"
|
||||||
borderRadius: "24px",
|
fontWeight={600}
|
||||||
background: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
color="text.secondary"
|
||||||
boxShadow: `0 4px 24px ${alpha(primaryMain, 0.08)}, 0 1px 3px rgba(0,0,0,0.04)`,
|
className="mbe-3"
|
||||||
border: `1px solid ${alpha(primaryMain, 0.12)}`,
|
>
|
||||||
}}
|
{t("growthStage.title")}
|
||||||
>
|
</Typography>
|
||||||
<CardContent className="p-5">
|
<Box className="flex gap-2 mb-6 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-hide">
|
||||||
<Box className="flex items-center justify-between mbe-4">
|
{growthStages.map((stage) => {
|
||||||
<Typography
|
const isSelected = selectedGrowthStage === stage;
|
||||||
variant="subtitle2"
|
|
||||||
fontWeight={600}
|
return (
|
||||||
color="text.secondary"
|
<Box
|
||||||
>
|
key={stage}
|
||||||
{t("farmInfo.title")}
|
component="button"
|
||||||
</Typography>
|
type="button"
|
||||||
<Box className="flex items-center gap-2">
|
onClick={() => setSelectedGrowthStage(stage)}
|
||||||
<Box
|
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]"
|
||||||
className="px-2.5 py-1 rounded-full text-xs font-medium"
|
sx={{
|
||||||
sx={{
|
borderColor: isSelected ? primaryMain : "transparent",
|
||||||
background: (t) =>
|
background: isSelected
|
||||||
`linear-gradient(135deg, ${t.palette.success.main} 0%, ${t.palette.success.dark} 100%)`,
|
? `linear-gradient(145deg, ${alpha(primaryMain, 0.15)} 0%, ${alpha(primaryMain, 0.06)} 100%)`
|
||||||
color: "white",
|
: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||||
display: "flex",
|
boxShadow: isSelected
|
||||||
alignItems: "center",
|
? `0 4px 20px ${alpha(primaryMain, 0.2)}, inset 0 1px 0 rgba(255,255,255,0.8)`
|
||||||
gap: 4,
|
: "0 2px 8px rgba(0,0,0,0.04)",
|
||||||
}}
|
"&:hover": {
|
||||||
>
|
transform: "translateY(-2px)",
|
||||||
<i className="tabler-circle-check text-sm" />
|
boxShadow: isSelected
|
||||||
{t("verifiedBadge")}
|
? `0 6px 24px ${alpha(primaryMain, 0.25)}`
|
||||||
</Box>
|
: `0 4px 16px ${alpha(primaryMain, 0.1)}`,
|
||||||
<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"
|
||||||
<i className="tabler-pencil text-lg" />
|
sx={{
|
||||||
</IconButton>
|
background: isSelected
|
||||||
</Box>
|
? `linear-gradient(135deg, ${primaryMain} 0%, ${primaryDark} 100%)`
|
||||||
|
: alpha(primaryMain, 0.08),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={`${getGrowthStageIcon(stage)} text-xl ${isSelected ? "text-white" : ""}`}
|
||||||
|
style={!isSelected ? { color: primaryMain } : undefined}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
fontWeight={600}
|
||||||
|
sx={{
|
||||||
|
color: isSelected ? "primary.main" : "text.secondary",
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatStageLabel(stage)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</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}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 3) Plant Selection Section */}
|
{/* 3) Plant Selection Section */}
|
||||||
<Typography
|
<Typography
|
||||||
@@ -274,11 +334,9 @@ export default function SmartIrrigationRecommendation() {
|
|||||||
<CropCard
|
<CropCard
|
||||||
key={crop.id}
|
key={crop.id}
|
||||||
crop={crop}
|
crop={crop}
|
||||||
label={t(`crops.${crop.labelKey}`)}
|
label={crop.name ?? (crop.labelKey ? t(`crops.${crop.labelKey}`) : crop.id)}
|
||||||
selected={selectedCrop === crop.id}
|
selected={selectedCrop === crop.id}
|
||||||
onClick={() =>
|
onClick={() => handleCropSelect(crop)}
|
||||||
setSelectedCrop((prev) => (prev === crop.id ? null : crop.id))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -289,7 +347,12 @@ export default function SmartIrrigationRecommendation() {
|
|||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={!selectedCrop || loading || configLoading}
|
disabled={
|
||||||
|
!selectedCrop ||
|
||||||
|
(growthStages.length > 0 && !selectedGrowthStage) ||
|
||||||
|
loading ||
|
||||||
|
configLoading
|
||||||
|
}
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
startIcon={<i className="tabler-sparkles text-xl" />}
|
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]"
|
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 ──────────────────────────────────────────────────────────
|
// ─── 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({
|
function CropCard({
|
||||||
crop,
|
crop,
|
||||||
label,
|
label,
|
||||||
|
|||||||
Reference in New Issue
Block a user