This commit is contained in:
2026-04-28 02:09:04 +03:30
parent ea36fcf7ae
commit e737e4c47d
6 changed files with 615 additions and 520 deletions
+3
View File
@@ -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)}`,
),
);
},
};
@@ -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,8 +241,182 @@ 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"}`}
>
{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"> <Box className="mb-8">
<Typography <Typography
variant="h4" variant="h4"
@@ -214,61 +440,8 @@ export default function SmartFertilizationRecommendation() {
</Typography> </Typography>
</Box> </Box>
{/* 2) Farm Data Card */} {!!growthStages.length && (
<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 */}
<Typography <Typography
variant="subtitle2" variant="subtitle2"
fontWeight={600} fontWeight={600}
@@ -328,14 +501,15 @@ export default function SmartFertilizationRecommendation() {
lineHeight: 1.2, lineHeight: 1.2,
}} }}
> >
{t(`growthStage.${stage.id}`)} {stage.label ?? formatStageLabel(stage.id)}
</Typography> </Typography>
</Box> </Box>
); );
})} })}
</Box> </Box>
</>
)}
{/* 4) Plant Selection */}
<Typography <Typography
variant="subtitle2" variant="subtitle2"
fontWeight={600} fontWeight={600}
@@ -358,22 +532,19 @@ export default function SmartFertilizationRecommendation() {
<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>
)} )}
{/* 5) Primary CTA Button - End of form */}
<Box className="mb-8"> <Box className="mb-8">
<Button <Button
fullWidth fullWidth
variant="contained" variant="contained"
disabled={!selectedCrop || loading || configLoading} disabled={!selectedCrop || !growthStage || 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]"
@@ -400,111 +571,9 @@ export default function SmartFertilizationRecommendation() {
{requestError} {requestError}
</Typography> </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"
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">
<Typography <Typography
variant="subtitle2" variant="subtitle2"
fontWeight={600} fontWeight={600}
color="text.secondary" color="text.secondary"
className="mbe-3"
> >
{t("farmInfo.title")} {t("growthStage.title")}
</Typography> </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 <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={{ sx={{
background: (t) => borderColor: isSelected ? primaryMain : "transparent",
`linear-gradient(135deg, ${t.palette.success.main} 0%, ${t.palette.success.dark} 100%)`, background: isSelected
color: "white", ? `linear-gradient(145deg, ${alpha(primaryMain, 0.15)} 0%, ${alpha(primaryMain, 0.06)} 100%)`
display: "flex", : `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
alignItems: "center", boxShadow: isSelected
gap: 4, ? `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" /> <Box
{t("verifiedBadge")} className="w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300"
</Box> sx={{
<IconButton background: isSelected
size="small" ? `linear-gradient(135deg, ${primaryMain} 0%, ${primaryDark} 100%)`
sx={{ color: "text.secondary" }} : alpha(primaryMain, 0.08),
aria-label={t("editFarmInfo")} }}
> >
<i className="tabler-pencil text-lg" /> <i
</IconButton> className={`${getGrowthStageIcon(stage)} text-xl ${isSelected ? "text-white" : ""}`}
</Box> style={!isSelected ? { color: primaryMain } : undefined}
</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> </Box>
</CardContent> <Typography
</Card> 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 */} {/* 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,