UPDATE
This commit is contained in:
@@ -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 type {
|
||||
RecommendationTaskInitResponse,
|
||||
RecommendationTaskStatusResponse,
|
||||
} from "./recommendationTask";
|
||||
import { normalizeRecommendationTaskStatus } from "./recommendationTask";
|
||||
|
||||
const PREFIX = "/api/fertilization-recommendation";
|
||||
const RECOMMEND_PREFIX = "/api/fertilization";
|
||||
@@ -40,12 +35,95 @@ export interface FertilizationConfigResponse {
|
||||
cropOptions: CropOption[];
|
||||
}
|
||||
|
||||
export interface FertilizationPlan {
|
||||
npkRatio: string;
|
||||
amountPerHectare: string;
|
||||
applicationMethod: string;
|
||||
applicationInterval: string;
|
||||
export interface FertilizationNpkRatio {
|
||||
n: number;
|
||||
p: number;
|
||||
k: number;
|
||||
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;
|
||||
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 {
|
||||
@@ -58,17 +136,9 @@ export interface FertilizationRecommendPayload {
|
||||
waterEC?: string;
|
||||
}
|
||||
|
||||
export interface FertilizationRecommendationResult {
|
||||
plan: FertilizationPlan;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export type FertilizationRecommendResponse =
|
||||
| FertilizationRecommendationResult
|
||||
| RecommendationTaskInitResponse;
|
||||
|
||||
interface ApiResponse<T> {
|
||||
status: string;
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
@@ -77,26 +147,6 @@ async function unwrap<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
|
||||
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 = {
|
||||
getConfig(farmUuid: string): Promise<FertilizationConfigResponse> {
|
||||
return unwrap(
|
||||
@@ -108,39 +158,12 @@ export const fertilizationRecommendationService = {
|
||||
|
||||
recommend(
|
||||
payload?: FertilizationRecommendPayload,
|
||||
): Promise<FertilizationRecommendResponse> {
|
||||
): Promise<FertilizationRecommendationResult> {
|
||||
return unwrap(
|
||||
apiClient.post<ApiResponse<FertilizationRecommendResponse>>(
|
||||
apiClient.post<ApiResponse<FertilizationRecommendationResult>>(
|
||||
`${RECOMMEND_PREFIX}/recommend/`,
|
||||
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,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
+592
-133
@@ -1,24 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Card from "@mui/material/Card";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Button from "@mui/material/Button";
|
||||
import Collapse from "@mui/material/Collapse";
|
||||
import Chip from "@mui/material/Chip";
|
||||
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 { 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 type {
|
||||
GrowthStage,
|
||||
CropOption,
|
||||
FertilizationPlan,
|
||||
import {
|
||||
fertilizationRecommendationService,
|
||||
type CropOption,
|
||||
type FertilizationAlternativeRecommendation,
|
||||
type FertilizationNutrientItem,
|
||||
type FertilizationRecommendationResult,
|
||||
type GrowthStage,
|
||||
} from "@/libs/api/services/fertilizationRecommendationService";
|
||||
import { fertilizationRecommendationService } from "@/libs/api/services/fertilizationRecommendationService";
|
||||
import { isRecommendationTaskRunning } from "@/libs/api/services/recommendationTask";
|
||||
import { selectedPlantsService } from "@/libs/api/services/selectedPlantsService";
|
||||
|
||||
const GROWTH_STAGE_LABELS: Record<string, string> = {
|
||||
@@ -36,6 +48,7 @@ const PLANT_ICON_MAP: Record<string, string> = {
|
||||
saffron: "tabler-flower-2",
|
||||
canola: "tabler-leaf",
|
||||
vegetables: "tabler-carrot",
|
||||
cucumber: "tabler-leaf",
|
||||
};
|
||||
|
||||
const GROWTH_STAGE_ICON_MAP: Record<string, string> = {
|
||||
@@ -51,14 +64,13 @@ const formatStageLabel = (stage: string) =>
|
||||
stage
|
||||
.split(/[_-]/)
|
||||
.filter(Boolean)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
|
||||
const getPlantIcon = (icon: string) => PLANT_ICON_MAP[icon] ?? "tabler-leaf";
|
||||
const getGrowthStageIcon = (stage: string) =>
|
||||
GROWTH_STAGE_ICON_MAP[stage] ?? "tabler-circle-dot";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const getErrorMessage = (error: unknown, fallback: string) =>
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
@@ -67,6 +79,20 @@ const getErrorMessage = (error: unknown, fallback: string) =>
|
||||
? error.message
|
||||
: 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() {
|
||||
const t = useTranslations("fertilization");
|
||||
const theme = useTheme();
|
||||
@@ -76,20 +102,29 @@ export default function SmartFertilizationRecommendation() {
|
||||
const primaryLight = theme.palette.primary.light;
|
||||
const primaryDark = theme.palette.primary.dark;
|
||||
const paperBg = theme.palette.background.paper;
|
||||
|
||||
const [growthStages, setGrowthStages] = useState<GrowthStage[]>([]);
|
||||
const [cropOptions, setCropOptions] = useState<CropOption[]>([]);
|
||||
const [configLoading, setConfigLoading] = useState(true);
|
||||
const [configError, setConfigError] = useState<string | null>(null);
|
||||
const [growthStage, setGrowthStage] = useState<string>("");
|
||||
const [growthStage, setGrowthStage] = useState("");
|
||||
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 [requestError, setRequestError] = useState<string | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [reasoningExpanded, setReasoningExpanded] = useState(false);
|
||||
const [area, setArea] = useState(1);
|
||||
const [detailsSheet, setDetailsSheet] = useState({
|
||||
isOpen: false,
|
||||
title: "",
|
||||
content: "",
|
||||
type: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPlan(null);
|
||||
setRecommendation(null);
|
||||
setRequestError(null);
|
||||
setSelectedCrop(null);
|
||||
setGrowthStages([]);
|
||||
@@ -123,75 +158,38 @@ export default function SmartFertilizationRecommendation() {
|
||||
id: stage,
|
||||
icon: getGrowthStageIcon(stage),
|
||||
label: formatStageLabel(stage),
|
||||
})) ??
|
||||
[];
|
||||
})) ?? [];
|
||||
|
||||
setGrowthStages(stages);
|
||||
setGrowthStage(stages[0]?.id ?? "");
|
||||
}
|
||||
})
|
||||
.catch((err: { message?: string }) => {
|
||||
setConfigError(err?.message ?? "Failed to load plants");
|
||||
.catch((error: { message?: string }) => {
|
||||
setConfigError(error?.message ?? "Failed to load plants");
|
||||
})
|
||||
.finally(() => setConfigLoading(false));
|
||||
}, [farmUuid, t]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedCrop || !farmUuid) return;
|
||||
if (!selectedCrop || !growthStage || !farmUuid) return;
|
||||
|
||||
setLoading(true);
|
||||
setPlan(null);
|
||||
setArea(1);
|
||||
setRecommendation(null);
|
||||
setRequestError(null);
|
||||
setStatusMessage(t("generating"));
|
||||
setReasoningExpanded(false);
|
||||
|
||||
try {
|
||||
const recommendation = await fertilizationRecommendationService.recommend(
|
||||
{
|
||||
const response = await fertilizationRecommendationService.recommend({
|
||||
farm_uuid: farmUuid,
|
||||
crop_id: selectedCrop,
|
||||
growth_stage: growthStage,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if ("task_id" in recommendation) {
|
||||
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);
|
||||
setRecommendation(response);
|
||||
} catch (error) {
|
||||
setPlan(null);
|
||||
setRecommendation(null);
|
||||
setRequestError(getErrorMessage(error, t("errors.generateFailed")));
|
||||
} finally {
|
||||
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 =
|
||||
cropOptions.find((option) => option.id === selectedCrop) ?? null;
|
||||
const selectedGrowthStage =
|
||||
@@ -208,6 +206,35 @@ export default function SmartFertilizationRecommendation() {
|
||||
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) => {
|
||||
setSelectedCrop((prev) => {
|
||||
const nextCrop = prev === crop.id ? null : crop.id;
|
||||
@@ -222,16 +249,52 @@ export default function SmartFertilizationRecommendation() {
|
||||
|
||||
setGrowthStages(nextStages);
|
||||
setGrowthStage(nextStages[0]?.id ?? "");
|
||||
setRecommendation(null);
|
||||
|
||||
return nextCrop;
|
||||
});
|
||||
};
|
||||
|
||||
const handleBackToForm = () => {
|
||||
setPlan(null);
|
||||
setRecommendation(null);
|
||||
setArea(1);
|
||||
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 (
|
||||
<Box
|
||||
className="min-bs-screen"
|
||||
@@ -241,10 +304,8 @@ export default function SmartFertilizationRecommendation() {
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className={`max-w-lg mx-auto px-4 ${plan ? "py-0 sm:py-0" : "py-6 sm:py-8"}`}
|
||||
>
|
||||
{plan ? (
|
||||
<Box className={`mx-auto px-4 ${recommendation ? "py-0 sm:py-0" : "py-6 sm:py-8"}`}>
|
||||
{recommendation ? (
|
||||
<Box className="animate-fade-in">
|
||||
<Box
|
||||
position="sticky"
|
||||
@@ -266,10 +327,7 @@ export default function SmartFertilizationRecommendation() {
|
||||
borderRadius: "16px",
|
||||
}}
|
||||
>
|
||||
<i
|
||||
className="tabler-arrow-right text-xl"
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
<i className="tabler-arrow-right text-xl" style={{ color: primaryMain }} />
|
||||
</IconButton>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
{resultContext}
|
||||
@@ -277,7 +335,231 @@ export default function SmartFertilizationRecommendation() {
|
||||
</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
|
||||
elevation={0}
|
||||
sx={{
|
||||
@@ -290,10 +572,7 @@ export default function SmartFertilizationRecommendation() {
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<Box className="flex items-center gap-2 mbe-5">
|
||||
<i
|
||||
className="tabler-prescription text-2xl"
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
<i className="tabler-prescription text-2xl" style={{ color: primaryMain }} />
|
||||
<Typography variant="h6" fontWeight={700} color="text.primary">
|
||||
{t("result.title")}
|
||||
</Typography>
|
||||
@@ -303,25 +582,35 @@ export default function SmartFertilizationRecommendation() {
|
||||
<PrescriptionRow
|
||||
icon="tabler-atom-2"
|
||||
label={t("result.fertilizerType")}
|
||||
value={plan.npkRatio}
|
||||
value={primaryRecommendation?.npk_ratio.label ?? "-"}
|
||||
/>
|
||||
<PrescriptionRow
|
||||
icon="tabler-scale"
|
||||
label={t("result.amountPerHectare")}
|
||||
value={plan.amountPerHectare}
|
||||
value={primaryRecommendation?.dosage.label ?? "-"}
|
||||
/>
|
||||
<PrescriptionRow
|
||||
icon="tabler-spray"
|
||||
label={t("result.applicationMethod")}
|
||||
value={plan.applicationMethod}
|
||||
value={primaryRecommendation?.application_method.label ?? "-"}
|
||||
/>
|
||||
<PrescriptionRow
|
||||
icon="tabler-calendar-repeat"
|
||||
label={t("result.applicationInterval")}
|
||||
value={plan.applicationInterval}
|
||||
value={primaryRecommendation?.application_interval.label ?? "-"}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{recommendationSection?.validityPeriod && (
|
||||
<Box className="mt-3">
|
||||
<PrescriptionRow
|
||||
icon="tabler-clock-hour-4"
|
||||
label="بازه اعتبار"
|
||||
value={recommendationSection.validityPeriod}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
className="mt-5 rounded-2xl overflow-hidden transition-all duration-300"
|
||||
sx={{
|
||||
@@ -337,15 +626,8 @@ export default function SmartFertilizationRecommendation() {
|
||||
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"
|
||||
>
|
||||
<i className="tabler-brain text-lg" style={{ color: primaryMain }} />
|
||||
<Typography variant="subtitle2" fontWeight={600} color="text.primary">
|
||||
{t("result.whyRecommendation")}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -358,12 +640,8 @@ export default function SmartFertilizationRecommendation() {
|
||||
</Box>
|
||||
<Collapse in={reasoningExpanded}>
|
||||
<Box className="px-4 pb-4">
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ lineHeight: 1.7 }}
|
||||
>
|
||||
{plan.reasoning}
|
||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.7 }}>
|
||||
{primaryRecommendation?.reasoning}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
@@ -372,6 +650,168 @@ export default function SmartFertilizationRecommendation() {
|
||||
</Card>
|
||||
</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
|
||||
position="fixed"
|
||||
bottom={0}
|
||||
@@ -431,23 +871,14 @@ export default function SmartFertilizationRecommendation() {
|
||||
>
|
||||
{t("title")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
className="mt-1 transition-colors duration-300"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
@@ -510,12 +941,7 @@ export default function SmartFertilizationRecommendation() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={600}
|
||||
color="text.secondary"
|
||||
className="mbe-3"
|
||||
>
|
||||
<Typography variant="subtitle2" fontWeight={600} color="text.secondary" className="mbe-3">
|
||||
{t("plantSelection.title")}
|
||||
</Typography>
|
||||
{configLoading ? (
|
||||
@@ -592,10 +1018,7 @@ export default function SmartFertilizationRecommendation() {
|
||||
background: `linear-gradient(135deg, ${alpha(primaryMain, 0.15)} 0%, ${alpha(primaryMain, 0.08)} 100%)`,
|
||||
}}
|
||||
>
|
||||
<i
|
||||
className="tabler-sparkles text-2xl"
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
<i className="tabler-sparkles text-2xl" style={{ color: primaryMain }} />
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{statusMessage ?? t("generating")}
|
||||
@@ -604,6 +1027,50 @@ export default function SmartFertilizationRecommendation() {
|
||||
</Card>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -623,6 +1090,7 @@ function CropCard({
|
||||
const primaryMain = theme.palette.primary.main;
|
||||
const primaryDark = theme.palette.primary.dark;
|
||||
const paperBg = theme.palette.background.paper;
|
||||
|
||||
return (
|
||||
<Card
|
||||
component="button"
|
||||
@@ -659,18 +1127,11 @@ function CropCard({
|
||||
style={!selected ? { color: primaryMain } : undefined}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={600}
|
||||
color={selected ? "primary.main" : "text.primary"}
|
||||
>
|
||||
<Typography variant="body2" fontWeight={600} color={selected ? "primary.main" : "text.primary"}>
|
||||
{label}
|
||||
</Typography>
|
||||
{selected && (
|
||||
<i
|
||||
className="tabler-circle-check-filled text-xl ms-auto"
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
<i className="tabler-circle-check-filled text-xl ms-auto" style={{ color: primaryMain }} />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
@@ -687,6 +1148,7 @@ function PrescriptionRow({
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const primaryMain = theme.palette.primary.main;
|
||||
|
||||
return (
|
||||
<Box
|
||||
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)}`,
|
||||
}}
|
||||
>
|
||||
<i
|
||||
className={`${icon} text-2xl shrink-0`}
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
<i className={`${icon} text-2xl shrink-0`} style={{ color: primaryMain }} />
|
||||
<Box className="flex-1 min-w-0">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{label}
|
||||
|
||||
Reference in New Issue
Block a user