This commit is contained in:
2026-04-28 04:11:23 +03:30
parent e737e4c47d
commit f4e60f51a6
3 changed files with 1232 additions and 263 deletions
+487
View File
@@ -0,0 +1,487 @@
# Fertilization Recommendation Result API Spec
این فایل مشخص می‌کند که فرانت‌اند برای صفحه `SmartFertilizationRecommendation` دقیقاً چه خروجی‌ای از بک‌اند نیاز دارد، مخصوصاً برای:
- Hero Card
- ماشین‌حساب مساحت مزرعه
- آنالیز ترکیبات
- مراحل مصرف
- نکات ایمنی
- کودهای جایگزین
- Bottom Sheet جزئیات
نکته مهم: برای ماشین‌حساب، فرانت‌اند **نباید** مقدار را از رشته‌هایی مثل `150 kg/ha` parse کند. بک‌اند باید مقادیر عددی استاندارد و مستقل برگرداند.
---
## هدف اصلی
برای اینکه ماشین‌حساب مقدار مصرف دقیق کار کند، بک‌اند باید علاوه بر متن نمایشی، **مقدار عددی پایه** را نیز برگرداند.
فرمول مورد نیاز فرانت:
```text
مقدار کل = مقدار مصرف در هر متر مربع × مساحت مزرعه
```
یا اگر واحد پایه بر حسب هکتار ارسال شود:
```text
مقدار کل = مقدار مصرف در هکتار × مساحت (هکتار)
```
اما پیشنهاد قطعی برای فرانت این است که بک‌اند هر دو را بدهد:
- `base_amount_per_hectare`
- `base_amount_per_square_meter`
تا هیچ تبدیل واحدی در UI لازم نباشد.
---
## Endpoint پیشنهادی
```text
POST /api/fertilization/recommend/
```
یا اگر ساختار فعلی پروژه حفظ شود:
```text
POST /api/fertilization-recommendation/recommend/
```
---
## Request پیشنهادی
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"crop_id": "wheat",
"growth_stage": "flowering",
"area": {
"value": 2.5,
"unit": "hectare"
}
}
```
### توضیح فیلدهای Request
| فیلد | نوع | اجباری | توضیح |
|------|-----|--------|-------|
| `farm_uuid` | string | بله | شناسه مزرعه |
| `crop_id` | string | بله | شناسه محصول |
| `growth_stage` | string | بله | مرحله رشد محصول |
| `area.value` | number | اختیاری | مساحت مزرعه برای محاسبه مستقیم مقدار کل |
| `area.unit` | string | اختیاری | واحد مساحت؛ ترجیحاً `hectare` یا `square_meter` |
اگر `area` ارسال نشود، فرانت با داده‌های پایه محاسبه را خودش انجام می‌دهد.
---
## Response پیشنهادی
```json
{
"status": "success",
"data": {
"recommendation_id": "fert-rec-001",
"crop": {
"id": "wheat",
"name": "گندم"
},
"growth_stage": {
"id": "flowering",
"name": "گلدهی"
},
"primary_recommendation": {
"fertilizer_code": "npk-20-20-20",
"fertilizer_name": "کود کامل 20-20-20",
"display_title": "کود کامل 20-20-20",
"fertilizer_type": "NPK",
"npk_ratio": {
"n": 20,
"p": 20,
"k": 20,
"label": "20-20-20"
},
"application_method": {
"id": "foliar_fertigation",
"label": "محلول پاشی / آب آبیاری"
},
"application_interval": {
"value": 14,
"unit": "day",
"label": "هر 14 روز"
},
"dosage": {
"base_amount_per_hectare": 150,
"base_amount_per_square_meter": 0.015,
"unit": "kg",
"label": "150 کیلوگرم در هکتار",
"calculation_basis": "product"
},
"total_amount": {
"value": 375,
"unit": "kg",
"label": "375 کیلوگرم"
},
"reasoning": "این ترکیب برای مرحله گلدهی به دلیل نیاز متعادل به ازت، فسفر و پتاسیم پیشنهاد شده است.",
"summary": "مناسب برای حفظ رشد رویشی و پشتیبانی از گلدهی"
},
"nutrient_analysis": {
"macro": [
{
"key": "n",
"name": "نیتروژن (N)",
"value": 20,
"unit": "percent",
"description": "نیتروژن برای رشد رویشی و افزایش سطح برگ ضروری است."
},
{
"key": "p",
"name": "فسفر (P)",
"value": 20,
"unit": "percent",
"description": "فسفر برای توسعه ریشه و انتقال انرژی اهمیت دارد."
},
{
"key": "k",
"name": "پتاسیم (K)",
"value": 20,
"unit": "percent",
"description": "پتاسیم به کیفیت محصول و مقاومت به تنش کمک می‌کند."
}
],
"micro": [
{
"key": "fe",
"name": "آهن",
"value": 0.5,
"unit": "percent",
"description": "آهن در تولید کلروفیل و جلوگیری از زردی موثر است."
},
{
"key": "zn",
"name": "روی",
"value": 1,
"unit": "percent",
"description": "روی در رشد متعادل و فعالیت آنزیم‌ها نقش دارد."
}
]
},
"application_guide": {
"safety_warning": "هنگام محلول پاشی از دستکش و ماسک استفاده کنید و در ساعات خنک روز مصرف انجام شود.",
"steps": [
{
"step_number": 1,
"title": "آماده سازی",
"description": "مقدار توصیه شده از کود را در یک سطل آب تمیز حل کنید."
},
{
"step_number": 2,
"title": "ترکیب",
"description": "محلول را به مخزن اصلی سم پاش یا سیستم آبیاری اضافه کنید و خوب هم بزنید."
},
{
"step_number": 3,
"title": "مصرف",
"description": "به صورت یکنواخت روی گیاه اسپری کنید یا در سیستم آبیاری تزریق نمایید."
}
]
},
"alternative_recommendations": [
{
"fertilizer_code": "npk-10-52-10",
"fertilizer_name": "کود 10-52-10",
"fertilizer_type": "NPK (فسفر بالا)",
"usage_method": "محلول پاشی",
"description": "برای تقویت ریشه و پشتیبانی از گلدهی در صورت نبود پیشنهاد اصلی مناسب است."
},
{
"fertilizer_code": "npk-12-12-36",
"fertilizer_name": "کود 12-12-36",
"fertilizer_type": "NPK (پتاس بالا)",
"usage_method": "تزریق در آبیاری",
"description": "برای بهبود کیفیت محصول و افزایش پتاسیم قابل استفاده است."
}
]
}
}
```
---
## فیلدهای ضروری برای فرانت
### 1) اطلاعات اصلی پیشنهاد
این فیلدها برای Hero Card لازم‌اند.
| فیلد | نوع | اجباری | توضیح |
|------|-----|--------|-------|
| `primary_recommendation.fertilizer_name` | string | بله | نام اصلی کود |
| `primary_recommendation.display_title` | string | بهتر است | عنوان نمایشی اگر با نام اصلی فرق دارد |
| `primary_recommendation.fertilizer_type` | string | بله | نوع کود مثل NPK |
| `primary_recommendation.npk_ratio.label` | string | بله | متن آماده برای نمایش مثل `20-20-20` |
| `primary_recommendation.npk_ratio.n` | number | بله | درصد نیتروژن |
| `primary_recommendation.npk_ratio.p` | number | بله | درصد فسفر |
| `primary_recommendation.npk_ratio.k` | number | بله | درصد پتاسیم |
| `primary_recommendation.application_method.label` | string | بله | روش مصرف نمایشی |
| `primary_recommendation.application_interval.label` | string | بله | فاصله مصرف نمایشی |
| `primary_recommendation.reasoning` | string | بله | دلیل توصیه برای بخش توضیحات |
### 2) فیلدهای ضروری برای ماشین‌حساب
این بخش مهم‌ترین قسمت برای بک‌اند است.
| فیلد | نوع | اجباری | توضیح |
|------|-----|--------|-------|
| `primary_recommendation.dosage.base_amount_per_hectare` | number | بله | مقدار پایه مصرف در هر هکتار |
| `primary_recommendation.dosage.base_amount_per_square_meter` | number | بله | مقدار پایه مصرف در هر متر مربع |
| `primary_recommendation.dosage.unit` | string | بله | واحد مقدار مصرف؛ مثل `kg` یا `liter` |
| `primary_recommendation.dosage.label` | string | بله | متن آماده برای نمایش مثل `150 کیلوگرم در هکتار` |
| `primary_recommendation.total_amount.value` | number | اختیاری | اگر بک‌اند بر اساس مساحت ورودی مقدار کل را حساب کند |
| `primary_recommendation.total_amount.unit` | string | اختیاری | واحد مقدار کل |
| `primary_recommendation.total_amount.label` | string | اختیاری | متن نمایشی مقدار کل |
### چرا `base_amount_per_square_meter` لازم است؟
چون شما گفتید ماشین‌حساب باید مقدار **کیلوگرم در هر متر مربع** را از بک‌اند بگیرد.
پس بک‌اند باید این مقدار را صریح برگرداند، نه اینکه فرانت از `kg/ha` خودش تبدیل کند.
نمونه:
```json
{
"base_amount_per_hectare": 150,
"base_amount_per_square_meter": 0.015,
"unit": "kg"
}
```
تبدیل مرجع:
```text
1 hectare = 10,000 square meters
150 kg/ha = 0.015 kg/m²
```
---
## فیلدهای آنالیز ترکیبات
برای اینکه فرانت مجبور به parse کردن متن `npkRatio` نباشد، بک‌اند باید آنالیز را ساختارمند بفرستد.
### ماکرو
| فیلد | نوع | اجباری | توضیح |
|------|-----|--------|-------|
| `nutrient_analysis.macro[].key` | string | بله | کلید استاندارد مثل `n`, `p`, `k` |
| `nutrient_analysis.macro[].name` | string | بله | نام نمایشی فارسی |
| `nutrient_analysis.macro[].value` | number | بله | درصد عنصر |
| `nutrient_analysis.macro[].unit` | string | بله | معمولاً `percent` |
| `nutrient_analysis.macro[].description` | string | بهتر است | توضیح برای Bottom Sheet |
### ریزمغذی‌ها
| فیلد | نوع | اجباری | توضیح |
|------|-----|--------|-------|
| `nutrient_analysis.micro[].key` | string | بله | مثل `fe`, `zn`, `mn`, `b` |
| `nutrient_analysis.micro[].name` | string | بله | نام فارسی عنصر |
| `nutrient_analysis.micro[].value` | number | بله | درصد عنصر |
| `nutrient_analysis.micro[].unit` | string | بله | معمولاً `percent` |
| `nutrient_analysis.micro[].description` | string | بهتر است | توضیح برای Bottom Sheet |
---
## فیلدهای دستورالعمل مصرف
| فیلد | نوع | اجباری | توضیح |
|------|-----|--------|-------|
| `application_guide.safety_warning` | string | بله | متن هشدار ایمنی |
| `application_guide.steps[].step_number` | number | بله | شماره مرحله |
| `application_guide.steps[].title` | string | بله | عنوان مرحله |
| `application_guide.steps[].description` | string | بله | توضیح مرحله |
---
## فیلدهای کودهای جایگزین
| فیلد | نوع | اجباری | توضیح |
|------|-----|--------|-------|
| `alternative_recommendations[].fertilizer_code` | string | بله | شناسه یکتا |
| `alternative_recommendations[].fertilizer_name` | string | بله | نام کود جایگزین |
| `alternative_recommendations[].fertilizer_type` | string | بله | نوع کود |
| `alternative_recommendations[].usage_method` | string | بله | روش مصرف |
| `alternative_recommendations[].description` | string | بله | توضیح برای Bottom Sheet |
---
## فیلدهای لازم برای Bottom Sheet
برای باز شدن Bottom Sheet روی مواد غذایی و کودهای جایگزین، بهتر است توضیح آماده از بک‌اند بیاید.
| بخش | فیلد پیشنهادی |
|-----|---------------|
| مواد اصلی | `nutrient_analysis.macro[].description` |
| ریزمغذی‌ها | `nutrient_analysis.micro[].description` |
| کود جایگزین | `alternative_recommendations[].description` |
این باعث می‌شود فرانت مجبور نباشد متن‌های توضیحی hard-code کند.
---
## فرمت واحدها
پیشنهاد می‌شود بک‌اند از مقادیر استاندارد زیر استفاده کند:
### واحد مقدار کود
- `kg`
- `gram`
- `liter`
- `milliliter`
### واحد مساحت
- `hectare`
- `square_meter`
### واحد درصد عناصر
- `percent`
### واحد فاصله مصرف
- `day`
- `week`
---
## قوانین پیشنهادی برای بک‌اند
### 1) متن نمایشی و مقدار عددی را با هم برگردانید
اشتباه:
```json
{
"amountPerHectare": "150 kg/ha"
}
```
صحیح:
```json
{
"dosage": {
"base_amount_per_hectare": 150,
"base_amount_per_square_meter": 0.015,
"unit": "kg",
"label": "150 کیلوگرم در هکتار"
}
}
```
### 2) هیچ داده مهمی فقط داخل `reasoning` دفن نشود
مواردی مثل:
- درصد N, P, K
- درصد ریزمغذی‌ها
- مقدار مصرف پایه
- روش مصرف
باید فیلد مستقل داشته باشند، نه فقط متن آزاد.
### 3) نام مرحله رشد و نام محصول را هم برگردانید
تا Header صفحه بدون lookup اضافه ساخته شود.
---
## حداقل خروجی لازم برای نسخه فعلی فرانت
اگر بک‌اند بخواهد فقط حداقل داده‌ی لازم را برگرداند، این ساختار minimum پیشنهاد می‌شود:
```json
{
"status": "success",
"data": {
"crop": {
"id": "wheat",
"name": "گندم"
},
"growth_stage": {
"id": "flowering",
"name": "گلدهی"
},
"primary_recommendation": {
"fertilizer_name": "کود کامل 20-20-20",
"fertilizer_type": "NPK",
"npk_ratio": {
"n": 20,
"p": 20,
"k": 20,
"label": "20-20-20"
},
"application_method": {
"label": "محلول پاشی / آب آبیاری"
},
"application_interval": {
"label": "هر 14 روز"
},
"dosage": {
"base_amount_per_hectare": 150,
"base_amount_per_square_meter": 0.015,
"unit": "kg",
"label": "150 کیلوگرم در هکتار"
},
"reasoning": "توضیحات توصیه"
},
"nutrient_analysis": {
"macro": [
{ "key": "n", "name": "نیتروژن (N)", "value": 20, "unit": "percent" },
{ "key": "p", "name": "فسفر (P)", "value": 20, "unit": "percent" },
{ "key": "k", "name": "پتاسیم (K)", "value": 20, "unit": "percent" }
],
"micro": []
},
"application_guide": {
"safety_warning": "هشدار ایمنی",
"steps": [
{ "step_number": 1, "title": "آماده سازی", "description": "..." },
{ "step_number": 2, "title": "ترکیب", "description": "..." },
{ "step_number": 3, "title": "مصرف", "description": "..." }
]
},
"alternative_recommendations": []
}
}
```
---
## نتیجه نهایی
برای اینکه UI فعلی بدون parse کردن رشته‌ها و بدون hard-code اضافی درست کار کند، بک‌اند باید حداقل این موارد را صریح برگرداند:
1. نام کود
2. نوع کود
3. نسبت NPK به صورت عددی و متنی
4. روش مصرف
5. فاصله مصرف
6. مقدار پایه در هکتار
7. مقدار پایه در متر مربع
8. واحد مقدار مصرف
9. استدلال توصیه
10. آنالیز ماکرو و ریزمغذی‌ها به صورت ساختارمند
11. هشدار ایمنی
12. مراحل مصرف
13. کودهای جایگزین با توضیح
اگر خواستی، قدم بعدی می‌توانم همین فایل را به یک قرارداد نهایی هماهنگ با TypeScript interface های `src/libs/api/services/fertilizationRecommendationService.ts` هم تبدیل کنم.
@@ -4,11 +4,6 @@
*/ */
import { apiClient } from "../client"; import { apiClient } from "../client";
import type {
RecommendationTaskInitResponse,
RecommendationTaskStatusResponse,
} from "./recommendationTask";
import { normalizeRecommendationTaskStatus } from "./recommendationTask";
const PREFIX = "/api/fertilization-recommendation"; const PREFIX = "/api/fertilization-recommendation";
const RECOMMEND_PREFIX = "/api/fertilization"; const RECOMMEND_PREFIX = "/api/fertilization";
@@ -40,12 +35,95 @@ export interface FertilizationConfigResponse {
cropOptions: CropOption[]; cropOptions: CropOption[];
} }
export interface FertilizationPlan { export interface FertilizationNpkRatio {
npkRatio: string; n: number;
amountPerHectare: string; p: number;
applicationMethod: string; k: number;
applicationInterval: string; label: string;
}
export interface FertilizationApplicationMethod {
id: string;
label: string;
}
export interface FertilizationApplicationInterval {
label: string;
unit: string;
value: number;
}
export interface FertilizationDosage {
label: string;
unit: string;
calculation_basis: string;
base_amount_per_hectare: number;
base_amount_per_square_meter: number;
}
export interface FertilizationPrimaryRecommendation {
fertilizer_code: string;
fertilizer_name: string;
display_title: string;
fertilizer_type: string;
reasoning: string; reasoning: string;
summary: string;
npk_ratio: FertilizationNpkRatio;
application_method: FertilizationApplicationMethod;
application_interval: FertilizationApplicationInterval;
dosage: FertilizationDosage;
}
export interface FertilizationNutrientItem {
key: string;
name: string;
unit: string;
description: string;
value: number;
}
export interface FertilizationApplicationStep {
step_number: number;
title: string;
description: string;
}
export interface FertilizationApplicationGuide {
safety_warning: string;
steps: FertilizationApplicationStep[];
}
export interface FertilizationAlternativeRecommendation {
fertilizer_code: string;
fertilizer_name: string;
fertilizer_type: string;
usage_method: string;
description: string;
}
export interface FertilizationSection {
title: string;
icon: string;
type: string;
content?: string;
items?: string[];
applicationMethod?: string;
fertilizerType?: string;
validityPeriod?: string;
amount?: string;
expandableExplanation?: string;
timing?: string;
}
export interface FertilizationRecommendationResult {
primary_recommendation: FertilizationPrimaryRecommendation;
nutrient_analysis: {
macro: FertilizationNutrientItem[];
micro: FertilizationNutrientItem[];
};
application_guide: FertilizationApplicationGuide;
alternative_recommendations: FertilizationAlternativeRecommendation[];
sections: FertilizationSection[];
} }
export interface FertilizationRecommendPayload { export interface FertilizationRecommendPayload {
@@ -58,17 +136,9 @@ export interface FertilizationRecommendPayload {
waterEC?: string; waterEC?: string;
} }
export interface FertilizationRecommendationResult {
plan: FertilizationPlan;
status?: string;
}
export type FertilizationRecommendResponse =
| FertilizationRecommendationResult
| RecommendationTaskInitResponse;
interface ApiResponse<T> { interface ApiResponse<T> {
status: string; code: number;
msg: string;
data: T; data: T;
} }
@@ -77,26 +147,6 @@ async function unwrap<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
return res.data; return res.data;
} }
function normalizeTaskInitResponse(
task: RecommendationTaskInitResponse,
): RecommendationTaskInitResponse {
return {
...task,
status: normalizeRecommendationTaskStatus(task.status),
};
}
function normalizeRecommendationResult(
result: FertilizationRecommendationResult,
): FertilizationRecommendationResult {
return result.status
? {
...result,
status: normalizeRecommendationTaskStatus(result.status),
}
: result;
}
export const fertilizationRecommendationService = { export const fertilizationRecommendationService = {
getConfig(farmUuid: string): Promise<FertilizationConfigResponse> { getConfig(farmUuid: string): Promise<FertilizationConfigResponse> {
return unwrap( return unwrap(
@@ -108,39 +158,12 @@ export const fertilizationRecommendationService = {
recommend( recommend(
payload?: FertilizationRecommendPayload, payload?: FertilizationRecommendPayload,
): Promise<FertilizationRecommendResponse> { ): Promise<FertilizationRecommendationResult> {
return unwrap( return unwrap(
apiClient.post<ApiResponse<FertilizationRecommendResponse>>( apiClient.post<ApiResponse<FertilizationRecommendationResult>>(
`${RECOMMEND_PREFIX}/recommend/`, `${RECOMMEND_PREFIX}/recommend/`,
payload ?? {}, payload ?? {},
), ),
).then((response) =>
"task_id" in response
? normalizeTaskInitResponse(response)
: normalizeRecommendationResult(response),
); );
}, },
getRecommendStatus(
taskId: string,
farmUuid: string,
): Promise<
RecommendationTaskStatusResponse<FertilizationRecommendationResult>
> {
return unwrap(
apiClient.get<
ApiResponse<
RecommendationTaskStatusResponse<FertilizationRecommendationResult>
>
>(
`${PREFIX}/recommend/status/${taskId}/?farm_uuid=${encodeURIComponent(farmUuid)}`,
),
).then((response) => ({
...response,
status: normalizeRecommendationTaskStatus(response.status),
result: response.result
? normalizeRecommendationResult(response.result)
: undefined,
}));
},
}; };
@@ -1,24 +1,36 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Card from "@mui/material/Card"; 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 Chip from "@mui/material/Chip";
import Button from "@mui/material/Button";
import Collapse from "@mui/material/Collapse";
import CircularProgress from "@mui/material/CircularProgress"; import CircularProgress from "@mui/material/CircularProgress";
import Collapse from "@mui/material/Collapse";
import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import { useTheme, alpha } from "@mui/material/styles"; import LinearProgress from "@mui/material/LinearProgress";
import Paper from "@mui/material/Paper";
import Slider from "@mui/material/Slider";
import Step from "@mui/material/Step";
import StepContent from "@mui/material/StepContent";
import StepLabel from "@mui/material/StepLabel";
import Stepper from "@mui/material/Stepper";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import { alpha, useTheme } from "@mui/material/styles";
import { useFarmHub } from "@/hooks/useFarmHub"; import { useFarmHub } from "@/hooks/useFarmHub";
import type { import {
GrowthStage, fertilizationRecommendationService,
CropOption, type CropOption,
FertilizationPlan, type FertilizationAlternativeRecommendation,
type FertilizationNutrientItem,
type FertilizationRecommendationResult,
type GrowthStage,
} from "@/libs/api/services/fertilizationRecommendationService"; } 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"; import { selectedPlantsService } from "@/libs/api/services/selectedPlantsService";
const GROWTH_STAGE_LABELS: Record<string, string> = { const GROWTH_STAGE_LABELS: Record<string, string> = {
@@ -36,6 +48,7 @@ const PLANT_ICON_MAP: Record<string, string> = {
saffron: "tabler-flower-2", saffron: "tabler-flower-2",
canola: "tabler-leaf", canola: "tabler-leaf",
vegetables: "tabler-carrot", vegetables: "tabler-carrot",
cucumber: "tabler-leaf",
}; };
const GROWTH_STAGE_ICON_MAP: Record<string, string> = { const GROWTH_STAGE_ICON_MAP: Record<string, string> = {
@@ -51,14 +64,13 @@ const formatStageLabel = (stage: string) =>
stage stage
.split(/[_-]/) .split(/[_-]/)
.filter(Boolean) .filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1)) .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" "); .join(" ");
const getPlantIcon = (icon: string) => PLANT_ICON_MAP[icon] ?? "tabler-leaf"; const getPlantIcon = (icon: string) => PLANT_ICON_MAP[icon] ?? "tabler-leaf";
const getGrowthStageIcon = (stage: string) => const getGrowthStageIcon = (stage: string) =>
GROWTH_STAGE_ICON_MAP[stage] ?? "tabler-circle-dot"; GROWTH_STAGE_ICON_MAP[stage] ?? "tabler-circle-dot";
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" &&
error !== null && error !== null &&
@@ -67,6 +79,20 @@ const getErrorMessage = (error: unknown, fallback: string) =>
? error.message ? error.message
: fallback; : fallback;
const formatNumber = (value: number) =>
new Intl.NumberFormat("fa-IR", { maximumFractionDigits: 2 }).format(value);
const formatUnitLabel = (unit: string) => {
const normalized = unit.toLowerCase();
if (normalized === "kg") return "کیلوگرم";
if (normalized === "gram") return "گرم";
if (normalized === "liter") return "لیتر";
if (normalized === "milliliter") return "میلی لیتر";
return unit;
};
export default function SmartFertilizationRecommendation() { export default function SmartFertilizationRecommendation() {
const t = useTranslations("fertilization"); const t = useTranslations("fertilization");
const theme = useTheme(); const theme = useTheme();
@@ -76,20 +102,29 @@ 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 [growthStages, setGrowthStages] = useState<GrowthStage[]>([]); const [growthStages, setGrowthStages] = useState<GrowthStage[]>([]);
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 [growthStage, setGrowthStage] = useState<string>(""); const [growthStage, setGrowthStage] = useState("");
const [selectedCrop, setSelectedCrop] = useState<string | null>(null); const [selectedCrop, setSelectedCrop] = useState<string | null>(null);
const [plan, setPlan] = useState<FertilizationPlan | null>(null); const [recommendation, setRecommendation] =
useState<FertilizationRecommendationResult | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [requestError, setRequestError] = useState<string | null>(null); const [requestError, setRequestError] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null); const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [reasoningExpanded, setReasoningExpanded] = useState(false); const [reasoningExpanded, setReasoningExpanded] = useState(false);
const [area, setArea] = useState(1);
const [detailsSheet, setDetailsSheet] = useState({
isOpen: false,
title: "",
content: "",
type: "",
});
useEffect(() => { useEffect(() => {
setPlan(null); setRecommendation(null);
setRequestError(null); setRequestError(null);
setSelectedCrop(null); setSelectedCrop(null);
setGrowthStages([]); setGrowthStages([]);
@@ -123,75 +158,38 @@ export default function SmartFertilizationRecommendation() {
id: stage, id: stage,
icon: getGrowthStageIcon(stage), icon: getGrowthStageIcon(stage),
label: formatStageLabel(stage), label: formatStageLabel(stage),
})) ?? })) ?? [];
[];
setGrowthStages(stages); setGrowthStages(stages);
setGrowthStage(stages[0]?.id ?? ""); setGrowthStage(stages[0]?.id ?? "");
} }
}) })
.catch((err: { message?: string }) => { .catch((error: { message?: string }) => {
setConfigError(err?.message ?? "Failed to load plants"); setConfigError(error?.message ?? "Failed to load plants");
}) })
.finally(() => setConfigLoading(false)); .finally(() => setConfigLoading(false));
}, [farmUuid, t]); }, [farmUuid, t]);
const handleGenerate = async () => { const handleGenerate = async () => {
if (!selectedCrop || !farmUuid) return; if (!selectedCrop || !growthStage || !farmUuid) return;
setLoading(true); setLoading(true);
setPlan(null); setArea(1);
setRecommendation(null);
setRequestError(null); setRequestError(null);
setStatusMessage(t("generating")); setStatusMessage(t("generating"));
setReasoningExpanded(false); setReasoningExpanded(false);
try { try {
const recommendation = await fertilizationRecommendationService.recommend( const response = await fertilizationRecommendationService.recommend({
{
farm_uuid: farmUuid, farm_uuid: farmUuid,
crop_id: selectedCrop, crop_id: selectedCrop,
growth_stage: growthStage, growth_stage: growthStage,
}, });
);
if ("task_id" in recommendation) { setRecommendation(response);
let attempts = 0;
let taskStatus =
await fertilizationRecommendationService.getRecommendStatus(
recommendation.task_id,
farmUuid,
);
while (isRecommendationTaskRunning(taskStatus.status)) {
attempts += 1;
setStatusMessage(taskStatus.progress?.message ?? t("generating"));
if (attempts >= 20) {
throw new Error(t("errors.timeout"));
}
await sleep(1500);
taskStatus =
await fertilizationRecommendationService.getRecommendStatus(
recommendation.task_id,
farmUuid,
);
}
if (taskStatus.status === "failed" || !taskStatus.result?.plan) {
throw new Error(taskStatus.error ?? t("errors.generateFailed"));
}
setPlan(taskStatus.result.plan);
return;
}
if (!recommendation.plan) {
throw new Error(t("errors.generateFailed"));
}
setPlan(recommendation.plan);
} catch (error) { } catch (error) {
setPlan(null); setRecommendation(null);
setRequestError(getErrorMessage(error, t("errors.generateFailed"))); setRequestError(getErrorMessage(error, t("errors.generateFailed")));
} finally { } finally {
setLoading(false); setLoading(false);
@@ -199,7 +197,7 @@ export default function SmartFertilizationRecommendation() {
} }
}; };
const stageIndex = growthStages.findIndex((s) => s.id === growthStage); const stageIndex = growthStages.findIndex((stage) => stage.id === growthStage);
const selectedCropOption = const selectedCropOption =
cropOptions.find((option) => option.id === selectedCrop) ?? null; cropOptions.find((option) => option.id === selectedCrop) ?? null;
const selectedGrowthStage = const selectedGrowthStage =
@@ -208,6 +206,35 @@ export default function SmartFertilizationRecommendation() {
selectedGrowthStage?.label ?? formatStageLabel(growthStage) selectedGrowthStage?.label ?? formatStageLabel(growthStage)
}`; }`;
const primaryRecommendation = recommendation?.primary_recommendation ?? null;
const recommendationSection =
recommendation?.sections.find((section) => section.type === "recommendation") ??
null;
const warningSections =
recommendation?.sections.filter((section) => section.type === "warning") ?? [];
const fertilizerName = primaryRecommendation?.fertilizer_name ?? "";
const displayTitle =
primaryRecommendation?.display_title ?? recommendationSection?.title ?? fertilizerName;
const dosageUnit = formatUnitLabel(primaryRecommendation?.dosage.unit ?? "kg");
const baseAmountPerHectare =
primaryRecommendation?.dosage.base_amount_per_hectare ?? 0;
const baseAmountPerSquareMeter =
primaryRecommendation?.dosage.base_amount_per_square_meter ?? 0;
const totalAmount = baseAmountPerHectare * area;
const macroNutrients = recommendation?.nutrient_analysis.macro ?? [];
const microNutrients = recommendation?.nutrient_analysis.micro ?? [];
const applicationSteps = recommendation?.application_guide.steps ?? [];
const alternativeFertilizers = recommendation?.alternative_recommendations ?? [];
const warningMessages = useMemo(() => {
const items = [
recommendation?.application_guide.safety_warning,
...warningSections.map((section) => section.content),
].filter((item): item is string => Boolean(item));
return Array.from(new Set(items));
}, [recommendation?.application_guide.safety_warning, warningSections]);
const handleCropSelect = (crop: CropOption) => { const handleCropSelect = (crop: CropOption) => {
setSelectedCrop((prev) => { setSelectedCrop((prev) => {
const nextCrop = prev === crop.id ? null : crop.id; const nextCrop = prev === crop.id ? null : crop.id;
@@ -222,16 +249,52 @@ export default function SmartFertilizationRecommendation() {
setGrowthStages(nextStages); setGrowthStages(nextStages);
setGrowthStage(nextStages[0]?.id ?? ""); setGrowthStage(nextStages[0]?.id ?? "");
setRecommendation(null);
return nextCrop; return nextCrop;
}); });
}; };
const handleBackToForm = () => { const handleBackToForm = () => {
setPlan(null); setRecommendation(null);
setArea(1);
setReasoningExpanded(false); setReasoningExpanded(false);
}; };
const handleAreaInputChange = (value: string) => {
if (value === "") {
setArea(0.5);
return;
}
const nextValue = Number(value);
if (Number.isNaN(nextValue)) return;
setArea(Math.min(100, Math.max(0.5, nextValue)));
};
const openNutrientDetails = (item: FertilizationNutrientItem) => {
setDetailsSheet({
isOpen: true,
title: item.name,
content: item.description,
type: "nutrient",
});
};
const openAlternativeDetails = (item: FertilizationAlternativeRecommendation) => {
setDetailsSheet({
isOpen: true,
title: item.fertilizer_name,
content: item.description,
type: "alternative",
});
};
const closeDetailsSheet = () => {
setDetailsSheet((prev) => ({ ...prev, isOpen: false }));
};
return ( return (
<Box <Box
className="min-bs-screen" className="min-bs-screen"
@@ -241,10 +304,8 @@ export default function SmartFertilizationRecommendation() {
minHeight: "100vh", minHeight: "100vh",
}} }}
> >
<Box <Box className={`mx-auto px-4 ${recommendation ? "py-0 sm:py-0" : "py-6 sm:py-8"}`}>
className={`max-w-lg mx-auto px-4 ${plan ? "py-0 sm:py-0" : "py-6 sm:py-8"}`} {recommendation ? (
>
{plan ? (
<Box className="animate-fade-in"> <Box className="animate-fade-in">
<Box <Box
position="sticky" position="sticky"
@@ -266,10 +327,7 @@ export default function SmartFertilizationRecommendation() {
borderRadius: "16px", borderRadius: "16px",
}} }}
> >
<i <i className="tabler-arrow-right text-xl" style={{ color: primaryMain }} />
className="tabler-arrow-right text-xl"
style={{ color: primaryMain }}
/>
</IconButton> </IconButton>
<Typography variant="subtitle1" fontWeight="bold"> <Typography variant="subtitle1" fontWeight="bold">
{resultContext} {resultContext}
@@ -277,7 +335,231 @@ export default function SmartFertilizationRecommendation() {
</Box> </Box>
</Box> </Box>
<Box className="pb-28"> <Box className="space-y-5 pb-28">
<Box className="grid grid-cols-12 gap-5 items-start">
<Box className="col-span-12 lg:col-span-6">
<Card
elevation={0}
sx={{
borderRadius: "32px",
background: `linear-gradient(145deg, ${alpha(primaryLight, 0.42)} 0%, ${alpha(primaryMain, 0.2)} 55%, ${paperBg} 100%)`,
boxShadow: `0 12px 40px ${alpha(primaryMain, 0.16)}, 0 4px 18px ${alpha(primaryMain, 0.08)}`,
border: `1px solid ${alpha(primaryMain, 0.16)}`,
overflow: "hidden",
}}
>
<CardContent className="p-6">
<Box className="flex items-start justify-between gap-3">
<Box>
<Typography variant="overline" color="primary.main" fontWeight={700}>
{displayTitle}
</Typography>
<Typography variant="h5" fontWeight={800} color="text.primary">
{fertilizerName}
</Typography>
<Typography
variant="h6"
fontWeight={700}
sx={{ color: primaryDark }}
className="mt-2"
>
NPK: {primaryRecommendation?.npk_ratio.label}
</Typography>
</Box>
<Box
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl"
sx={{ backgroundColor: alpha(primaryMain, 0.12) }}
>
<i className="tabler-atom-2 text-2xl" style={{ color: primaryMain }} />
</Box>
</Box>
<Box className="mt-4 flex flex-wrap items-center gap-3">
<Chip
label={`روش مصرف: ${primaryRecommendation?.application_method.label ?? "-"}`}
className="rounded-2xl"
sx={{
bgcolor: alpha(primaryMain, 0.12),
color: primaryDark,
fontWeight: 700,
border: `1px solid ${alpha(primaryMain, 0.14)}`,
}}
/>
<Typography variant="body1" fontWeight={600} color="text.secondary">
مقدار پایه: {primaryRecommendation?.dosage.label ?? "-"}
</Typography>
</Box>
<Box
className="mt-5 rounded-[24px] p-4"
sx={{
backgroundColor: alpha(theme.palette.background.paper, 0.72),
border: `1px solid ${alpha(primaryMain, 0.12)}`,
backdropFilter: "blur(10px)",
}}
>
<Box className="flex items-center justify-between gap-3">
<Typography variant="subtitle1" fontWeight={700} color="text.primary">
محاسبه مقدار برای مساحت مزرعه
</Typography>
<Typography variant="body2" color="text.secondary">
هکتار
</Typography>
</Box>
<Box className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1fr)_120px] sm:items-center">
<Slider
value={area}
min={0.5}
max={100}
step={0.5}
onChange={(_, value) => setArea(value as number)}
valueLabelDisplay="auto"
sx={{
color: primaryMain,
"& .MuiSlider-valueLabel": {
backgroundColor: primaryDark,
borderRadius: "10px",
},
}}
/>
<TextField
type="number"
value={area}
onChange={(event) => handleAreaInputChange(event.target.value)}
inputProps={{ min: 0.5, max: 100, step: 0.5 }}
label="مساحت"
className="rounded-2xl"
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: "18px",
backgroundColor: alpha(theme.palette.background.paper, 0.88),
},
}}
/>
</Box>
<Box
className="mt-5 rounded-[20px] p-4"
sx={{ backgroundColor: alpha(primaryMain, 0.08) }}
>
<Typography variant="body2" color="text.secondary">
نیاز کل مزرعه شما:
</Typography>
<Typography variant="h4" fontWeight={800} sx={{ color: primaryMain }}>
{formatNumber(totalAmount)} {dosageUnit}
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
بر پایه {formatNumber(baseAmountPerHectare)} {dosageUnit} در هر هکتار
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
معادل {formatNumber(baseAmountPerSquareMeter)} {dosageUnit} در هر متر مربع
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Box>
<Box className="col-span-12 lg:col-span-6">
<Box className="mt-2 lg:mt-0">
<Typography variant="h6" fontWeight={700} className="mb-4 mt-2 lg:mt-0">
آنالیز ترکیبات
</Typography>
<Paper
elevation={0}
className="rounded-[24px] border p-4"
sx={{
borderColor: alpha(primaryMain, 0.14),
background: `linear-gradient(180deg, ${alpha(theme.palette.background.paper, 0.96)} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
}}
>
<Box className="flex flex-col gap-y-4">
{macroNutrients.map((item) => (
<Box
key={item.key}
className="space-y-2 cursor-pointer rounded-2xl px-2 py-2 transition-colors"
onClick={() => openNutrientDetails(item)}
sx={{
"&:hover": {
backgroundColor: alpha(primaryMain, 0.04),
},
}}
>
<Box className="flex items-center justify-between gap-3">
<Typography variant="body2" fontWeight={600} color="text.primary">
{item.name}
</Typography>
<Typography variant="body2" fontWeight={700} color="text.secondary">
{formatNumber(item.value)}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={Math.min(item.value, 100)}
color={
item.key === "p"
? "secondary"
: item.key === "k"
? "success"
: "primary"
}
sx={{
height: 10,
borderRadius: "999px",
backgroundColor: alpha(primaryMain, 0.08),
}}
/>
</Box>
))}
</Box>
<Box className="mt-5">
<Typography
variant="subtitle2"
fontWeight={700}
color="text.secondary"
className="mb-3"
>
ریزمغذی ها
</Typography>
<Box className="flex flex-wrap gap-2">
{microNutrients.length ? (
microNutrients.map((item) => (
<Chip
key={item.key}
label={`${item.name}: ${formatNumber(item.value)}٪`}
variant="outlined"
className="rounded-2xl"
sx={{
borderColor: alpha(primaryMain, 0.16),
color: "text.secondary",
backgroundColor: alpha(primaryMain, 0.03),
}}
onClick={() => openNutrientDetails(item)}
/>
))
) : (
<Chip
label="ریزمغذی مشخصی در نسخه ثبت نشده است"
variant="outlined"
className="rounded-2xl"
sx={{
borderColor: alpha(primaryMain, 0.16),
color: "text.secondary",
backgroundColor: alpha(primaryMain, 0.03),
}}
/>
)}
</Box>
</Box>
</Paper>
</Box>
</Box>
</Box>
<Box className="grid grid-cols-12 gap-5 items-start">
<Box className="col-span-12 lg:col-span-6">
<Card <Card
elevation={0} elevation={0}
sx={{ sx={{
@@ -290,10 +572,7 @@ export default function SmartFertilizationRecommendation() {
> >
<CardContent className="p-6"> <CardContent className="p-6">
<Box className="flex items-center gap-2 mbe-5"> <Box className="flex items-center gap-2 mbe-5">
<i <i className="tabler-prescription text-2xl" style={{ color: primaryMain }} />
className="tabler-prescription text-2xl"
style={{ color: primaryMain }}
/>
<Typography variant="h6" fontWeight={700} color="text.primary"> <Typography variant="h6" fontWeight={700} color="text.primary">
{t("result.title")} {t("result.title")}
</Typography> </Typography>
@@ -303,25 +582,35 @@ export default function SmartFertilizationRecommendation() {
<PrescriptionRow <PrescriptionRow
icon="tabler-atom-2" icon="tabler-atom-2"
label={t("result.fertilizerType")} label={t("result.fertilizerType")}
value={plan.npkRatio} value={primaryRecommendation?.npk_ratio.label ?? "-"}
/> />
<PrescriptionRow <PrescriptionRow
icon="tabler-scale" icon="tabler-scale"
label={t("result.amountPerHectare")} label={t("result.amountPerHectare")}
value={plan.amountPerHectare} value={primaryRecommendation?.dosage.label ?? "-"}
/> />
<PrescriptionRow <PrescriptionRow
icon="tabler-spray" icon="tabler-spray"
label={t("result.applicationMethod")} label={t("result.applicationMethod")}
value={plan.applicationMethod} value={primaryRecommendation?.application_method.label ?? "-"}
/> />
<PrescriptionRow <PrescriptionRow
icon="tabler-calendar-repeat" icon="tabler-calendar-repeat"
label={t("result.applicationInterval")} label={t("result.applicationInterval")}
value={plan.applicationInterval} value={primaryRecommendation?.application_interval.label ?? "-"}
/> />
</Box> </Box>
{recommendationSection?.validityPeriod && (
<Box className="mt-3">
<PrescriptionRow
icon="tabler-clock-hour-4"
label="بازه اعتبار"
value={recommendationSection.validityPeriod}
/>
</Box>
)}
<Box <Box
className="mt-5 rounded-2xl overflow-hidden transition-all duration-300" className="mt-5 rounded-2xl overflow-hidden transition-all duration-300"
sx={{ sx={{
@@ -337,15 +626,8 @@ export default function SmartFertilizationRecommendation() {
sx={{ "&:hover": { bgcolor: alpha(primaryMain, 0.06) } }} sx={{ "&:hover": { bgcolor: alpha(primaryMain, 0.06) } }}
> >
<Box className="flex items-center gap-2"> <Box className="flex items-center gap-2">
<i <i className="tabler-brain text-lg" style={{ color: primaryMain }} />
className="tabler-brain text-lg" <Typography variant="subtitle2" fontWeight={600} color="text.primary">
style={{ color: primaryMain }}
/>
<Typography
variant="subtitle2"
fontWeight={600}
color="text.primary"
>
{t("result.whyRecommendation")} {t("result.whyRecommendation")}
</Typography> </Typography>
</Box> </Box>
@@ -358,12 +640,8 @@ export default function SmartFertilizationRecommendation() {
</Box> </Box>
<Collapse in={reasoningExpanded}> <Collapse in={reasoningExpanded}>
<Box className="px-4 pb-4"> <Box className="px-4 pb-4">
<Typography <Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.7 }}>
variant="body2" {primaryRecommendation?.reasoning}
color="text.secondary"
sx={{ lineHeight: 1.7 }}
>
{plan.reasoning}
</Typography> </Typography>
</Box> </Box>
</Collapse> </Collapse>
@@ -372,6 +650,168 @@ export default function SmartFertilizationRecommendation() {
</Card> </Card>
</Box> </Box>
<Box className="col-span-12 lg:col-span-6">
<Box className="mt-6 lg:mt-0">
<Typography variant="h6" fontWeight={700} className="mb-4 lg:mt-0">
مراحل و دستورالعمل مصرف
</Typography>
<Paper
elevation={0}
className="rounded-[24px] border p-4"
sx={{
borderColor: alpha(primaryMain, 0.14),
background: `linear-gradient(180deg, ${alpha(theme.palette.background.paper, 0.98)} 0%, ${alpha(primaryMain, 0.03)} 100%)`,
}}
>
<Stepper
orientation="vertical"
activeStep={applicationSteps.length}
sx={{
"& .MuiStepConnector-line": {
borderColor: alpha(primaryMain, 0.18),
minHeight: 24,
},
"& .MuiStepLabel-iconContainer": {
paddingInlineEnd: 1.5,
},
"& .MuiStepLabel-label": {
fontWeight: 700,
color: theme.palette.text.primary,
},
"& .MuiStepIcon-root": {
color: alpha(primaryMain, 0.22),
},
"& .MuiStepIcon-root.Mui-active, & .MuiStepIcon-root.Mui-completed": {
color: primaryMain,
},
}}
>
{applicationSteps.map((item, index) => (
<Step key={item.step_number} expanded active completed={index < applicationSteps.length}>
<StepLabel>{item.title}</StepLabel>
<StepContent>
<Typography
variant="body2"
color="text.secondary"
className="pb-2 text-sm leading-7"
>
{item.description}
</Typography>
</StepContent>
</Step>
))}
</Stepper>
</Paper>
</Box>
</Box>
</Box>
{warningMessages.length > 0 && (
<Box className="mt-6">
<Typography variant="h6" fontWeight={700} className="mb-4">
هشدارها و نکات مهم
</Typography>
<Box className="grid grid-cols-1 gap-3">
{warningMessages.map((warning, index) => (
<Alert
key={`${warning}-${index}`}
severity="warning"
className="rounded-[20px]"
sx={{
alignItems: "flex-start",
borderRadius: "20px",
border: `1px solid ${alpha(theme.palette.warning.main, 0.18)}`,
backgroundColor: alpha(theme.palette.warning.main, 0.1),
"& .MuiAlert-message": {
width: "100%",
},
}}
>
{warning}
</Alert>
))}
</Box>
</Box>
)}
<Box className="mt-8">
<Typography variant="h6" fontWeight={700}>
کودهای جایگزین
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1 mb-4">
در صورت عدم دسترسی به پیشنهاد اصلی، می توانید از موارد زیر استفاده کنید:
</Typography>
<Box
className="flex gap-4 overflow-x-auto pb-4 snap-x snap-mandatory"
sx={{
scrollBehavior: "smooth",
scrollbarWidth: "none",
msOverflowStyle: "none",
"&::-webkit-scrollbar": {
display: "none",
},
}}
>
{alternativeFertilizers.map((item) => (
<Card
key={item.fertilizer_code}
elevation={0}
className="min-w-[220px] snap-center rounded-[20px]"
onClick={() => openAlternativeDetails(item)}
sx={{
border: `1px solid ${alpha(primaryMain, 0.12)}`,
background: `linear-gradient(180deg, ${alpha(theme.palette.background.paper, 0.98)} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
boxShadow: `0 6px 18px ${alpha(primaryMain, 0.08)}`,
cursor: "pointer",
}}
>
<CardContent className="flex h-full flex-col p-4">
<Box className="mb-4 flex items-center justify-between gap-3">
<Box
className="flex h-10 w-10 items-center justify-center rounded-2xl"
sx={{ backgroundColor: alpha(primaryMain, 0.1) }}
>
<i className="tabler-flask text-xl" style={{ color: primaryMain }} />
</Box>
<Chip
size="small"
label={item.fertilizer_type}
className="rounded-xl"
sx={{
bgcolor: alpha(primaryMain, 0.1),
color: primaryDark,
fontWeight: 700,
}}
/>
</Box>
<Typography variant="subtitle1" fontWeight="bold" color="text.primary">
{item.fertilizer_name}
</Typography>
<Typography variant="caption" color="text.secondary" className="mt-2 block text-xs">
روش مصرف: {item.usage_method}
</Typography>
<Button
variant="outlined"
size="small"
className="mt-4 self-start rounded-xl"
sx={{
borderColor: alpha(primaryMain, 0.22),
color: primaryMain,
}}
>
انتخاب این مورد
</Button>
</CardContent>
</Card>
))}
</Box>
</Box>
</Box>
<Box <Box
position="fixed" position="fixed"
bottom={0} bottom={0}
@@ -431,23 +871,14 @@ export default function SmartFertilizationRecommendation() {
> >
{t("title")} {t("title")}
</Typography> </Typography>
<Typography <Typography variant="body2" color="text.secondary" className="mt-1 transition-colors duration-300">
variant="body2"
color="text.secondary"
className="mt-1 transition-colors duration-300"
>
{t("subtitle")} {t("subtitle")}
</Typography> </Typography>
</Box> </Box>
{!!growthStages.length && ( {!!growthStages.length && (
<> <>
<Typography <Typography variant="subtitle2" fontWeight={600} color="text.secondary" className="mbe-3">
variant="subtitle2"
fontWeight={600}
color="text.secondary"
className="mbe-3"
>
{t("growthStage.title")} {t("growthStage.title")}
</Typography> </Typography>
<Box className="flex gap-2 mb-6 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-hide"> <Box className="flex gap-2 mb-6 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-hide">
@@ -510,12 +941,7 @@ export default function SmartFertilizationRecommendation() {
</> </>
)} )}
<Typography <Typography variant="subtitle2" fontWeight={600} color="text.secondary" className="mbe-3">
variant="subtitle2"
fontWeight={600}
color="text.secondary"
className="mbe-3"
>
{t("plantSelection.title")} {t("plantSelection.title")}
</Typography> </Typography>
{configLoading ? ( {configLoading ? (
@@ -592,10 +1018,7 @@ export default function SmartFertilizationRecommendation() {
background: `linear-gradient(135deg, ${alpha(primaryMain, 0.15)} 0%, ${alpha(primaryMain, 0.08)} 100%)`, background: `linear-gradient(135deg, ${alpha(primaryMain, 0.15)} 0%, ${alpha(primaryMain, 0.08)} 100%)`,
}} }}
> >
<i <i className="tabler-sparkles text-2xl" style={{ color: primaryMain }} />
className="tabler-sparkles text-2xl"
style={{ color: primaryMain }}
/>
</Box> </Box>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{statusMessage ?? t("generating")} {statusMessage ?? t("generating")}
@@ -604,6 +1027,50 @@ export default function SmartFertilizationRecommendation() {
</Card> </Card>
)} )}
</Box> </Box>
<Drawer
anchor="bottom"
open={detailsSheet.isOpen}
onClose={closeDetailsSheet}
PaperProps={{
sx: {
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
p: 2,
maxHeight: "80vh",
zIndex: 1400,
},
}}
>
<Box className="w-10 h-1.5 bg-gray-300 rounded-full mx-auto mb-4" />
<Box className="flex items-center justify-between gap-3">
<Typography variant="h6" fontWeight="bold">
{detailsSheet.title}
</Typography>
<IconButton onClick={closeDetailsSheet}>
<i className="tabler-x text-xl" />
</IconButton>
</Box>
<Typography variant="body1" sx={{ mt: 2, color: "text.secondary", lineHeight: 1.8 }}>
{detailsSheet.content}
</Typography>
<Box className="mt-6">
<Button
fullWidth
variant="contained"
onClick={closeDetailsSheet}
sx={{
borderRadius: "16px",
backgroundColor: detailsSheet.type === "alternative" ? primaryDark : primaryMain,
"&:hover": {
backgroundColor: detailsSheet.type === "alternative" ? primaryMain : primaryDark,
},
}}
>
{detailsSheet.type === "alternative" ? "افزودن به یادآور" : "متوجه شدم"}
</Button>
</Box>
</Drawer>
</Box> </Box>
); );
} }
@@ -623,6 +1090,7 @@ function CropCard({
const primaryMain = theme.palette.primary.main; const primaryMain = theme.palette.primary.main;
const primaryDark = theme.palette.primary.dark; const primaryDark = theme.palette.primary.dark;
const paperBg = theme.palette.background.paper; const paperBg = theme.palette.background.paper;
return ( return (
<Card <Card
component="button" component="button"
@@ -659,18 +1127,11 @@ function CropCard({
style={!selected ? { color: primaryMain } : undefined} style={!selected ? { color: primaryMain } : undefined}
/> />
</Box> </Box>
<Typography <Typography variant="body2" fontWeight={600} color={selected ? "primary.main" : "text.primary"}>
variant="body2"
fontWeight={600}
color={selected ? "primary.main" : "text.primary"}
>
{label} {label}
</Typography> </Typography>
{selected && ( {selected && (
<i <i className="tabler-circle-check-filled text-xl ms-auto" style={{ color: primaryMain }} />
className="tabler-circle-check-filled text-xl ms-auto"
style={{ color: primaryMain }}
/>
)} )}
</Card> </Card>
); );
@@ -687,6 +1148,7 @@ function PrescriptionRow({
}) { }) {
const theme = useTheme(); const theme = useTheme();
const primaryMain = theme.palette.primary.main; const primaryMain = theme.palette.primary.main;
return ( return (
<Box <Box
className="flex items-center gap-4 p-3 rounded-2xl transition-colors duration-200" className="flex items-center gap-4 p-3 rounded-2xl transition-colors duration-200"
@@ -695,10 +1157,7 @@ function PrescriptionRow({
border: `1px solid ${alpha(primaryMain, 0.08)}`, border: `1px solid ${alpha(primaryMain, 0.08)}`,
}} }}
> >
<i <i className={`${icon} text-2xl shrink-0`} style={{ color: primaryMain }} />
className={`${icon} text-2xl shrink-0`}
style={{ color: primaryMain }}
/>
<Box className="flex-1 min-w-0"> <Box className="flex-1 min-w-0">
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{label} {label}