UPDATE
This commit is contained in:
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM mirror.cdn.ir/node:20-bookworm-slim AS base
|
||||
FROM docker.iranserver.com/node:20-bookworm-slim AS base
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
@@ -18,7 +18,7 @@ RUN rm -f /etc/apt/sources.list /etc/apt/sources.list.d/* && \
|
||||
|
||||
# npm mirrors (Iranian)
|
||||
RUN npm config set registry https://package-mirror.liara.ir/repository/npm/ && \
|
||||
npm config set @runflare:registry https://mirror-npm.runflare.com/ && \
|
||||
# npm config set @runflare:registry https://mirror-npm.runflare.com/ && \
|
||||
npm config set @chabokan:registry https://mirror2.chabokan.net/npm/ && \
|
||||
npm config set strict-ssl false && \
|
||||
npm config set fetch-retries 5 && \
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
# Crop Zoning Backend Handoff
|
||||
|
||||
این سند برای انتقال منطق موجود در `src/views/dashboards/farm/cropZoning/cropZoningUtils.ts` از فرانتاند به بکاند تهیه شده است تا تیم بکاند بتواند APIهای لازم را بهصورت پایدار و قابل توسعه پیادهسازی کند.
|
||||
|
||||
## هدف
|
||||
|
||||
منطق فعلی در فرانتاند دو کار اصلی انجام میدهد:
|
||||
|
||||
1. تولید گرید مربعی از روی یک پلیگان مزرعه
|
||||
2. اختصاص محصول پیشنهادی به هر زون بر اساس یک الگوریتم Rule-based موقت
|
||||
|
||||
هدف انتقال به بکاند این است که:
|
||||
|
||||
- تولید زونها در یک نقطه مرکزی و قابل اعتماد انجام شود
|
||||
- منطق پیشنهاد محصول از فرانت حذف شود
|
||||
- APIها قابل استفاده برای ذخیره، بازخوانی، و توسعه مدل تصمیمگیری واقعی باشند
|
||||
- در آینده بتوان الگوریتم Rule-based را با مدل دادهمحور یا ML جایگزین کرد بدون تغییر زیاد در فرانت
|
||||
|
||||
---
|
||||
|
||||
## منطق فعلی فرانت
|
||||
|
||||
فایل فعلی شامل دو تابع اصلی است:
|
||||
|
||||
### 1) `createGridFromPolygon`
|
||||
|
||||
ورودی:
|
||||
- یک `GeoJSON Feature<Polygon>`
|
||||
- مقدار `cellSideKm` با مقدار پیشفرض `0.15`
|
||||
|
||||
خروجی:
|
||||
- یک `FeatureCollection<Polygon>` شامل سلولهای مربعی داخل پلیگان
|
||||
- برای هر سلول فقط `properties.index` ثبت میشود
|
||||
|
||||
رفتار:
|
||||
- با استفاده از bounding box پلیگان، گرید مربعی ساخته میشود
|
||||
- فقط بخشهای داخل پلیگان نگه داشته میشوند (`mask`)
|
||||
- هر سلول یک اندیس ترتیبی میگیرد
|
||||
|
||||
### 2) `createZonedGrid`
|
||||
|
||||
ورودی:
|
||||
- یک `GeoJSON Feature<Polygon>`
|
||||
- مقدار `cellSideKm` با مقدار پیشفرض `0.15`
|
||||
|
||||
خروجی:
|
||||
- یک `FeatureCollection<Polygon, ZoneFeatureProperties>`
|
||||
|
||||
برای هر زون این فیلدها تولید میشوند:
|
||||
- `zoneId`
|
||||
- `crop`
|
||||
- `matchPercent`
|
||||
- `waterNeed`
|
||||
- `estimatedProfit`
|
||||
- `reason`
|
||||
- `criteria`
|
||||
|
||||
منطق تخصیص محصول فعلی از تابع `ruleBasedCropAssignment` میآید.
|
||||
|
||||
---
|
||||
|
||||
## جزئیات الگوریتم فعلی تخصیص محصول
|
||||
|
||||
الگوریتم فعلی موقت و نمایشی است و به داده واقعی مزرعه متصل نیست.
|
||||
|
||||
### ورودی الگوریتم
|
||||
- `index`: شماره زون
|
||||
- `coords`: مختصات پلیگان سلول
|
||||
|
||||
### نحوه محاسبه
|
||||
یک `seed` مصنوعی از این دادهها ساخته میشود:
|
||||
- اندیس زون
|
||||
- اولین latitude
|
||||
- اولین longitude
|
||||
|
||||
فرمول فعلی:
|
||||
- `seed = index * 7 + floor(lat * 100) + floor(lng * 100)`
|
||||
|
||||
### محصولات ممکن
|
||||
در وضعیت فعلی فقط این محصولات استفاده میشوند:
|
||||
- `wheat`
|
||||
- `canola`
|
||||
- `saffron`
|
||||
|
||||
### منطق انتخاب
|
||||
- محصول با `seed % numberOfCrops` انتخاب میشود
|
||||
- درصد تطابق (`matchPercent`) بین 60 تا 94 تولید میشود
|
||||
- `waterNeed`، `estimatedProfit` و `reason` از روی محصول انتخابی از یک map ثابت خوانده میشوند
|
||||
- `criteria` نیز بهصورت ساختگی از seed تولید میشود
|
||||
|
||||
### نکته مهم
|
||||
این الگوریتم:
|
||||
- deterministic است
|
||||
- اما علمی/عملیاتی نیست
|
||||
- صرفاً برای نمایش UI مناسب بوده
|
||||
- نباید به همان شکل نهایی در production باقی بماند مگر موقت و با برچسب mock/demo
|
||||
|
||||
---
|
||||
|
||||
## پیشنهاد معماری بکاند
|
||||
|
||||
بهتر است بکاند این منطق را در دو لایه جدا کند:
|
||||
|
||||
### لایه 1: Zone Generation
|
||||
مسئول تولید گرید از روی پلیگان مزرعه
|
||||
|
||||
### لایه 2: Crop Recommendation / Zone Evaluation
|
||||
مسئول تخصیص محصول و ویژگیهای تحلیلی به هر زون
|
||||
|
||||
این جداسازی مهم است چون در آینده ممکن است:
|
||||
- گرید ثابت بماند اما مدل پیشنهاد محصول عوض شود
|
||||
- یا زونها ذخیره شوند ولی recommendation دوباره محاسبه شود
|
||||
|
||||
---
|
||||
|
||||
## APIهای پیشنهادی
|
||||
|
||||
### API 1: Generate Initial Zones
|
||||
|
||||
**Purpose**
|
||||
تولید گرید اولیه از روی محدوده مزرعه
|
||||
|
||||
**Method**
|
||||
`POST /api/crop-zoning/zones/initial`
|
||||
|
||||
**Request Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"farm_id": 123,
|
||||
"cell_side_km": 0.15,
|
||||
"boundary": {
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[[51.0,35.0],[51.1,35.0],[51.1,35.1],[51.0,35.1],[51.0,35.0]]]
|
||||
},
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior**
|
||||
- اعتبارسنجی GeoJSON انجام شود
|
||||
- Polygon معتبر بودن بررسی شود
|
||||
- گرید مربعی با اندازه `cell_side_km` ساخته شود
|
||||
- فقط سلولهای داخل boundary برگردانده شوند
|
||||
- برای هر زون یک شناسه یکتا تولید شود
|
||||
- در صورت نیاز زونها در DB ذخیره شوند
|
||||
|
||||
**Response پیشنهادی**
|
||||
|
||||
```json
|
||||
{
|
||||
"farm_id": 123,
|
||||
"cell_side_km": 0.15,
|
||||
"zones": {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[[51.0,35.0],[51.01,35.0],[51.01,35.01],[51.0,35.01],[51.0,35.0]]]
|
||||
},
|
||||
"properties": {
|
||||
"zone_id": "zone-0",
|
||||
"index": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### API 2: Run Crop Recommendation For Zones
|
||||
|
||||
**Purpose**
|
||||
اختصاص محصول پیشنهادی و شاخصها به زونهای تولید شده
|
||||
|
||||
**Method**
|
||||
`POST /api/crop-zoning/zones/recommend`
|
||||
|
||||
**Request Body پیشنهادی**
|
||||
|
||||
```json
|
||||
{
|
||||
"farm_id": 123,
|
||||
"algorithm": "rule_based_v1",
|
||||
"zones": {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[[51.0,35.0],[51.01,35.0],[51.01,35.01],[51.0,35.01],[51.0,35.0]]]
|
||||
},
|
||||
"properties": {
|
||||
"zone_id": "zone-0",
|
||||
"index": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior**
|
||||
- اگر فعلاً داده واقعی وجود ندارد، همان الگوریتم موقت `rule_based_v1` پیادهسازی شود
|
||||
- خروجی برای هر زون شامل crop recommendation و تحلیلها باشد
|
||||
- اگر زونها از قبل در DB هستند، امکان ارسال فقط `farm_id` هم میتواند اضافه شود
|
||||
|
||||
**Response پیشنهادی**
|
||||
|
||||
```json
|
||||
{
|
||||
"farm_id": 123,
|
||||
"algorithm": "rule_based_v1",
|
||||
"zones": {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[[51.0,35.0],[51.01,35.0],[51.01,35.01],[51.0,35.01],[51.0,35.0]]]
|
||||
},
|
||||
"properties": {
|
||||
"zone_id": "zone-0",
|
||||
"index": 0,
|
||||
"crop": "wheat",
|
||||
"match_percent": 82,
|
||||
"water_need": "۴۵۰۰-۵۵۰۰ m³/ha",
|
||||
"estimated_profit": "۱۵-۲۵ میلیون/هکتار",
|
||||
"reason": "دمای مناسب، خاک حاصلخیز، دسترسی به آب کافی",
|
||||
"criteria": [
|
||||
{ "name": "دما", "value": 78 },
|
||||
{ "name": "بارش", "value": 66 },
|
||||
{ "name": "خاک", "value": 72 },
|
||||
{ "name": "آب", "value": 81 }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### API 3: Get Saved Zoning Result
|
||||
|
||||
**Purpose**
|
||||
دریافت آخرین نتیجه زونبندی ذخیره شده برای مزرعه
|
||||
|
||||
**Method**
|
||||
`GET /api/crop-zoning/farms/:farmId/zones`
|
||||
|
||||
**Use case**
|
||||
- هنگام ورود مجدد کاربر به صفحه
|
||||
- جلوگیری از محاسبه مجدد غیرضروری
|
||||
- نمایش نسخه ذخیرهشده recommendation
|
||||
|
||||
**Response**
|
||||
- همان ساختار `FeatureCollection` enriched شده با properties کامل هر زون
|
||||
|
||||
---
|
||||
|
||||
## ساختار داده پیشنهادی
|
||||
|
||||
### Zone entity
|
||||
|
||||
حداقل فیلدهای پیشنهادی برای هر زون:
|
||||
- `id`
|
||||
- `farm_id`
|
||||
- `zone_index`
|
||||
- `geometry` (GeoJSON Polygon یا نوع spatial معادل)
|
||||
- `cell_side_km`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
### Zone recommendation entity
|
||||
|
||||
اگر recommendation جدا ذخیره شود:
|
||||
- `id`
|
||||
- `zone_id`
|
||||
- `algorithm_version`
|
||||
- `crop`
|
||||
- `match_percent`
|
||||
- `water_need`
|
||||
- `estimated_profit`
|
||||
- `reason`
|
||||
- `criteria` (JSON)
|
||||
- `created_at`
|
||||
|
||||
اگر simplicity مهمتر است، میتوان recommendation را مستقیماً روی جدول zone نیز ذخیره کرد.
|
||||
|
||||
---
|
||||
|
||||
## قرارداد خروجی پیشنهادی برای فرانت
|
||||
|
||||
برای کم کردن تغییرات فرانت، بهتر است response نهایی نزدیک به ساختار فعلی باشد.
|
||||
|
||||
### ساختار هر feature
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": []
|
||||
},
|
||||
"properties": {
|
||||
"zone_id": "zone-0",
|
||||
"index": 0,
|
||||
"crop": "wheat",
|
||||
"match_percent": 82,
|
||||
"water_need": "۴۵۰۰-۵۵۰۰ m³/ha",
|
||||
"estimated_profit": "۱۵-۲۵ میلیون/هکتار",
|
||||
"reason": "...",
|
||||
"criteria": [
|
||||
{ "name": "دما", "value": 78 }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### نکته naming
|
||||
در فرانت فعلی بعضی فیلدها camelCase هستند:
|
||||
- `zoneId`
|
||||
- `matchPercent`
|
||||
- `waterNeed`
|
||||
- `estimatedProfit`
|
||||
|
||||
اگر بکاند snake_case برمیگرداند، فرانت باید mapper داشته باشد.
|
||||
برای سادهتر شدن integration یکی از این دو رویکرد انتخاب شود:
|
||||
|
||||
1. بکاند موقتاً همان naming فرانت را برگرداند
|
||||
2. یا فرانت یک adapter ثابت برای map کردن response داشته باشد
|
||||
|
||||
پیشنهاد بهتر:
|
||||
- بکاند از قرارداد استاندارد خودش مثل `snake_case` استفاده کند
|
||||
- فرانت mapper داشته باشد
|
||||
|
||||
---
|
||||
|
||||
## پیشنهاد برای نسخه اول پیادهسازی
|
||||
|
||||
برای فاز اول، این scope کافی است:
|
||||
|
||||
### فاز 1
|
||||
- تولید گرید از polygon در بکاند
|
||||
- پیادهسازی الگوریتم mock با نام `rule_based_v1`
|
||||
- برگرداندن FeatureCollection کامل
|
||||
- امکان ذخیره نتیجه برای هر farm
|
||||
|
||||
### فاز 2
|
||||
- استفاده از دادههای واقعی مثل:
|
||||
- نوع خاک
|
||||
- منابع آب
|
||||
- اقلیم
|
||||
- شیب زمین
|
||||
- سوابق کشت
|
||||
- نسخهبندی الگوریتم recommendation
|
||||
- بازمحاسبه recommendation بدون بازتولید geometry
|
||||
|
||||
---
|
||||
|
||||
## شبهکد بکاند برای الگوریتم موقت
|
||||
|
||||
```text
|
||||
for each zone in zones:
|
||||
coords = zone.geometry.coordinates
|
||||
lat = coords[0][0][1] or 35
|
||||
lng = coords[0][0][0] or 51
|
||||
seed = zone.index * 7 + floor(lat * 100) + floor(lng * 100)
|
||||
|
||||
crops = [wheat, canola, saffron]
|
||||
crop = crops[abs(seed) % len(crops)]
|
||||
match_percent = 60 + (abs(seed) % 35)
|
||||
|
||||
water_need = map by crop
|
||||
estimated_profit = map by crop
|
||||
reason = map by crop
|
||||
criteria = generated from seed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation requirements
|
||||
|
||||
بکاند بهتر است این موارد را validate کند:
|
||||
- `boundary.type == Feature`
|
||||
- `geometry.type == Polygon`
|
||||
- polygon ring بسته باشد
|
||||
- مختصات معتبر longitude/latitude باشند
|
||||
- `cell_side_km > 0`
|
||||
- تعداد زونها از یک سقف منطقی بیشتر نشود
|
||||
|
||||
### پیشنهاد محدودیت
|
||||
برای جلوگیری از payload سنگین:
|
||||
- حداقل `cell_side_km` تعریف شود
|
||||
- یا حداکثر تعداد زون مجاز تعریف شود
|
||||
- اگر مزرعه خیلی بزرگ باشد response مناسب خطا داده شود
|
||||
|
||||
---
|
||||
|
||||
## Error responses پیشنهادی
|
||||
|
||||
### 400 Bad Request
|
||||
برای:
|
||||
- GeoJSON نامعتبر
|
||||
- polygon نامعتبر
|
||||
- cell size نامعتبر
|
||||
|
||||
### 404 Not Found
|
||||
برای:
|
||||
- farm پیدا نشد
|
||||
|
||||
### 422 Unprocessable Entity
|
||||
برای:
|
||||
- boundary معتبر است ولی برای zoning قابل استفاده نیست
|
||||
|
||||
---
|
||||
|
||||
## چیزی که باید از فرانت حذف یا ساده شود
|
||||
|
||||
بعد از آماده شدن API بکاند، این بخشها از فرانت باید حذف/کم شوند:
|
||||
|
||||
- استفاده مستقیم از `createZonedGrid`
|
||||
- استفاده مستقیم از `ruleBasedCropAssignment`
|
||||
- تولید mock recommendation در کلاینت
|
||||
|
||||
و فرانت فقط این مسئولیتها را نگه میدارد:
|
||||
- ارسال boundary
|
||||
- دریافت FeatureCollection از API
|
||||
- نمایش زونها روی نقشه
|
||||
- نمایش جزئیات recommendation
|
||||
|
||||
---
|
||||
|
||||
## پیشنهاد نهایی برای تیم بکاند
|
||||
|
||||
اگر بخواهیم کمریسک و سریع جلو برویم:
|
||||
|
||||
1. ابتدا فقط endpoint تولید zone + recommendation را با الگوریتم mock فعلی بسازید
|
||||
2. response را GeoJSON-based نگه دارید
|
||||
3. شناسه و نسخه الگوریتم را در خروجی قرار دهید
|
||||
4. بعداً recommendation engine را از geometry generation جدا کنید
|
||||
|
||||
---
|
||||
|
||||
## خلاصه اجرایی
|
||||
|
||||
منطق فعلی فرانت یک implementation موقت برای:
|
||||
- ساخت grid از polygon مزرعه
|
||||
- اختصاص crop recommendation نمایشی به هر zone
|
||||
|
||||
پیشنهاد میشود بکاند:
|
||||
- این منطق را به API منتقل کند
|
||||
- geometry generation و recommendation را از هم جدا نگه دارد
|
||||
- در فاز اول همان رفتار deterministic فعلی را با نام `rule_based_v1` پیادهسازی کند
|
||||
- خروجی را بهصورت `GeoJSON FeatureCollection` برگرداند تا فرانت با کمترین تغییر از آن استفاده کند
|
||||
+34
-3
@@ -438,7 +438,6 @@
|
||||
"formWizard": "ویزارد فرم",
|
||||
"apexCharts": "چارت Apex",
|
||||
"analytics": "تحلیلها",
|
||||
"todo": "وظایف",
|
||||
"accountSettings": "تنظیمات حساب",
|
||||
"faq": "سوالات متداول",
|
||||
"pricing": "قیمتگذاری",
|
||||
@@ -532,7 +531,18 @@
|
||||
"cropZoning": {
|
||||
"title": "زونبندی پیشنهادی کشت",
|
||||
"drawLand": "زمین خود را روی نقشه با Polygon رسم کنید",
|
||||
"loadingArea": "در حال دریافت محدوده زمین...",
|
||||
"optimizeAgain": "بهینهسازی دوباره",
|
||||
"taskStatus": {
|
||||
"pending": "تسک در صف پردازش است...",
|
||||
"processing": "در حال پردازش محدوده زمین...",
|
||||
"completed": "پردازش محدوده زمین کامل شد.",
|
||||
"failed": "پردازش محدوده زمین ناموفق بود."
|
||||
},
|
||||
"errors": {
|
||||
"areaLoadFailed": "بارگذاری محدوده زمین با خطا مواجه شد.",
|
||||
"timeout": "دریافت نتیجه محدوده زمین بیش از حد طول کشید. دوباره تلاش کنید."
|
||||
},
|
||||
"layers": {
|
||||
"crops": "محصولات پیشنهادی",
|
||||
"waterNeed": "نیاز آبی",
|
||||
@@ -625,6 +635,10 @@
|
||||
},
|
||||
"generateCta": "تولید برنامه آبیاری",
|
||||
"generating": "در حال تحلیل و تولید برنامه آبیاری...",
|
||||
"errors": {
|
||||
"generateFailed": "دریافت برنامه آبیاری با خطا مواجه شد.",
|
||||
"timeout": "دریافت نتیجه بیش از حد طول کشید. دوباره تلاش کنید."
|
||||
},
|
||||
"result": {
|
||||
"moistureLevel": "سطح رطوبت هدف",
|
||||
"frequency": "تناوب آبیاری",
|
||||
@@ -632,7 +646,12 @@
|
||||
"duration": "مدت هر نوبت",
|
||||
"minutes": "دقیقه",
|
||||
"bestTime": "بهترین زمان آبیاری",
|
||||
"smartWarning": "هشدار هوشمند"
|
||||
"smartWarning": "هشدار هوشمند",
|
||||
"waterBalance": "جزئیات تراز آب",
|
||||
"forecastDate": "تاریخ پیشبینی",
|
||||
"grossIrrigation": "نیاز آبیاری ناخالص",
|
||||
"irrigationTiming": "زمان آبیاری",
|
||||
"activeKc": "ضریب رشد فعال"
|
||||
}
|
||||
},
|
||||
"fertilization": {
|
||||
@@ -666,6 +685,10 @@
|
||||
},
|
||||
"generateCta": "تولید برنامه کوددهی",
|
||||
"generating": "در حال تحلیل و تولید نسخه تغذیهای...",
|
||||
"errors": {
|
||||
"generateFailed": "دریافت برنامه کوددهی با خطا مواجه شد.",
|
||||
"timeout": "دریافت نتیجه بیش از حد طول کشید. دوباره تلاش کنید."
|
||||
},
|
||||
"result": {
|
||||
"title": "نسخه تغذیه گیاه",
|
||||
"fertilizerType": "نوع کود توصیهشده",
|
||||
@@ -737,7 +760,15 @@
|
||||
},
|
||||
"errors": {
|
||||
"contextLoad": "بارگذاری زمینه مزرعه ناموفق بود.",
|
||||
"chatSend": "ارسال پیام ناموفق بود. دوباره تلاش کنید."
|
||||
"chatSend": "ارسال پیام ناموفق بود. دوباره تلاش کنید.",
|
||||
"conversationLoad": "بارگذاری لیست مکالمات ناموفق بود.",
|
||||
"conversationCreate": "ایجاد چت جدید ناموفق بود."
|
||||
},
|
||||
"sidebar": {
|
||||
"title": "مکالمات",
|
||||
"newChat": "چت جدید",
|
||||
"empty": "هنوز مکالمهای ندارید",
|
||||
"chatLabel": "چت"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
# Recommend Task Status API Guide
|
||||
|
||||
این فایل برای تیم فرانتاند توضیح میدهد که برای ماژولهای `fertilization_recommendation` و `irrigation_recommendation` چه درخواستهایی باید به بکاند ارسال شود و چه پاسخهایی باید دریافت شود.
|
||||
|
||||
## Fertilization Recommendation
|
||||
|
||||
### 1) ثبت درخواست پیشنهاد
|
||||
|
||||
**Endpoint**
|
||||
|
||||
`POST /api/fertilization-recommendation/recommend/`
|
||||
|
||||
**Request Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"crop_id": "wheat",
|
||||
"growth_stage": "tillering",
|
||||
"farm_data": {
|
||||
"soilType": "loam",
|
||||
"organicMatter": "medium",
|
||||
"waterEC": "1.2"
|
||||
},
|
||||
"soilType": "loam",
|
||||
"organicMatter": "medium",
|
||||
"waterEC": "1.2"
|
||||
}
|
||||
```
|
||||
|
||||
**Field Description**
|
||||
|
||||
- `crop_id`: شناسه محصول
|
||||
- `growth_stage`: مرحله رشد محصول
|
||||
- `farm_data.soilType`: نوع خاک
|
||||
- `farm_data.organicMatter`: مقدار ماده آلی
|
||||
- `farm_data.waterEC`: EC آب
|
||||
- `soilType`, `organicMatter`, `waterEC`: همین دادهها اگر فرانت بخواهد به صورت flat هم ارسال کند
|
||||
|
||||
**Success Response**
|
||||
|
||||
اگر سرویس خارجی مستقیم نتیجه را برگرداند:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"plan": {
|
||||
"npkRatio": "20-20-20",
|
||||
"amountPerHectare": "150 kg/ha",
|
||||
"applicationMethod": "drip",
|
||||
"applicationInterval": "every 14 days",
|
||||
"reasoning": "balanced nutrition for current growth stage"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
اگر سرویس خارجی async باشد، معمولاً `task_id` برمیگرداند:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"task_id": "fert-task-123",
|
||||
"status": "pending"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2) دریافت وضعیت تسک
|
||||
|
||||
**Endpoint**
|
||||
|
||||
`GET /api/fertilization-recommendation/recommend/status/{task_id}/`
|
||||
|
||||
**Path Param**
|
||||
|
||||
- `task_id`: شناسه تسکی که از مرحله قبل گرفته شده
|
||||
|
||||
**Success Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"task_id": "fert-task-123",
|
||||
"status": "processing",
|
||||
"progress": {
|
||||
"message": "analyzing farm data"
|
||||
},
|
||||
"result": {
|
||||
"plan": {
|
||||
"npkRatio": "20-20-20",
|
||||
"amountPerHectare": "150 kg/ha",
|
||||
"applicationMethod": "drip",
|
||||
"applicationInterval": "every 14 days",
|
||||
"reasoning": "balanced nutrition for current growth stage"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Possible status values**
|
||||
|
||||
- `pending`
|
||||
- `processing`
|
||||
- `completed`
|
||||
- `failed`
|
||||
|
||||
---
|
||||
|
||||
## Irrigation Recommendation
|
||||
|
||||
### 1) ثبت درخواست پیشنهاد
|
||||
|
||||
**Endpoint**
|
||||
|
||||
`POST /api/irrigation-recommendation/recommend/`
|
||||
|
||||
**Request Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"crop_id": "wheat",
|
||||
"farm_data": {
|
||||
"soilType": "loam",
|
||||
"waterQuality": "good",
|
||||
"climateZone": "semi-arid"
|
||||
},
|
||||
"soilType": "loam",
|
||||
"waterQuality": "good",
|
||||
"climateZone": "semi-arid"
|
||||
}
|
||||
```
|
||||
|
||||
**Field Description**
|
||||
|
||||
- `crop_id`: شناسه محصول
|
||||
- `farm_data.soilType`: نوع خاک
|
||||
- `farm_data.waterQuality`: کیفیت آب
|
||||
- `farm_data.climateZone`: اقلیم
|
||||
- `soilType`, `waterQuality`, `climateZone`: همین دادهها در حالت flat
|
||||
|
||||
**Success Response**
|
||||
|
||||
حالت نتیجه مستقیم:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"plan": {
|
||||
"frequencyPerWeek": "3",
|
||||
"durationMinutes": "45",
|
||||
"bestTimeOfDay": "early morning",
|
||||
"moistureLevel": "optimal",
|
||||
"warning": "avoid irrigation during strong wind"
|
||||
},
|
||||
"raw_response": "...",
|
||||
"water_balance": {
|
||||
"daily": [
|
||||
{
|
||||
"forecast_date": "2025-03-28",
|
||||
"et0_mm": 4.1,
|
||||
"etc_mm": 3.8,
|
||||
"effective_rainfall_mm": 0.0,
|
||||
"gross_irrigation_mm": 3.8,
|
||||
"irrigation_timing": "06:00"
|
||||
}
|
||||
],
|
||||
"crop_profile": {
|
||||
"kc_initial": 0.7,
|
||||
"kc_mid": 1.05,
|
||||
"kc_end": 0.85
|
||||
},
|
||||
"active_kc": 1.05
|
||||
},
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
حالت async:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"task_id": "irr-task-123",
|
||||
"status": "pending"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2) دریافت وضعیت تسک
|
||||
|
||||
**Endpoint**
|
||||
|
||||
`GET /api/irrigation-recommendation/recommend/status/{task_id}/`
|
||||
|
||||
**Path Param**
|
||||
|
||||
- `task_id`: شناسه تسک
|
||||
|
||||
**Success Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"task_id": "irr-task-123",
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"plan": {
|
||||
"frequencyPerWeek": "3",
|
||||
"durationMinutes": "45",
|
||||
"bestTimeOfDay": "early morning",
|
||||
"moistureLevel": "optimal",
|
||||
"warning": "avoid irrigation during strong wind"
|
||||
},
|
||||
"raw_response": "...",
|
||||
"water_balance": {
|
||||
"daily": [],
|
||||
"crop_profile": {
|
||||
"kc_initial": 0.7,
|
||||
"kc_mid": 1.05,
|
||||
"kc_end": 0.85
|
||||
},
|
||||
"active_kc": 1.05
|
||||
},
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## پیشنهاد پیادهسازی در فرانت
|
||||
|
||||
### Fertilization
|
||||
|
||||
1. با `POST /recommend/` درخواست را ارسال کنید.
|
||||
2. اگر `data.task_id` برگشت، polling را با `GET /recommend/status/{task_id}/` شروع کنید.
|
||||
3. وقتی `data.status` به `completed` رسید، `data.result` را نمایش دهید.
|
||||
4. اگر `failed` شد، پیام خطا را به کاربر نشان دهید.
|
||||
|
||||
### Irrigation
|
||||
|
||||
1. با `POST /recommend/` درخواست را ارسال کنید.
|
||||
2. اگر `task_id` برگشت، هر چند ثانیه وضعیت را چک کنید.
|
||||
3. وقتی `completed` شد، `result.plan` و `result.water_balance` را نمایش دهید.
|
||||
|
||||
## نکات
|
||||
|
||||
- همه پاسخها در این پروژه معمولاً با ساختار زیر برمیگردند:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
- در صورت خطا ممکن است `status` مقدار دیگری داشته باشد یا سرویس خارجی خطای مستقیم برگرداند.
|
||||
- فرانت باید هر دو حالت **direct result** و **task-based result** را هندل کند.
|
||||
@@ -1,126 +0,0 @@
|
||||
# مستندات APIهای دستیار هوشمند مزرعه (Farm AI Assistant)
|
||||
|
||||
این سند تمام APIهای مورد نیاز برای صفحه **Farm AI Assistant** را شرح میدهد: ورودیها، خروجیها و استفاده در UI.
|
||||
|
||||
**مسیر صفحه:** `(dashboard)/(private)/farm-ai-assistant`
|
||||
**کامپوننت اصلی:** `FarmAiAssistantChat`
|
||||
|
||||
---
|
||||
|
||||
## نمای کلی
|
||||
|
||||
دستیار هوشمند مزرعه برای کار به موارد زیر نیاز دارد:
|
||||
|
||||
| ردیف | API | هدف |
|
||||
|------|-----|------|
|
||||
| ۱ | **ارسال پیام به دستیار (Chat/Complete)** | دریافت پاسخ ساختیافته (توصیه، لیست، هشدار) بر اساس پیام کاربر و زمینه مزرعه |
|
||||
| ۲ | **دریافت زمینه مزرعه (Farm Context)** | پر کردن نوار «زمینه مزرعه» (نوع خاک، EC آب، محصول، مرحله رشد، آخرین آبیاری) |
|
||||
| ۳ | **توصیه آبیاری** | در صورت درخواست کاربر یا تصمیم دستیار برای توصیه آبیاری |
|
||||
| ۴ | **توصیه کوددهی** | در صورت درخواست کاربر یا توصیه کود |
|
||||
| ۵ | **تشخیص آفت از تصویر** | وقتی کاربر تصویر گیاه را ارسال میکند |
|
||||
|
||||
---
|
||||
|
||||
## ۱. API ارسال پیام به دستیار (Farm AI Chat)
|
||||
|
||||
این API هسته اصلی دستیار است و در حال حاضر در فرانت با پاسخ دمو شبیهسازی شده است؛ باید با API واقعی جایگزین شود.
|
||||
|
||||
### ۱.۱ مشخصات
|
||||
|
||||
- **متد:** `POST`
|
||||
- **آدرس پیشنهادی:** `POST /api/farm-ai-assistant/chat/` یا `POST /api/farm-ai-assistant/messages/`
|
||||
- **هدف:** ارسال پیام کاربر (و در صورت وجود تصویر) به همراه زمینه مزرعه و دریافت پاسخ ساختیافته دستیار.
|
||||
|
||||
### ۱.۲ ورودی (Request Body)
|
||||
|
||||
| فیلد | نوع | اجباری | توضیح |
|
||||
|------|-----|--------|--------|
|
||||
| `content` | string | بله | متن پیام کاربر |
|
||||
| `images` | string[] یا base64[] | خیر | آرایه آدرس تصاویر یا داده base64 (در صورت استفاده از آپلود تصویر دوربین در چت) |
|
||||
| `conversation_id` | string | خیر | شناسه مکالمه برای ادامه گفتگو؛ در اولین پیام ارسال نشود |
|
||||
| `farm_context` | object | توصیه | زمینه مزرعه برای پاسخ شخصیسازیشده (در صورت نبودن، بکاند میتواند از پیشفرض استفاده کند) |
|
||||
|
||||
ساختار پیشنهادی `farm_context` (همخوان با `FarmContext` در فرانت):
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "برنامه آبیاری برای گوجه در مرحله گلدهی چطور باشد؟",
|
||||
"farm_context": {
|
||||
"soilType": "Loamy",
|
||||
"waterEC": "1.2 dS/m",
|
||||
"selectedCrop": "Tomato",
|
||||
"growthStage": "Flowering",
|
||||
"lastIrrigationStatus": "2 days ago"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
اگر از **تصویر** استفاده شود (دکمه دوربین در input):
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "این برگ زرد شده، چه مشکلی داره؟",
|
||||
"images": ["data:image/jpeg;base64,..."],
|
||||
"farm_context": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### ۱.۳ خروجی (Response Body)
|
||||
|
||||
پاسخ باید شامل **بخشهای ساختیافته** (sections) باشد تا در UI به صورت کارت (توصیه، لیست، هشدار) رندر شود.
|
||||
|
||||
**قالب پیشنهادی:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"message_id": "a-1739123456789",
|
||||
"conversation_id": "conv-abc123",
|
||||
"content": "",
|
||||
"sections": [
|
||||
{
|
||||
"type": "recommendation",
|
||||
"title": "Irrigation recommendation",
|
||||
"icon": "droplet",
|
||||
"frequency": "3 times per week",
|
||||
"amount": "15–20 L per plant",
|
||||
"timing": "Early morning (05:00–07:00)",
|
||||
"expandableExplanation": "Your loamy soil holds moisture well..."
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"title": "Key points",
|
||||
"icon": "leaf",
|
||||
"items": [
|
||||
"Avoid midday watering to reduce evaporation",
|
||||
"Drip irrigation preferred for root zone targeting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "warning",
|
||||
"title": "Weather advisory",
|
||||
"icon": "warning",
|
||||
"content": "High temps forecasted next week. Consider increasing frequency."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**ساختار هر بخش (Section) مطابق `AIResponseSection` در فرانت:**
|
||||
|
||||
| فیلد | نوع | اجباری | توضیح |
|
||||
|------|-----|--------|--------|
|
||||
| `type` | string | بله | یکی از: `text` \| `list` \| `recommendation` \| `warning` |
|
||||
| `title` | string | خیر | عنوان بخش |
|
||||
| `content` | string | خیر | برای `type: "text"` یا `type: "warning"` |
|
||||
| `items` | string[] | خیر | برای `type: "list"` |
|
||||
| `icon` | string | خیر | یکی از: `droplet` \| `leaf` \| `warning` \| `fertilizer` \| `calendar` |
|
||||
| `frequency` | string | خیر | فقط برای `recommendation`: تعداد دفعات (مثلاً در هفته) |
|
||||
| `amount` | string | خیر | فقط برای `recommendation`: مقدار (مثلاً لیتر یا کیلوگرم) |
|
||||
| `timing` | string | خیر | فقط برای `recommendation`: زمان پیشنهادی |
|
||||
| `expandableExplanation` | string | خیر | فقط برای `recommendation`: توضیح قابل گسترش «چرا این توصیه» |
|
||||
|
||||
- اگر `content` خالی باشد و فقط `sections` برگردد، در UI فقط کارتها نمایش داده میشوند (مطابق پیادهسازی فعلی).
|
||||
- در صورت خطا انتظار میرود پاسخ با `status: "error"` و پیام مناسب برگردد.
|
||||
@@ -3,138 +3,250 @@
|
||||
* @see CROP_ZONING_APIS.md
|
||||
*/
|
||||
|
||||
import type { Feature, FeatureCollection, Polygon } from 'geojson'
|
||||
import { apiClient } from '../client'
|
||||
import type { Feature, FeatureCollection, Polygon } from "geojson";
|
||||
import { apiClient } from "../client";
|
||||
import type {
|
||||
RecommendationTaskInitResponse,
|
||||
RecommendationTaskStatus,
|
||||
RecommendationTaskStatusResponse,
|
||||
} from "./recommendationTask";
|
||||
import { normalizeRecommendationTaskStatus } from "./recommendationTask";
|
||||
|
||||
const PREFIX = '/api/crop-zoning'
|
||||
const PREFIX = "/api/crop-zoning";
|
||||
|
||||
export interface Product {
|
||||
id: string
|
||||
label: string
|
||||
color: string
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ZoneInitialData {
|
||||
zoneId: string
|
||||
geometry: Polygon
|
||||
zoneId: string;
|
||||
geometry: Polygon;
|
||||
/** اگر null/خالی/uncultivable باشد، زون غیرقابل کشت و خاکستری نمایش داده میشود */
|
||||
crop?: string | null
|
||||
matchPercent?: number | null
|
||||
waterNeed?: string | null
|
||||
estimatedProfit?: string | null
|
||||
crop?: string | null;
|
||||
matchPercent?: number | null;
|
||||
waterNeed?: string | null;
|
||||
estimatedProfit?: string | null;
|
||||
}
|
||||
|
||||
export interface ZonesInitialResponse {
|
||||
total_area_hectares: number
|
||||
total_area_sqm: number
|
||||
zone_count: number
|
||||
zones: ZoneInitialData[]
|
||||
total_area_hectares: number;
|
||||
total_area_sqm: number;
|
||||
zone_count: number;
|
||||
zones: ZoneInitialData[];
|
||||
}
|
||||
|
||||
export interface AreaResponse {
|
||||
area: Feature<Polygon>
|
||||
area: Feature<Polygon> | null;
|
||||
}
|
||||
|
||||
export interface CropZoningAreaTask {
|
||||
status?:
|
||||
| "IDLE"
|
||||
| "PENDING"
|
||||
| "PROCESSING"
|
||||
| "SUCCESS"
|
||||
| "FAILURE"
|
||||
| "pending"
|
||||
| "processing"
|
||||
| "success"
|
||||
| "completed"
|
||||
| "failure"
|
||||
| "failed";
|
||||
stage?: string;
|
||||
stage_label?: string;
|
||||
area_uuid?: string;
|
||||
total_zones?: number;
|
||||
completed_zones?: number;
|
||||
processing_zones?: number;
|
||||
pending_zones?: number;
|
||||
failed_zones?: number;
|
||||
remaining_zones?: number;
|
||||
progress_percent?: number;
|
||||
message?: string;
|
||||
failed_zone_errors?: string[];
|
||||
cell_side_km?: number;
|
||||
}
|
||||
|
||||
export interface CropZoningAreaResult extends AreaResponse {
|
||||
status?: string;
|
||||
task?: CropZoningAreaTask | null;
|
||||
zones?: ZoneInitialData[];
|
||||
}
|
||||
|
||||
export type CropZoningAreaResponse =
|
||||
| CropZoningAreaResult
|
||||
| RecommendationTaskInitResponse;
|
||||
|
||||
export interface ZoneDetailData {
|
||||
zoneId: string
|
||||
crop: string
|
||||
matchPercent: number
|
||||
waterNeed: string
|
||||
estimatedProfit: string
|
||||
reason: string
|
||||
criteria: { name: string; value: number }[]
|
||||
area_hectares?: number
|
||||
zoneId: string;
|
||||
crop: string;
|
||||
matchPercent: number;
|
||||
waterNeed: string;
|
||||
estimatedProfit: string;
|
||||
reason: string;
|
||||
criteria: { name: string; value: number }[];
|
||||
area_hectares?: number;
|
||||
}
|
||||
|
||||
/** دیتای نیاز آبی هر زون — لایهٔ نیاز آبی */
|
||||
export interface ZoneWaterNeedData {
|
||||
zoneId: string
|
||||
geometry: Polygon
|
||||
level: 'low' | 'medium' | 'high'
|
||||
value?: string
|
||||
color: string
|
||||
zoneId: string;
|
||||
geometry: Polygon;
|
||||
level: "low" | "medium" | "high";
|
||||
value?: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** دیتای کیفیت خاک هر زون — لایهٔ کیفیت خاک */
|
||||
export interface ZoneSoilQualityData {
|
||||
zoneId: string
|
||||
geometry: Polygon
|
||||
level: 'low' | 'medium' | 'high'
|
||||
score?: number
|
||||
color: string
|
||||
zoneId: string;
|
||||
geometry: Polygon;
|
||||
level: "low" | "medium" | "high";
|
||||
score?: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** دیتای ریسک کشت هر زون — لایهٔ ریسک کشت */
|
||||
export interface ZoneCultivationRiskData {
|
||||
zoneId: string
|
||||
geometry: Polygon
|
||||
level: 'low' | 'medium' | 'high'
|
||||
color: string
|
||||
zoneId: string;
|
||||
geometry: Polygon;
|
||||
level: "low" | "medium" | "high";
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** دادهٔ نمایشی هر زون روی نقشه — خروجی تبدیل از تمام لایهها */
|
||||
export interface ZoneMapData {
|
||||
zoneId: string
|
||||
geometry: Polygon
|
||||
color: string
|
||||
tooltipContent: string
|
||||
cultivable: boolean
|
||||
zoneInitialData?: ZoneInitialData
|
||||
zoneId: string;
|
||||
geometry: Polygon;
|
||||
color: string;
|
||||
tooltipContent: string;
|
||||
cultivable: boolean;
|
||||
zoneInitialData?: ZoneInitialData;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
status: string
|
||||
data: T
|
||||
status: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
async function unwrap<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
|
||||
const res = await promise
|
||||
return res.data
|
||||
const res = await promise;
|
||||
return res.data;
|
||||
}
|
||||
|
||||
function normalizeTaskInitResponse(
|
||||
task: RecommendationTaskInitResponse,
|
||||
): RecommendationTaskInitResponse {
|
||||
return {
|
||||
...task,
|
||||
status: normalizeRecommendationTaskStatus(task.status),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAreaResult(
|
||||
result: CropZoningAreaResult,
|
||||
): CropZoningAreaResult {
|
||||
return {
|
||||
...result,
|
||||
task: result.task
|
||||
? {
|
||||
...result.task,
|
||||
status: normalizeRecommendationTaskStatus(result.task.status),
|
||||
}
|
||||
: result.task,
|
||||
};
|
||||
}
|
||||
|
||||
export const cropZoningService = {
|
||||
getProducts(): Promise<{ products: Product[] }> {
|
||||
return unwrap(apiClient.get<ApiResponse<{ products: Product[] }>>(`${PREFIX}/products/`))
|
||||
return unwrap(
|
||||
apiClient.get<ApiResponse<{ products: Product[] }>>(
|
||||
`${PREFIX}/products/`,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
getZonesInitial(body: {
|
||||
zones: FeatureCollection<Polygon>
|
||||
products?: string[]
|
||||
zones: FeatureCollection<Polygon>;
|
||||
products?: string[];
|
||||
}): Promise<ZonesInitialResponse> {
|
||||
return unwrap(apiClient.post<ApiResponse<ZonesInitialResponse>>(`${PREFIX}/zones/initial/`, body))
|
||||
return unwrap(
|
||||
apiClient.post<ApiResponse<ZonesInitialResponse>>(
|
||||
`${PREFIX}/zones/initial/`,
|
||||
body,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
getZoneDetails(zoneId: string): Promise<ZoneDetailData> {
|
||||
return unwrap(apiClient.get<ApiResponse<ZoneDetailData>>(`${PREFIX}/zones/${zoneId}/details/`))
|
||||
return unwrap(
|
||||
apiClient.get<ApiResponse<ZoneDetailData>>(
|
||||
`${PREFIX}/zones/${zoneId}/details/`,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
getArea(): Promise<AreaResponse> {
|
||||
return unwrap(apiClient.get<ApiResponse<AreaResponse>>(`${PREFIX}/area/`))
|
||||
getArea(sensorUuid: string): Promise<CropZoningAreaResponse> {
|
||||
return unwrap(
|
||||
apiClient.get<ApiResponse<CropZoningAreaResponse>>(`${PREFIX}/area/?sensor_uuid=${sensorUuid}`),
|
||||
).then((response) =>
|
||||
"task_id" in response
|
||||
? normalizeTaskInitResponse(response)
|
||||
: normalizeAreaResult(response),
|
||||
);
|
||||
},
|
||||
|
||||
getAreaStatus(
|
||||
taskId: string,
|
||||
): Promise<RecommendationTaskStatusResponse<CropZoningAreaResult>> {
|
||||
return unwrap(
|
||||
apiClient.get<
|
||||
ApiResponse<RecommendationTaskStatusResponse<CropZoningAreaResult>>
|
||||
>(`${PREFIX}/area/status/${taskId}/`),
|
||||
).then((response) => ({
|
||||
...response,
|
||||
status: normalizeRecommendationTaskStatus(response.status),
|
||||
result: response.result
|
||||
? normalizeAreaResult(response.result)
|
||||
: undefined,
|
||||
}));
|
||||
},
|
||||
|
||||
/** نیاز آبی هر منطقه — برای لایهٔ نیاز آبی */
|
||||
getZonesWaterNeed(body: { zones: FeatureCollection<Polygon> }): Promise<{ zones: ZoneWaterNeedData[] }> {
|
||||
getZonesWaterNeed(body: {
|
||||
zones: FeatureCollection<Polygon>;
|
||||
}): Promise<{ zones: ZoneWaterNeedData[] }> {
|
||||
return unwrap(
|
||||
apiClient.post<ApiResponse<{ zones: ZoneWaterNeedData[] }>>(`${PREFIX}/zones/water-need/`, body)
|
||||
)
|
||||
apiClient.post<ApiResponse<{ zones: ZoneWaterNeedData[] }>>(
|
||||
`${PREFIX}/zones/water-need/`,
|
||||
body,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
/** کیفیت خاک هر منطقه — برای لایهٔ کیفیت خاک */
|
||||
getZonesSoilQuality(body: { zones: FeatureCollection<Polygon> }): Promise<{ zones: ZoneSoilQualityData[] }> {
|
||||
getZonesSoilQuality(body: {
|
||||
zones: FeatureCollection<Polygon>;
|
||||
}): Promise<{ zones: ZoneSoilQualityData[] }> {
|
||||
return unwrap(
|
||||
apiClient.post<ApiResponse<{ zones: ZoneSoilQualityData[] }>>(`${PREFIX}/zones/soil-quality/`, body)
|
||||
)
|
||||
apiClient.post<ApiResponse<{ zones: ZoneSoilQualityData[] }>>(
|
||||
`${PREFIX}/zones/soil-quality/`,
|
||||
body,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
/** ریسک کشت هر منطقه — برای لایهٔ ریسک کشت */
|
||||
getZonesCultivationRisk(body: {
|
||||
zones: FeatureCollection<Polygon>
|
||||
zones: FeatureCollection<Polygon>;
|
||||
}): Promise<{ zones: ZoneCultivationRiskData[] }> {
|
||||
return unwrap(
|
||||
apiClient.post<ApiResponse<{ zones: ZoneCultivationRiskData[] }>>(
|
||||
`${PREFIX}/zones/cultivation-risk/`,
|
||||
body
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
body,
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
/**
|
||||
* Farm AI Assistant API
|
||||
* GET context (farm bar data), POST chat (user message + optional farm_context/images).
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type { FarmContext } from '@views/dashboards/farm/farmAiAssistant/farmAiAssistantTypes'
|
||||
import type { ConversationSummary, FarmContext } from '@views/dashboards/farm/farmAiAssistant/farmAiAssistantTypes'
|
||||
|
||||
const PREFIX = '/api/farm-ai-assistant'
|
||||
|
||||
@@ -29,17 +24,57 @@ export interface ChatSection {
|
||||
}
|
||||
|
||||
export interface ChatPayload {
|
||||
content: string
|
||||
farm_context?: FarmContext
|
||||
content?: string
|
||||
images?: string[]
|
||||
conversation_id?: string
|
||||
title?: string
|
||||
farm_context?: Partial<FarmContext>
|
||||
}
|
||||
|
||||
export interface ChatResponseData {
|
||||
export interface ChatTaskInitResponse {
|
||||
task_id: string
|
||||
status: 'PENDING' | 'STARTED' | 'SUCCESS' | 'FAILURE'
|
||||
status_url?: string
|
||||
conversation_id: string
|
||||
message_id: string
|
||||
}
|
||||
|
||||
export interface ChatTaskStatusResponse {
|
||||
task_id: string
|
||||
status: 'PENDING' | 'STARTED' | 'SUCCESS' | 'FAILURE'
|
||||
conversation_id: string
|
||||
progress?: {
|
||||
message?: string
|
||||
}
|
||||
result?: ChatMessageResponse
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ChatMessageResponse {
|
||||
message_id: string
|
||||
conversation_id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
sections: ChatSection[]
|
||||
images?: string[]
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface ConversationMessagesResponse {
|
||||
conversation_id: string
|
||||
messages: ChatMessageResponse[]
|
||||
}
|
||||
|
||||
export interface CreateConversationPayload {
|
||||
title?: string
|
||||
farm_context?: Partial<FarmContext>
|
||||
}
|
||||
|
||||
export interface CreateConversationResponse {
|
||||
id: string
|
||||
message_count: number
|
||||
title?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
@@ -52,21 +87,33 @@ function unwrap<T>(res: ApiResponse<T>): T {
|
||||
}
|
||||
|
||||
export const farmAiAssistantService = {
|
||||
/**
|
||||
* Returns farm context for the context bar (soilType, waterEC, selectedCrop, growthStage, lastIrrigationStatus).
|
||||
*/
|
||||
getContext(): Promise<FarmContextResponse> {
|
||||
return apiClient
|
||||
.get<ApiResponse<FarmContextResponse>>(`${PREFIX}/context/`)
|
||||
.then(unwrap)
|
||||
return apiClient.get<ApiResponse<FarmContextResponse>>(`${PREFIX}/context/`).then(unwrap)
|
||||
},
|
||||
|
||||
/**
|
||||
* Send user message (and optional farm_context, images, conversation_id). Returns message with sections.
|
||||
*/
|
||||
chat(payload: ChatPayload): Promise<ChatResponseData> {
|
||||
createChatTask(payload: ChatPayload): Promise<ChatTaskInitResponse> {
|
||||
return apiClient.post<ApiResponse<ChatTaskInitResponse>>(`${PREFIX}/chat/task/`, payload).then(unwrap)
|
||||
},
|
||||
|
||||
getChatTaskStatus(taskId: string): Promise<ChatTaskStatusResponse> {
|
||||
return apiClient.get<ApiResponse<ChatTaskStatusResponse>>(`${PREFIX}/chat/task/${taskId}/status/`).then(unwrap)
|
||||
},
|
||||
|
||||
getConversations(): Promise<ConversationSummary[]> {
|
||||
return apiClient.get<ApiResponse<ConversationSummary[]>>(`${PREFIX}/chats/`).then(unwrap)
|
||||
},
|
||||
|
||||
createConversation(payload?: CreateConversationPayload): Promise<CreateConversationResponse> {
|
||||
return apiClient.post<ApiResponse<CreateConversationResponse>>(`${PREFIX}/chats/`, payload).then(unwrap)
|
||||
},
|
||||
|
||||
deleteConversation(conversationId: string): Promise<{ conversation_id: string }> {
|
||||
return apiClient.delete<ApiResponse<{ conversation_id: string }>>(`${PREFIX}/chats/${conversationId}/`).then(unwrap)
|
||||
},
|
||||
|
||||
getConversationMessages(conversationId: string): Promise<ConversationMessagesResponse> {
|
||||
return apiClient
|
||||
.post<ApiResponse<ChatResponseData>>(`${PREFIX}/chat/`, payload)
|
||||
.get<ApiResponse<ConversationMessagesResponse>>(`${PREFIX}/chats/${conversationId}/messages/`)
|
||||
.then(unwrap)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,65 +3,135 @@
|
||||
* @see RECOMMENDATION_APIS.md
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
export interface FarmData {
|
||||
soilType: string
|
||||
organicMatter: string
|
||||
waterEC: string
|
||||
soilType: string;
|
||||
organicMatter: string;
|
||||
waterEC: string;
|
||||
}
|
||||
|
||||
export interface GrowthStage {
|
||||
id: string
|
||||
icon: string
|
||||
id: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface CropOption {
|
||||
id: string
|
||||
labelKey: string
|
||||
icon: string
|
||||
id: string;
|
||||
labelKey: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface FertilizationConfigResponse {
|
||||
farmData: FarmData
|
||||
growthStages: GrowthStage[]
|
||||
cropOptions: CropOption[]
|
||||
farmData: FarmData;
|
||||
growthStages: GrowthStage[];
|
||||
cropOptions: CropOption[];
|
||||
}
|
||||
|
||||
export interface FertilizationPlan {
|
||||
npkRatio: string
|
||||
amountPerHectare: string
|
||||
applicationMethod: string
|
||||
applicationInterval: string
|
||||
reasoning: string
|
||||
npkRatio: string;
|
||||
amountPerHectare: string;
|
||||
applicationMethod: string;
|
||||
applicationInterval: string;
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
export interface FertilizationRecommendPayload {
|
||||
crop_id?: string
|
||||
growth_stage?: string
|
||||
soilType?: string
|
||||
organicMatter?: string
|
||||
waterEC?: string
|
||||
crop_id?: string;
|
||||
growth_stage?: string;
|
||||
farm_data?: Partial<FarmData>;
|
||||
soilType?: string;
|
||||
organicMatter?: string;
|
||||
waterEC?: string;
|
||||
}
|
||||
|
||||
export interface FertilizationRecommendationResult {
|
||||
plan: FertilizationPlan;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export type FertilizationRecommendResponse =
|
||||
| FertilizationRecommendationResult
|
||||
| RecommendationTaskInitResponse;
|
||||
|
||||
interface ApiResponse<T> {
|
||||
status: string
|
||||
data: T
|
||||
status: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
async function unwrap<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
|
||||
const res = await promise
|
||||
return res.data
|
||||
const res = await promise;
|
||||
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(): Promise<FertilizationConfigResponse> {
|
||||
return unwrap(apiClient.get<ApiResponse<FertilizationConfigResponse>>(`${PREFIX}/config/`))
|
||||
return unwrap(
|
||||
apiClient.get<ApiResponse<FertilizationConfigResponse>>(
|
||||
`${PREFIX}/config/`,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
recommend(payload?: FertilizationRecommendPayload): Promise<{ plan: FertilizationPlan }> {
|
||||
return unwrap(apiClient.post<ApiResponse<{ plan: FertilizationPlan }>>(`${PREFIX}/recommend/`, payload ?? {}))
|
||||
recommend(
|
||||
payload?: FertilizationRecommendPayload,
|
||||
): Promise<FertilizationRecommendResponse> {
|
||||
return unwrap(
|
||||
apiClient.post<ApiResponse<FertilizationRecommendResponse>>(
|
||||
`${PREFIX}/recommend/`,
|
||||
payload ?? {},
|
||||
),
|
||||
).then((response) =>
|
||||
"task_id" in response
|
||||
? normalizeTaskInitResponse(response)
|
||||
: normalizeRecommendationResult(response),
|
||||
);
|
||||
},
|
||||
}
|
||||
|
||||
getRecommendStatus(
|
||||
taskId: string,
|
||||
): Promise<
|
||||
RecommendationTaskStatusResponse<FertilizationRecommendationResult>
|
||||
> {
|
||||
return unwrap(
|
||||
apiClient.get<
|
||||
ApiResponse<
|
||||
RecommendationTaskStatusResponse<FertilizationRecommendationResult>
|
||||
>
|
||||
>(`${PREFIX}/recommend/status/${taskId}/`),
|
||||
).then((response) => ({
|
||||
...response,
|
||||
status: normalizeRecommendationTaskStatus(response.status),
|
||||
result: response.result
|
||||
? normalizeRecommendationResult(response.result)
|
||||
: undefined,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,58 +3,147 @@
|
||||
* @see RECOMMENDATION_APIS.md
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import { apiClient } from "../client";
|
||||
import type {
|
||||
RecommendationTaskInitResponse,
|
||||
RecommendationTaskStatusResponse,
|
||||
} from "./recommendationTask";
|
||||
import { normalizeRecommendationTaskStatus } from "./recommendationTask";
|
||||
|
||||
const PREFIX = '/api/irrigation-recommendation'
|
||||
const PREFIX = "/api/irrigation-recommendation";
|
||||
|
||||
export interface FarmInfo {
|
||||
soilType: string
|
||||
waterQuality: string
|
||||
climateZone: string
|
||||
soilType: string;
|
||||
waterQuality: string;
|
||||
climateZone: string;
|
||||
}
|
||||
|
||||
export interface CropOption {
|
||||
id: string
|
||||
labelKey: string
|
||||
icon: string
|
||||
id: string;
|
||||
labelKey: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface IrrigationConfigResponse {
|
||||
farmInfo: FarmInfo
|
||||
cropOptions: CropOption[]
|
||||
farmInfo: FarmInfo;
|
||||
cropOptions: CropOption[];
|
||||
}
|
||||
|
||||
export interface IrrigationPlan {
|
||||
frequencyPerWeek: number
|
||||
durationMinutes: number
|
||||
bestTimeOfDay: string
|
||||
moistureLevel: number
|
||||
warning?: string
|
||||
frequencyPerWeek: number | string;
|
||||
durationMinutes: number | string;
|
||||
bestTimeOfDay: string;
|
||||
moistureLevel: number | string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export interface IrrigationRecommendPayload {
|
||||
crop_id?: string
|
||||
soilType?: string
|
||||
waterQuality?: string
|
||||
climateZone?: string
|
||||
crop_id?: string;
|
||||
farm_data?: Partial<FarmInfo>;
|
||||
soilType?: string;
|
||||
waterQuality?: string;
|
||||
climateZone?: string;
|
||||
}
|
||||
|
||||
export interface WaterBalanceDailyEntry {
|
||||
forecast_date: string;
|
||||
et0_mm: number;
|
||||
etc_mm: number;
|
||||
effective_rainfall_mm: number;
|
||||
gross_irrigation_mm: number;
|
||||
irrigation_timing: string;
|
||||
}
|
||||
|
||||
export interface WaterBalanceCropProfile {
|
||||
kc_initial: number;
|
||||
kc_mid: number;
|
||||
kc_end: number;
|
||||
}
|
||||
|
||||
export interface WaterBalance {
|
||||
daily: WaterBalanceDailyEntry[];
|
||||
crop_profile?: WaterBalanceCropProfile;
|
||||
active_kc?: number;
|
||||
}
|
||||
|
||||
export interface IrrigationRecommendationResult {
|
||||
plan: IrrigationPlan;
|
||||
raw_response?: string;
|
||||
water_balance?: WaterBalance;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export type IrrigationRecommendResponse =
|
||||
| IrrigationRecommendationResult
|
||||
| RecommendationTaskInitResponse;
|
||||
|
||||
interface ApiResponse<T> {
|
||||
status: string
|
||||
data: T
|
||||
status: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
async function unwrap<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
|
||||
const res = await promise
|
||||
return res.data
|
||||
const res = await promise;
|
||||
return res.data;
|
||||
}
|
||||
|
||||
function normalizeTaskInitResponse(
|
||||
task: RecommendationTaskInitResponse,
|
||||
): RecommendationTaskInitResponse {
|
||||
return {
|
||||
...task,
|
||||
status: normalizeRecommendationTaskStatus(task.status),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRecommendationResult(
|
||||
result: IrrigationRecommendationResult,
|
||||
): IrrigationRecommendationResult {
|
||||
return result.status
|
||||
? {
|
||||
...result,
|
||||
status: normalizeRecommendationTaskStatus(result.status),
|
||||
}
|
||||
: result;
|
||||
}
|
||||
|
||||
export const irrigationRecommendationService = {
|
||||
getConfig(): Promise<IrrigationConfigResponse> {
|
||||
return unwrap(apiClient.get<ApiResponse<IrrigationConfigResponse>>(`${PREFIX}/config/`))
|
||||
return unwrap(
|
||||
apiClient.get<ApiResponse<IrrigationConfigResponse>>(`${PREFIX}/config/`),
|
||||
);
|
||||
},
|
||||
|
||||
recommend(payload?: IrrigationRecommendPayload): Promise<{ plan: IrrigationPlan }> {
|
||||
return unwrap(apiClient.post<ApiResponse<{ plan: IrrigationPlan }>>(`${PREFIX}/recommend/`, payload ?? {}))
|
||||
recommend(
|
||||
payload?: IrrigationRecommendPayload,
|
||||
): Promise<IrrigationRecommendResponse> {
|
||||
return unwrap(
|
||||
apiClient.post<ApiResponse<IrrigationRecommendResponse>>(
|
||||
`${PREFIX}/recommend/`,
|
||||
payload ?? {},
|
||||
),
|
||||
).then((response) =>
|
||||
"task_id" in response
|
||||
? normalizeTaskInitResponse(response)
|
||||
: normalizeRecommendationResult(response),
|
||||
);
|
||||
},
|
||||
}
|
||||
|
||||
getRecommendStatus(
|
||||
taskId: string,
|
||||
): Promise<RecommendationTaskStatusResponse<IrrigationRecommendationResult>> {
|
||||
return unwrap(
|
||||
apiClient.get<
|
||||
ApiResponse<
|
||||
RecommendationTaskStatusResponse<IrrigationRecommendationResult>
|
||||
>
|
||||
>(`${PREFIX}/recommend/status/${taskId}/`),
|
||||
).then((response) => ({
|
||||
...response,
|
||||
status: normalizeRecommendationTaskStatus(response.status),
|
||||
result: response.result
|
||||
? normalizeRecommendationResult(response.result)
|
||||
: undefined,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export type RecommendationTaskStatus =
|
||||
| "pending"
|
||||
| "processing"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export interface RecommendationTaskInitResponse {
|
||||
task_id: string;
|
||||
status: RecommendationTaskStatus;
|
||||
}
|
||||
|
||||
export interface RecommendationTaskProgress {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface RecommendationTaskStatusResponse<T> {
|
||||
task_id: string;
|
||||
status: RecommendationTaskStatus;
|
||||
progress?: RecommendationTaskProgress;
|
||||
result?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const normalizeRecommendationTaskStatus = (
|
||||
status?: string,
|
||||
): RecommendationTaskStatus => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case "started":
|
||||
case "processing":
|
||||
return "processing";
|
||||
case "success":
|
||||
case "completed":
|
||||
return "completed";
|
||||
case "failure":
|
||||
case "failed":
|
||||
return "failed";
|
||||
default:
|
||||
return "pending";
|
||||
}
|
||||
};
|
||||
|
||||
export const isRecommendationTaskRunning = (status: RecommendationTaskStatus) =>
|
||||
status === "pending" || status === "processing";
|
||||
@@ -0,0 +1,134 @@
|
||||
# Crop Zoning API Integration
|
||||
|
||||
## نحوه ارتباط با API
|
||||
|
||||
### 1. دریافت اطلاعات Area و Zones
|
||||
|
||||
```typescript
|
||||
GET /api/crop-zoning/area/?sensor_uuid=<sensor_uuid>
|
||||
```
|
||||
|
||||
این endpoint به صورت task-based کار میکند:
|
||||
|
||||
**مراحل:**
|
||||
1. اولین بار که API را صدا میزنید، task ساخته میشود و `status: 'PENDING'` برمیگرداند
|
||||
2. باید هر 2 ثانیه polling کنید تا `status` به `'SUCCESS'` برسد
|
||||
3. در حین polling، از فیلدهای زیر برای نمایش progress استفاده کنید:
|
||||
- `task.progress_percent`: درصد پیشرفت (0-100)
|
||||
- `task.message`: پیام فارسی برای نمایش
|
||||
- `task.completed_zones` / `task.total_zones`: تعداد زونهای پردازش شده
|
||||
|
||||
**مثال پاسخ در حال پردازش:**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"task": {
|
||||
"status": "PROCESSING",
|
||||
"stage_label": "در حال پردازش زونها",
|
||||
"total_zones": 364,
|
||||
"completed_zones": 182,
|
||||
"progress_percent": 50,
|
||||
"message": "از مجموع 364 زون، 182 زون پردازش شده..."
|
||||
},
|
||||
"area": { ... },
|
||||
"zones": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**مثال پاسخ موفق:**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"task": {
|
||||
"status": "SUCCESS",
|
||||
"progress_percent": 100,
|
||||
"total_zones": 364,
|
||||
"completed_zones": 364
|
||||
},
|
||||
"area": {
|
||||
"type": "Feature",
|
||||
"geometry": { "type": "Polygon", "coordinates": [...] }
|
||||
},
|
||||
"zones": [
|
||||
{
|
||||
"zoneId": "zone-0",
|
||||
"geometry": { "type": "Polygon", "coordinates": [...] },
|
||||
"crop": "wheat",
|
||||
"matchPercent": 89,
|
||||
"waterNeed": "4820-5820 m³/ha"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. نمایش Progress Bar در UI
|
||||
|
||||
```tsx
|
||||
// وقتی task در حال پردازش است
|
||||
{loading && progress && (
|
||||
<Box>
|
||||
<Typography>{progress.message}</Typography>
|
||||
<LinearProgress variant="determinate" value={progress.percent} />
|
||||
<Typography>{progress.percent}%</Typography>
|
||||
</Box>
|
||||
)}
|
||||
```
|
||||
|
||||
### 3. وضعیتهای Task
|
||||
|
||||
- `IDLE`: هنوز task ساخته نشده
|
||||
- `PENDING`: task ساخته شده، منتظر شروع
|
||||
- `PROCESSING`: در حال پردازش زونها
|
||||
- `SUCCESS`: همه زونها پردازش شدند
|
||||
- `FAILURE`: خطا در پردازش
|
||||
|
||||
### 4. Flow کامل در کد
|
||||
|
||||
```typescript
|
||||
const loadArea = async () => {
|
||||
let polls = 0;
|
||||
|
||||
while (polls < MAX_POLLS) {
|
||||
const res = await cropZoningService.getArea(sensorUuid);
|
||||
const task = res.task;
|
||||
|
||||
// نمایش progress
|
||||
if (task) {
|
||||
setProgress({
|
||||
message: task.message || task.stage_label,
|
||||
percent: task.progress_percent || 0
|
||||
});
|
||||
}
|
||||
|
||||
// بررسی وضعیت
|
||||
if (task?.status === 'SUCCESS') {
|
||||
setAreaGeoJson(res.area);
|
||||
setZonesData(res.zones);
|
||||
break;
|
||||
}
|
||||
|
||||
if (task?.status === 'FAILURE') {
|
||||
throw new Error(task.message);
|
||||
}
|
||||
|
||||
// اگر در حال پردازش است، صبر کن و دوباره بررسی کن
|
||||
if (task?.status === 'PENDING' || task?.status === 'PROCESSING') {
|
||||
await sleep(2000);
|
||||
polls++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## نکات مهم
|
||||
|
||||
1. **Polling Interval**: 2 ثانیه (نه کمتر، نه بیشتر)
|
||||
2. **Max Attempts**: حداکثر 100 بار تلاش (200 ثانیه = 3.3 دقیقه)
|
||||
3. **Progress Bar**: حتما از `LinearProgress` با `variant="determinate"` استفاده کنید
|
||||
4. **Sensor UUID**: باید از props یا context دریافت شود (فعلاً hardcode شده)
|
||||
@@ -108,6 +108,11 @@ export default function CropZoningMap({
|
||||
geoJsonLayer.addTo(map)
|
||||
zonesLayerRef.current = geoJsonLayer
|
||||
|
||||
const bounds = geoJsonLayer.getBounds()
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { padding: [24, 24] })
|
||||
}
|
||||
|
||||
let idx = 0
|
||||
geoJsonLayer.eachLayer((layer: L.Layer) => {
|
||||
const leafLayer = layer as L.Polygon
|
||||
@@ -183,6 +188,10 @@ export default function CropZoningMap({
|
||||
drawnItems.clearLayers()
|
||||
L.geoJSON(initialAreaGeoJson as unknown as Feature<Polygon>).eachLayer((layer) => drawnItems.addLayer(layer))
|
||||
emitAreaChange()
|
||||
const bounds = drawnItems.getBounds()
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { padding: [24, 24] })
|
||||
}
|
||||
}
|
||||
|
||||
const onCreated = (e: L.LeafletEvent) => {
|
||||
|
||||
@@ -1,303 +1,211 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Box from '@mui/material/Box'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import Button from '@mui/material/Button'
|
||||
import CropZoningMap from './CropZoningMap'
|
||||
import ZoneLegend from './ZoneLegend'
|
||||
import LayerControl from './LayerControl'
|
||||
import ZoneDetailPanel from './ZoneDetailPanel'
|
||||
import CropZoningWeatherSection from './CropZoningWeatherSection'
|
||||
import { createGridFromPolygon } from './cropZoningUtils'
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSensorHub } from "@/hooks/useSensorHub";
|
||||
import Box from "@mui/material/Box";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import LinearProgress from "@mui/material/LinearProgress";
|
||||
import CropZoningMap from "./CropZoningMap";
|
||||
import ZoneLegend from "./ZoneLegend";
|
||||
import LayerControl from "./LayerControl";
|
||||
import ZoneDetailPanel from "./ZoneDetailPanel";
|
||||
import CropZoningWeatherSection from "./CropZoningWeatherSection";
|
||||
import {
|
||||
cropZoningService,
|
||||
type Product,
|
||||
type ZoneInitialData,
|
||||
type ZoneDetailData,
|
||||
type ZoneMapData,
|
||||
type ZoneWaterNeedData,
|
||||
type ZoneSoilQualityData,
|
||||
type ZoneCultivationRiskData
|
||||
} from '@/libs/api/services/cropZoningService'
|
||||
import { CROP_COLORS, type CropType } from './cropZoningTypes'
|
||||
import type { LayerType } from './cropZoningTypes'
|
||||
import type { MapDrawGeoJSON } from './CropZoningMap'
|
||||
} from "@/libs/api/services/cropZoningService";
|
||||
import { CROP_COLORS, type CropType } from "./cropZoningTypes";
|
||||
import type { LayerType } from "./cropZoningTypes";
|
||||
import type { MapDrawGeoJSON } from "./CropZoningMap";
|
||||
|
||||
const MapComponent = dynamic(() => Promise.resolve(CropZoningMap), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Box className='flex items-center justify-center bs-full min-bs-[400px] rounded-xl bg-action-hover'>
|
||||
<Box className="flex items-center justify-center bs-full min-bs-[400px] rounded-xl bg-action-hover">
|
||||
<CircularProgress size={48} />
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
function isPolygon(geojson: MapDrawGeoJSON): geojson is MapDrawGeoJSON & { geometry: { type: 'Polygon'; coordinates: unknown[] } } {
|
||||
return !!geojson?.geometry && (geojson.geometry as { type: string }).type === 'Polygon'
|
||||
}
|
||||
const POLL_INTERVAL = 2000;
|
||||
const MAX_POLLS = 100;
|
||||
const getNormalizedTaskStatus = (status?: string) => status?.toLowerCase();
|
||||
|
||||
export default function CropZoningWrapper() {
|
||||
const t = useTranslations('cropZoning')
|
||||
const [areaGeoJson, setAreaGeoJson] = useState<MapDrawGeoJSON>(null)
|
||||
const [areaLoading, setAreaLoading] = useState(true)
|
||||
const [zonesData, setZonesData] = useState<ZoneInitialData[] | null>(null)
|
||||
const [zonesWaterNeed, setZonesWaterNeed] = useState<ZoneWaterNeedData[] | null>(null)
|
||||
const [zonesSoilQuality, setZonesSoilQuality] = useState<ZoneSoilQualityData[] | null>(null)
|
||||
const [zonesCultivationRisk, setZonesCultivationRisk] = useState<ZoneCultivationRiskData[] | null>(null)
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [productsLoading, setProductsLoading] = useState(true)
|
||||
const [zonesLoading, setZonesLoading] = useState(false)
|
||||
const [layerDataLoading, setLayerDataLoading] = useState(false)
|
||||
const [activeLayer, setActiveLayer] = useState<LayerType>('crops')
|
||||
const [selectedZone, setSelectedZone] = useState<ZoneDetailData | null>(null)
|
||||
const [panelOpen, setPanelOpen] = useState(false)
|
||||
const [zoneDetailLoading, setZoneDetailLoading] = useState(false)
|
||||
const [optimizationKey, setOptimizationKey] = useState(0)
|
||||
|
||||
const productLabels = Object.fromEntries(products.map(p => [p.id, p.label]))
|
||||
const t = useTranslations("cropZoning");
|
||||
const { sensorHub } = useSensorHub();
|
||||
const [areaGeoJson, setAreaGeoJson] = useState<MapDrawGeoJSON>(null);
|
||||
const [isClientReady, setIsClientReady] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [progress, setProgress] = useState<{ message: string; percent: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [zonesData, setZonesData] = useState<ZoneInitialData[] | null>(null);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [activeLayer, setActiveLayer] = useState<LayerType>("crops");
|
||||
const [selectedZone, setSelectedZone] = useState<ZoneDetailData | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
cropZoningService
|
||||
.getProducts()
|
||||
setIsClientReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
cropZoningService.getProducts()
|
||||
.then(res => setProducts(res.products))
|
||||
.catch(() => setProducts([]))
|
||||
.finally(() => setProductsLoading(false))
|
||||
}, [])
|
||||
.catch(() => setProducts([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setAreaLoading(true)
|
||||
cropZoningService
|
||||
.getArea()
|
||||
.then(res => setAreaGeoJson(res.area as unknown as MapDrawGeoJSON))
|
||||
.catch(() => setAreaGeoJson(null))
|
||||
.finally(() => setAreaLoading(false))
|
||||
}, [])
|
||||
let cancelled = false;
|
||||
|
||||
const fetchZones = useCallback((geojson: MapDrawGeoJSON) => {
|
||||
if (!isPolygon(geojson)) {
|
||||
setZonesData(null)
|
||||
setZonesWaterNeed(null)
|
||||
setZonesSoilQuality(null)
|
||||
setZonesCultivationRisk(null)
|
||||
return
|
||||
}
|
||||
setZonesWaterNeed(null)
|
||||
setZonesSoilQuality(null)
|
||||
setZonesCultivationRisk(null)
|
||||
setZonesLoading(true)
|
||||
const grid = createGridFromPolygon(geojson as unknown as import('geojson').Feature<import('geojson').Polygon>)
|
||||
cropZoningService
|
||||
.getZonesInitial({ zones: grid })
|
||||
.then(res => setZonesData(res.zones))
|
||||
.catch(() => setZonesData(null))
|
||||
.finally(() => setZonesLoading(false))
|
||||
}, [])
|
||||
const loadArea = async () => {
|
||||
if (!isClientReady) return;
|
||||
|
||||
useEffect(() => {
|
||||
if (isPolygon(areaGeoJson)) {
|
||||
fetchZones(areaGeoJson)
|
||||
} else {
|
||||
setZonesData(null)
|
||||
setZonesWaterNeed(null)
|
||||
setZonesSoilQuality(null)
|
||||
setZonesCultivationRisk(null)
|
||||
}
|
||||
}, [areaGeoJson, optimizationKey, fetchZones])
|
||||
if (!sensorHub?.id) {
|
||||
setError(t("errors.noSensor"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const gridForLayers = isPolygon(areaGeoJson)
|
||||
? createGridFromPolygon(areaGeoJson as unknown as import('geojson').Feature<import('geojson').Polygon>)
|
||||
: null
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setProgress({ message: t("loadingArea"), percent: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!gridForLayers || zonesLoading) return
|
||||
if (activeLayer === 'waterNeed' && zonesWaterNeed === null) {
|
||||
setLayerDataLoading(true)
|
||||
cropZoningService
|
||||
.getZonesWaterNeed({ zones: gridForLayers })
|
||||
.then(res => setZonesWaterNeed(res.zones))
|
||||
.catch(() => setZonesWaterNeed(null))
|
||||
.finally(() => setLayerDataLoading(false))
|
||||
} else if (activeLayer === 'soilQuality' && zonesSoilQuality === null) {
|
||||
setLayerDataLoading(true)
|
||||
cropZoningService
|
||||
.getZonesSoilQuality({ zones: gridForLayers })
|
||||
.then(res => setZonesSoilQuality(res.zones))
|
||||
.catch(() => setZonesSoilQuality(null))
|
||||
.finally(() => setLayerDataLoading(false))
|
||||
} else if (activeLayer === 'cultivationRisk' && zonesCultivationRisk === null) {
|
||||
setLayerDataLoading(true)
|
||||
cropZoningService
|
||||
.getZonesCultivationRisk({ zones: gridForLayers })
|
||||
.then(res => setZonesCultivationRisk(res.zones))
|
||||
.catch(() => setZonesCultivationRisk(null))
|
||||
.finally(() => setLayerDataLoading(false))
|
||||
}
|
||||
}, [activeLayer, gridForLayers, zonesLoading, zonesWaterNeed, zonesSoilQuality, zonesCultivationRisk])
|
||||
try {
|
||||
let polls = 0;
|
||||
|
||||
const mapZonesData = useMemo((): ZoneMapData[] | null => {
|
||||
const labels = Object.fromEntries(products.map(p => [p.id, p.label]))
|
||||
if (activeLayer === 'crops' && zonesData) {
|
||||
const isCultivable = (crop: string | null | undefined) =>
|
||||
!!crop && crop !== 'uncultivable' && crop.toLowerCase() !== 'uncultivable'
|
||||
return zonesData.map(z => {
|
||||
const cultivable = isCultivable(z.crop)
|
||||
const cropLabel = cultivable ? (labels[z.crop!] ?? z.crop) : 'غیر قابل کشت'
|
||||
const tooltipContent = cultivable
|
||||
? `<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
|
||||
<div style="font-weight: 600; margin-bottom: 6px;">${cropLabel}</div>
|
||||
<div>درصد تطابق: ${z.matchPercent ?? '-'}%</div>
|
||||
<div>نیاز آب: ${z.waterNeed ?? '-'}</div>
|
||||
<div>سود تخمینی: ${z.estimatedProfit ?? '-'}</div>
|
||||
</div>`
|
||||
: `<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
|
||||
<div style="font-weight: 600; margin-bottom: 6px; color: #64748b;">غیر قابل کشت</div>
|
||||
<div style="color: #94a3b8;">این بخش برای کشت مناسب تشخیص داده نشده است.</div>
|
||||
</div>`
|
||||
const color = cultivable ? (CROP_COLORS[z.crop as CropType] ?? '#94a3b8') : '#94a3b8'
|
||||
return {
|
||||
zoneId: z.zoneId,
|
||||
geometry: z.geometry,
|
||||
color,
|
||||
tooltipContent,
|
||||
cultivable,
|
||||
zoneInitialData: z
|
||||
while (!cancelled && polls < MAX_POLLS) {
|
||||
const res = await cropZoningService.getArea(sensorHub.id);
|
||||
|
||||
if (!("area" in res)) break;
|
||||
|
||||
const task = res.task;
|
||||
const taskStatus = getNormalizedTaskStatus(task?.status);
|
||||
|
||||
if (task) {
|
||||
setProgress({
|
||||
message: task.message || task.stage_label || t("loadingArea"),
|
||||
percent: task.progress_percent || 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (taskStatus === "completed" || taskStatus === "success") {
|
||||
setAreaGeoJson(res.area as unknown as MapDrawGeoJSON);
|
||||
if (res.zones) setZonesData(res.zones as ZoneInitialData[]);
|
||||
break;
|
||||
}
|
||||
|
||||
if ((!task && res.area) || (res.area && taskStatus !== "pending" && taskStatus !== "processing")) {
|
||||
setAreaGeoJson(res.area as unknown as MapDrawGeoJSON);
|
||||
if (res.zones) setZonesData(res.zones as ZoneInitialData[]);
|
||||
break;
|
||||
}
|
||||
|
||||
if (taskStatus === "failed" || taskStatus === "failure") {
|
||||
throw new Error(task.message || t("errors.areaLoadFailed"));
|
||||
}
|
||||
|
||||
if (taskStatus === "pending" || taskStatus === "processing") {
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL));
|
||||
polls++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if (activeLayer === 'waterNeed' && zonesWaterNeed) {
|
||||
const levelLabels = { low: 'کم', medium: 'متوسط', high: 'زیاد' }
|
||||
return zonesWaterNeed.map(z => ({
|
||||
zoneId: z.zoneId,
|
||||
geometry: z.geometry,
|
||||
color: z.color,
|
||||
tooltipContent: `<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
|
||||
<div style="font-weight: 600; margin-bottom: 6px;">نیاز آبی: ${levelLabels[z.level]}</div>
|
||||
<div>${z.value ?? '-'}</div>
|
||||
</div>`,
|
||||
cultivable: true,
|
||||
zoneInitialData: { zoneId: z.zoneId, geometry: z.geometry } as ZoneInitialData
|
||||
}))
|
||||
}
|
||||
if (activeLayer === 'soilQuality' && zonesSoilQuality) {
|
||||
const levelLabels = { low: 'کم', medium: 'متوسط', high: 'زیاد' }
|
||||
return zonesSoilQuality.map(z => ({
|
||||
zoneId: z.zoneId,
|
||||
geometry: z.geometry,
|
||||
color: z.color,
|
||||
tooltipContent: `<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
|
||||
<div style="font-weight: 600; margin-bottom: 6px;">کیفیت خاک: ${levelLabels[z.level]}</div>
|
||||
<div>امتیاز: ${z.score ?? '-'}</div>
|
||||
</div>`,
|
||||
cultivable: true,
|
||||
zoneInitialData: { zoneId: z.zoneId, geometry: z.geometry } as ZoneInitialData
|
||||
}))
|
||||
}
|
||||
if (activeLayer === 'cultivationRisk' && zonesCultivationRisk) {
|
||||
const levelLabels = { low: 'کم', medium: 'متوسط', high: 'زیاد' }
|
||||
return zonesCultivationRisk.map(z => ({
|
||||
zoneId: z.zoneId,
|
||||
geometry: z.geometry,
|
||||
color: z.color,
|
||||
tooltipContent: `<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
|
||||
<div style="font-weight: 600; margin-bottom: 6px;">ریسک کشت: ${levelLabels[z.level]}</div>
|
||||
</div>`,
|
||||
cultivable: true,
|
||||
zoneInitialData: { zoneId: z.zoneId, geometry: z.geometry } as ZoneInitialData
|
||||
}))
|
||||
}
|
||||
return null
|
||||
}, [activeLayer, zonesData, zonesWaterNeed, zonesSoilQuality, zonesCultivationRisk, products])
|
||||
|
||||
const handleAreaChange = useCallback((geojson: MapDrawGeoJSON) => {
|
||||
setAreaGeoJson(geojson)
|
||||
}, [])
|
||||
if (polls >= MAX_POLLS) {
|
||||
throw new Error(t("errors.timeout"));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("errors.areaLoadFailed"));
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
setProgress(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadArea();
|
||||
return () => { cancelled = true; };
|
||||
}, [isClientReady, sensorHub, t]);
|
||||
|
||||
const mapZonesData = useMemo(() => {
|
||||
if (activeLayer === "crops" && zonesData) {
|
||||
return zonesData.map(z => ({
|
||||
zoneId: z.zoneId,
|
||||
geometry: z.geometry,
|
||||
color: z.crop ? CROP_COLORS[z.crop as CropType] || "#94a3b8" : "#94a3b8",
|
||||
tooltipContent: `<div style="padding: 4px 8px;">${z.crop || "نامشخص"}</div>`,
|
||||
cultivable: !!z.crop,
|
||||
zoneInitialData: z,
|
||||
}));
|
||||
}
|
||||
return null;
|
||||
}, [activeLayer, zonesData]);
|
||||
|
||||
const handleZoneClick = useCallback((zone: ZoneInitialData) => {
|
||||
setZoneDetailLoading(true)
|
||||
setPanelOpen(true)
|
||||
setSelectedZone(null)
|
||||
cropZoningService
|
||||
.getZoneDetails(zone.zoneId)
|
||||
.then(details => setSelectedZone(details))
|
||||
.catch(() => setSelectedZone(null))
|
||||
.finally(() => setZoneDetailLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleOptimize = useCallback(() => {
|
||||
setOptimizationKey(k => k + 1)
|
||||
}, [])
|
||||
setPanelOpen(true);
|
||||
setSelectedZone(null);
|
||||
cropZoningService.getZoneDetails(zone.zoneId)
|
||||
.then(setSelectedZone)
|
||||
.catch(() => setSelectedZone(null));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box className='flex flex-col gap-6 is-full'>
|
||||
<Box className='relative min-bs-[400px] rounded-xl overflow-hidden' sx={{ height: 'min(60vh, 500px)' }}>
|
||||
<Box className='absolute inset-0 z-0'>
|
||||
<Box className="flex flex-col gap-6 is-full">
|
||||
<Box className="relative min-bs-[400px] rounded-xl overflow-hidden" sx={{ height: "min(60vh, 500px)" }}>
|
||||
<Box className="absolute inset-0 z-0">
|
||||
{areaGeoJson ? (
|
||||
<MapComponent
|
||||
key='crop-zoning-map'
|
||||
center={[35.6892, 51.389]}
|
||||
zoom={13}
|
||||
height='100%'
|
||||
height="100%"
|
||||
activeLayer={activeLayer}
|
||||
onAreaChange={handleAreaChange}
|
||||
onZoneClick={handleZoneClick}
|
||||
optimizationKey={optimizationKey}
|
||||
className='min-bs-[400px]'
|
||||
initialAreaGeoJson={areaGeoJson}
|
||||
zonesData={mapZonesData}
|
||||
productLabels={productLabels}
|
||||
productLabels={Object.fromEntries(products.map(p => [p.id, p.label]))}
|
||||
readOnly
|
||||
/>
|
||||
) : (
|
||||
<Box className='flex items-center justify-center bs-full min-bs-[400px] rounded-xl bg-action-hover' />
|
||||
<Box className="flex items-center justify-center bs-full bg-action-hover" />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{(areaLoading || zonesLoading || (activeLayer !== 'crops' && layerDataLoading)) && (
|
||||
<Box
|
||||
className='absolute inset-0 z-[500] flex items-center justify-center bg-white/60 dark:bg-gray-900/60'
|
||||
sx={{ borderRadius: 12 }}
|
||||
>
|
||||
{loading && (
|
||||
<Box className="absolute inset-0 z-[500] flex flex-col items-center justify-center gap-3 bg-white/80 dark:bg-gray-900/80" sx={{ borderRadius: 12 }}>
|
||||
<CircularProgress size={48} />
|
||||
{progress && (
|
||||
<Box className="w-full max-w-md px-8">
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500, mb: 1, textAlign: "center" }}>
|
||||
{progress.message}
|
||||
</Box>
|
||||
<LinearProgress variant="determinate" value={progress.percent} sx={{ height: 8, borderRadius: 4 }} />
|
||||
<Box sx={{ fontSize: 12, mt: 0.5, textAlign: "center", color: "text.secondary" }}>
|
||||
{progress.percent}%
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<Box className="absolute inset-x-4 bottom-4 z-[600] rounded-xl px-4 py-3 text-sm" sx={{ backgroundColor: "error.main", color: "error.contrastText" }}>
|
||||
{error}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<LayerControl activeLayer={activeLayer} onLayerChange={setActiveLayer} />
|
||||
|
||||
<ZoneLegend
|
||||
activeLayer={activeLayer}
|
||||
products={products}
|
||||
loading={productsLoading || (activeLayer !== 'crops' && layerDataLoading)}
|
||||
/>
|
||||
|
||||
{areaGeoJson && (
|
||||
<Box className='absolute top-16 end-4 z-[1000]'>
|
||||
<Button
|
||||
variant='contained'
|
||||
color='primary'
|
||||
size='medium'
|
||||
startIcon={<i className='tabler-refresh text-xl' />}
|
||||
onClick={handleOptimize}
|
||||
disabled={zonesLoading}
|
||||
className='rounded-xl shadow-lg'
|
||||
>
|
||||
{t('optimizeAgain')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<ZoneLegend activeLayer={activeLayer} products={products} loading={false} />
|
||||
</Box>
|
||||
|
||||
<ZoneDetailPanel
|
||||
open={panelOpen}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
zone={selectedZone}
|
||||
products={products}
|
||||
loading={zoneDetailLoading}
|
||||
/>
|
||||
|
||||
<ZoneDetailPanel open={panelOpen} onClose={() => setPanelOpen(false)} zone={selectedZone} products={products} loading={false} />
|
||||
<CropZoningWeatherSection />
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Box from '@mui/material/Box'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import List from '@mui/material/List'
|
||||
import ListItemButton from '@mui/material/ListItemButton'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import ListItemIcon from '@mui/material/ListItemIcon'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import { useTheme, alpha } from '@mui/material/styles'
|
||||
import type { ConversationSummary } from './farmAiAssistantTypes'
|
||||
|
||||
interface ChatSidebarProps {
|
||||
open: boolean
|
||||
loading: boolean
|
||||
conversations: ConversationSummary[]
|
||||
activeConversationId: string | null
|
||||
onClose: () => void
|
||||
onSelectConversation: (id: string) => void
|
||||
getConversationLabel: (conversation: ConversationSummary, index: number) => string
|
||||
}
|
||||
|
||||
export default function ChatSidebar({
|
||||
open,
|
||||
loading,
|
||||
conversations,
|
||||
activeConversationId,
|
||||
onClose,
|
||||
onSelectConversation,
|
||||
getConversationLabel
|
||||
}: ChatSidebarProps) {
|
||||
const t = useTranslations('farmAiAssistant')
|
||||
const theme = useTheme()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
bgcolor: alpha(theme.palette.common.black, 0.18),
|
||||
opacity: open ? 1 : 0,
|
||||
pointerEvents: open ? 'auto' : 'none',
|
||||
transition: 'opacity 0.24s ease',
|
||||
zIndex: 5
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: { xs: '88%', sm: 320 },
|
||||
maxWidth: 320,
|
||||
transform: open ? 'translateX(0)' : 'translateX(-104%)',
|
||||
transition: 'transform 0.28s ease',
|
||||
zIndex: 6,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
bgcolor: 'background.paper',
|
||||
borderInlineEnd: `1px solid ${alpha(theme.palette.primary.main, 0.12)}`,
|
||||
boxShadow: `0 10px 30px ${alpha(theme.palette.common.black, 0.12)}`
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className='flex items-center justify-between px-4 py-3'
|
||||
sx={{ borderBottom: `1px solid ${alpha(theme.palette.primary.main, 0.12)}` }}
|
||||
>
|
||||
<Typography variant='subtitle1' fontWeight={700}>
|
||||
{t('sidebar.title')}
|
||||
</Typography>
|
||||
<IconButton size='small' onClick={onClose}>
|
||||
<i className='tabler-x text-xl' />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box className='flex-1 overflow-y-auto py-2'>
|
||||
{loading ? (
|
||||
<Box className='flex justify-center py-8'>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : conversations.length === 0 ? (
|
||||
<Box className='flex flex-col items-center justify-center py-8 px-4 text-center'>
|
||||
<i
|
||||
className='tabler-messages text-4xl mb-2'
|
||||
style={{ color: alpha(theme.palette.text.secondary, 0.35) }}
|
||||
/>
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
{t('sidebar.empty')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<List disablePadding>
|
||||
{conversations.map((conversation, index) => (
|
||||
<ListItemButton
|
||||
key={conversation.id}
|
||||
selected={conversation.id === activeConversationId}
|
||||
onClick={() => onSelectConversation(conversation.id)}
|
||||
sx={{
|
||||
mx: 1,
|
||||
my: 0.5,
|
||||
borderRadius: '12px',
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.15)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<i className='tabler-message text-lg' style={{ color: theme.palette.primary.main }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={getConversationLabel(conversation, index)}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body2',
|
||||
fontWeight: conversation.id === activeConversationId ? 700 : 500,
|
||||
noWrap: true
|
||||
}}
|
||||
secondary={`${conversation.message_count} پیام`}
|
||||
secondaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import Box from '@mui/material/Box'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Card from '@mui/material/Card'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Button from '@mui/material/Button'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Collapse from '@mui/material/Collapse'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
@@ -17,7 +18,8 @@ import classnames from 'classnames'
|
||||
import { commonLayoutClasses } from '@layouts/utils/layoutClasses'
|
||||
import { farmAiAssistantService } from '@/libs/api/services/farmAiAssistantService'
|
||||
|
||||
import type { FarmContext, FarmAIMessage, AIResponseSection } from './farmAiAssistantTypes'
|
||||
import type { ConversationSummary, FarmContext, FarmAIMessage, AIResponseSection } from './farmAiAssistantTypes'
|
||||
import ChatSidebar from './ChatSidebar'
|
||||
|
||||
// ─── Constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -36,6 +38,8 @@ const SUGGESTION_CHIPS = [
|
||||
{ id: 'plant-disease', labelKey: 'suggestions.plantDisease' }
|
||||
]
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
// ─── Main Component ────────────────────────────────────────────────────────
|
||||
|
||||
export default function FarmAiAssistantChat() {
|
||||
@@ -50,10 +54,46 @@ export default function FarmAiAssistantChat() {
|
||||
const [farmContext, setFarmContext] = useState<FarmContext>(DEFAULT_FARM_CONTEXT)
|
||||
const [contextLoading, setContextLoading] = useState(true)
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([])
|
||||
const [conversationLoading, setConversationLoading] = useState(false)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { primary, info, warning } = theme.palette
|
||||
|
||||
const mapApiMessageToUi = (message: {
|
||||
message_id: string
|
||||
role: 'user' | 'assistant'
|
||||
content?: string
|
||||
sections?: AIResponseSection[]
|
||||
images?: string[]
|
||||
created_at?: string
|
||||
}): FarmAIMessage => ({
|
||||
id: message.message_id,
|
||||
role: message.role,
|
||||
content: message.content ?? '',
|
||||
timestamp: new Date(message.created_at ?? Date.now()),
|
||||
images: message.images ?? [],
|
||||
sections: message.sections ?? []
|
||||
})
|
||||
|
||||
const loadConversations = async () => {
|
||||
setConversationLoading(true)
|
||||
try {
|
||||
const data = await farmAiAssistantService.getConversations()
|
||||
setConversations(data)
|
||||
} catch {
|
||||
toast.error(t('errors.conversationLoad'))
|
||||
} finally {
|
||||
setConversationLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getConversationLabel = (conversation: ConversationSummary, index: number) => {
|
||||
if (conversation.title?.trim()) return conversation.title
|
||||
return `${t('sidebar.chatLabel')} ${index + 1}`
|
||||
}
|
||||
|
||||
// Fetch farm context on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -83,6 +123,16 @@ export default function FarmAiAssistantChat() {
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
loadConversations()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarOpen) {
|
||||
loadConversations()
|
||||
}
|
||||
}, [sidebarOpen])
|
||||
|
||||
// Scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
@@ -114,20 +164,39 @@ export default function FarmAiAssistantChat() {
|
||||
setIsTyping(true)
|
||||
|
||||
try {
|
||||
const res = await farmAiAssistantService.chat({
|
||||
const task = await farmAiAssistantService.createChatTask({
|
||||
content,
|
||||
title: !conversationId ? content.slice(0, 60) : undefined,
|
||||
farm_context: farmContext,
|
||||
...(conversationId ? { conversation_id: conversationId } : {})
|
||||
})
|
||||
if (res.conversation_id) setConversationId(res.conversation_id)
|
||||
const aiMessage: FarmAIMessage = {
|
||||
id: res.message_id,
|
||||
role: 'assistant',
|
||||
content: res.content ?? '',
|
||||
timestamp: new Date(),
|
||||
sections: res.sections ?? []
|
||||
|
||||
if (task.conversation_id) {
|
||||
setConversationId(task.conversation_id)
|
||||
}
|
||||
setMessages(prev => [...prev, aiMessage])
|
||||
|
||||
let attempts = 0
|
||||
let taskStatus = await farmAiAssistantService.getChatTaskStatus(task.task_id)
|
||||
|
||||
while (taskStatus.status === 'PENDING' || taskStatus.status === 'STARTED') {
|
||||
attempts += 1
|
||||
|
||||
if (attempts >= 20) {
|
||||
throw new Error('timeout')
|
||||
}
|
||||
|
||||
await sleep(1500)
|
||||
taskStatus = await farmAiAssistantService.getChatTaskStatus(task.task_id)
|
||||
}
|
||||
|
||||
if (taskStatus.status === 'FAILURE' || !taskStatus.result) {
|
||||
throw new Error(taskStatus.error || 'chat-failed')
|
||||
}
|
||||
|
||||
const result = taskStatus.result
|
||||
|
||||
setMessages(prev => [...prev, mapApiMessageToUi(result)])
|
||||
await loadConversations()
|
||||
} catch {
|
||||
toast.error(t('errors.chatSend'))
|
||||
} finally {
|
||||
@@ -135,6 +204,45 @@ export default function FarmAiAssistantChat() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewChat = async () => {
|
||||
setConversationId(null)
|
||||
setMessages([])
|
||||
setSelectedChip(null)
|
||||
setExpandedExplanations(new Set())
|
||||
setInputValue('')
|
||||
setSidebarOpen(false)
|
||||
|
||||
try {
|
||||
const conversation = await farmAiAssistantService.createConversation({
|
||||
title: t('sidebar.newChat'),
|
||||
farm_context: farmContext
|
||||
})
|
||||
|
||||
setConversationId(conversation.id)
|
||||
await loadConversations()
|
||||
} catch {
|
||||
setConversationId(null)
|
||||
toast.error(t('errors.conversationCreate'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectConversation = async (id: string) => {
|
||||
setConversationId(id)
|
||||
setMessages([])
|
||||
setIsTyping(true)
|
||||
setSidebarOpen(false)
|
||||
|
||||
try {
|
||||
const data = await farmAiAssistantService.getConversationMessages(id)
|
||||
|
||||
setMessages(data.messages.map(mapApiMessageToUi))
|
||||
} catch {
|
||||
toast.error(t('errors.conversationLoad'))
|
||||
} finally {
|
||||
setIsTyping(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExplanation = (id: string) => {
|
||||
setExpandedExplanations(prev => {
|
||||
const next = new Set(prev)
|
||||
@@ -149,10 +257,21 @@ export default function FarmAiAssistantChat() {
|
||||
className={classnames(commonLayoutClasses.contentHeightFixed, 'flex flex-col is-full overflow-hidden rounded')}
|
||||
sx={{
|
||||
background: `linear-gradient(180deg, ${theme.palette.background.default} 0%, ${alpha(primary.main, 0.04)} 30%, ${alpha(primary.main, 0.08)} 100%)`,
|
||||
minHeight: '100%'
|
||||
minHeight: '100%',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{/* 1) Smart Header */}
|
||||
<ChatSidebar
|
||||
open={sidebarOpen}
|
||||
loading={conversationLoading}
|
||||
conversations={conversations}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
activeConversationId={conversationId}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
getConversationLabel={getConversationLabel}
|
||||
/>
|
||||
|
||||
<Box
|
||||
className='flex items-center gap-3 px-4 pt-4 pb-3 flex-shrink-0'
|
||||
sx={{
|
||||
@@ -160,6 +279,33 @@ export default function FarmAiAssistantChat() {
|
||||
borderBottom: `1px solid ${alpha(primary.main, 0.12)}`
|
||||
}}
|
||||
>
|
||||
<Box className='flex items-center gap-2 shrink-0'>
|
||||
<IconButton
|
||||
size='small'
|
||||
onClick={() => setSidebarOpen(prev => !prev)}
|
||||
sx={{
|
||||
borderRadius: '12px',
|
||||
bgcolor: alpha(primary.main, 0.08),
|
||||
'&:hover': { bgcolor: alpha(primary.main, 0.16) }
|
||||
}}
|
||||
>
|
||||
<i className='tabler-menu-2 text-xl' />
|
||||
</IconButton>
|
||||
<Button
|
||||
variant='contained'
|
||||
size='small'
|
||||
onClick={handleNewChat}
|
||||
startIcon={<i className='tabler-plus' />}
|
||||
sx={{
|
||||
borderRadius: '12px',
|
||||
minWidth: 'fit-content',
|
||||
textTransform: 'none',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{t('sidebar.newChat')}
|
||||
</Button>
|
||||
</Box>
|
||||
<Box
|
||||
className='w-12 h-12 rounded-2xl flex items-center justify-center shrink-0'
|
||||
sx={{
|
||||
|
||||
@@ -34,3 +34,10 @@ export interface FarmAIMessage {
|
||||
// For structured AI responses
|
||||
sections?: AIResponseSection[]
|
||||
}
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string
|
||||
message_count: number
|
||||
title?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
+368
-246
@@ -1,372 +1,480 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Box from '@mui/material/Box'
|
||||
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 CircularProgress from '@mui/material/CircularProgress'
|
||||
import { useTheme, alpha } from '@mui/material/styles'
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Box from "@mui/material/Box";
|
||||
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 CircularProgress from "@mui/material/CircularProgress";
|
||||
import { useTheme, alpha } from "@mui/material/styles";
|
||||
import type {
|
||||
FarmData,
|
||||
GrowthStage,
|
||||
CropOption,
|
||||
FertilizationPlan,
|
||||
} from '@/libs/api/services/fertilizationRecommendationService'
|
||||
import { fertilizationRecommendationService } from '@/libs/api/services/fertilizationRecommendationService'
|
||||
} from "@/libs/api/services/fertilizationRecommendationService";
|
||||
import { fertilizationRecommendationService } from "@/libs/api/services/fertilizationRecommendationService";
|
||||
import { isRecommendationTaskRunning } from "@/libs/api/services/recommendationTask";
|
||||
|
||||
const DEFAULT_FARM_DATA: FarmData = {
|
||||
soilType: 'Loamy',
|
||||
organicMatter: 'Medium (2.5%)',
|
||||
waterEC: '1.2 dS/m'
|
||||
}
|
||||
soilType: "Loamy",
|
||||
organicMatter: "Medium (2.5%)",
|
||||
waterEC: "1.2 dS/m",
|
||||
};
|
||||
|
||||
const DEFAULT_GROWTH_STAGES: GrowthStage[] = [
|
||||
{ id: 'prePlanting', icon: 'tabler-seedling' },
|
||||
{ id: 'earlyGrowth', icon: 'tabler-leaf' },
|
||||
{ id: 'flowering', icon: 'tabler-flower' },
|
||||
{ id: 'fruiting', icon: 'tabler-apple' },
|
||||
{ id: 'postHarvest', icon: 'tabler-basket' }
|
||||
]
|
||||
{ id: "prePlanting", icon: "tabler-seedling" },
|
||||
{ id: "earlyGrowth", icon: "tabler-leaf" },
|
||||
{ id: "flowering", icon: "tabler-flower" },
|
||||
{ id: "fruiting", icon: "tabler-apple" },
|
||||
{ id: "postHarvest", icon: "tabler-basket" },
|
||||
];
|
||||
|
||||
const DEFAULT_CROP_OPTIONS: CropOption[] = [
|
||||
{ id: 'wheat', labelKey: 'wheat', icon: 'tabler-wheat' },
|
||||
{ id: 'corn', labelKey: 'corn', icon: 'tabler-plant-2' },
|
||||
{ id: 'cotton', labelKey: 'cotton', icon: 'tabler-flower' },
|
||||
{ id: 'saffron', labelKey: 'saffron', icon: 'tabler-flower-2' },
|
||||
{ id: 'canola', labelKey: 'canola', icon: 'tabler-leaf' },
|
||||
{ id: 'vegetables', labelKey: 'vegetables', icon: 'tabler-carrot' }
|
||||
]
|
||||
{ id: "wheat", labelKey: "wheat", icon: "tabler-wheat" },
|
||||
{ id: "corn", labelKey: "corn", icon: "tabler-plant-2" },
|
||||
{ id: "cotton", labelKey: "cotton", icon: "tabler-flower" },
|
||||
{ id: "saffron", labelKey: "saffron", icon: "tabler-flower-2" },
|
||||
{ id: "canola", labelKey: "canola", icon: "tabler-leaf" },
|
||||
{ id: "vegetables", labelKey: "vegetables", icon: "tabler-carrot" },
|
||||
];
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const getErrorMessage = (error: unknown, fallback: string) =>
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"message" in error &&
|
||||
typeof error.message === "string"
|
||||
? error.message
|
||||
: fallback;
|
||||
|
||||
export default function SmartFertilizationRecommendation() {
|
||||
const t = useTranslations('fertilization')
|
||||
const theme = useTheme()
|
||||
const primaryMain = theme.palette.primary.main
|
||||
const primaryLight = theme.palette.primary.light
|
||||
const primaryDark = theme.palette.primary.dark
|
||||
const paperBg = theme.palette.background.paper
|
||||
const [farmData, setFarmData] = useState<FarmData>(DEFAULT_FARM_DATA)
|
||||
const [growthStages, setGrowthStages] = useState<GrowthStage[]>(DEFAULT_GROWTH_STAGES)
|
||||
const [cropOptions, setCropOptions] = useState<CropOption[]>(DEFAULT_CROP_OPTIONS)
|
||||
const [configLoading, setConfigLoading] = useState(true)
|
||||
const [configError, setConfigError] = useState<string | null>(null)
|
||||
const [growthStage, setGrowthStage] = useState<string>(DEFAULT_GROWTH_STAGES[0].id)
|
||||
const [selectedCrop, setSelectedCrop] = useState<string | null>(null)
|
||||
const [plan, setPlan] = useState<FertilizationPlan | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [reasoningExpanded, setReasoningExpanded] = useState(false)
|
||||
const t = useTranslations("fertilization");
|
||||
const theme = useTheme();
|
||||
const primaryMain = theme.palette.primary.main;
|
||||
const primaryLight = theme.palette.primary.light;
|
||||
const primaryDark = theme.palette.primary.dark;
|
||||
const paperBg = theme.palette.background.paper;
|
||||
const [farmData, setFarmData] = useState<FarmData>(DEFAULT_FARM_DATA);
|
||||
const [growthStages, setGrowthStages] = useState<GrowthStage[]>(
|
||||
DEFAULT_GROWTH_STAGES,
|
||||
);
|
||||
const [cropOptions, setCropOptions] =
|
||||
useState<CropOption[]>(DEFAULT_CROP_OPTIONS);
|
||||
const [configLoading, setConfigLoading] = useState(true);
|
||||
const [configError, setConfigError] = useState<string | null>(null);
|
||||
const [growthStage, setGrowthStage] = useState<string>(
|
||||
DEFAULT_GROWTH_STAGES[0].id,
|
||||
);
|
||||
const [selectedCrop, setSelectedCrop] = useState<string | null>(null);
|
||||
const [plan, setPlan] = useState<FertilizationPlan | 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);
|
||||
|
||||
useEffect(() => {
|
||||
fertilizationRecommendationService
|
||||
.getConfig()
|
||||
.then(({ farmData: farm, growthStages: stages, cropOptions: crops }) => {
|
||||
if (farm) setFarmData(farm)
|
||||
if (farm) setFarmData(farm);
|
||||
if (stages?.length) {
|
||||
setGrowthStages(stages)
|
||||
setGrowthStage(stages[0].id)
|
||||
setGrowthStages(stages);
|
||||
setGrowthStage(stages[0].id);
|
||||
}
|
||||
if (crops?.length) setCropOptions(crops)
|
||||
if (crops?.length) setCropOptions(crops);
|
||||
})
|
||||
.catch((err: { message?: string }) => {
|
||||
setConfigError(err?.message ?? 'Failed to load config')
|
||||
setConfigError(err?.message ?? "Failed to load config");
|
||||
})
|
||||
.finally(() => setConfigLoading(false))
|
||||
}, [])
|
||||
.finally(() => setConfigLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedCrop) return
|
||||
setLoading(true)
|
||||
setPlan(null)
|
||||
setReasoningExpanded(false)
|
||||
if (!selectedCrop) return;
|
||||
setLoading(true);
|
||||
setPlan(null);
|
||||
setRequestError(null);
|
||||
setStatusMessage(t("generating"));
|
||||
setReasoningExpanded(false);
|
||||
try {
|
||||
const { plan: nextPlan } = await fertilizationRecommendationService.recommend({
|
||||
crop_id: selectedCrop,
|
||||
growth_stage: growthStage,
|
||||
soilType: farmData.soilType,
|
||||
organicMatter: farmData.organicMatter,
|
||||
waterEC: farmData.waterEC,
|
||||
})
|
||||
setPlan(nextPlan)
|
||||
} catch {
|
||||
setPlan(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
const recommendation = await fertilizationRecommendationService.recommend(
|
||||
{
|
||||
crop_id: selectedCrop,
|
||||
growth_stage: growthStage,
|
||||
farm_data: {
|
||||
soilType: farmData.soilType,
|
||||
organicMatter: farmData.organicMatter,
|
||||
waterEC: farmData.waterEC,
|
||||
},
|
||||
soilType: farmData.soilType,
|
||||
organicMatter: farmData.organicMatter,
|
||||
waterEC: farmData.waterEC,
|
||||
},
|
||||
);
|
||||
|
||||
const stageIndex = growthStages.findIndex(s => s.id === growthStage)
|
||||
if ("task_id" in recommendation) {
|
||||
let attempts = 0;
|
||||
let taskStatus =
|
||||
await fertilizationRecommendationService.getRecommendStatus(
|
||||
recommendation.task_id,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
setPlan(null);
|
||||
setRequestError(getErrorMessage(error, t("errors.generateFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setStatusMessage(null);
|
||||
}
|
||||
};
|
||||
|
||||
const stageIndex = growthStages.findIndex((s) => s.id === growthStage);
|
||||
|
||||
return (
|
||||
<Box
|
||||
className='min-bs-screen'
|
||||
className="min-bs-screen"
|
||||
sx={{
|
||||
background: (th) =>
|
||||
`linear-gradient(165deg, ${alpha(th.palette.primary.main, 0.08)} 0%, ${alpha(th.palette.primary.main, 0.05)} 25%, ${alpha(th.palette.primary.main, 0.03)} 60%, ${th.palette.background.default} 100%)`,
|
||||
minHeight: '100vh'
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<Box className='max-w-lg mx-auto px-4 py-6 sm:py-8'>
|
||||
<Box className="max-w-lg mx-auto px-4 py-6 sm:py-8">
|
||||
{/* 1) Header */}
|
||||
<Box className='mb-8'>
|
||||
<Box className="mb-8">
|
||||
<Typography
|
||||
variant='h4'
|
||||
className='font-bold tracking-tight'
|
||||
variant="h4"
|
||||
className="font-bold tracking-tight"
|
||||
sx={{
|
||||
background: `linear-gradient(135deg, ${primaryDark} 0%, ${primaryMain} 40%, ${primaryLight} 100%)`,
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
fontSize: { xs: '1.5rem', sm: '1.75rem' }
|
||||
backgroundClip: "text",
|
||||
WebkitBackgroundClip: "text",
|
||||
color: "transparent",
|
||||
fontSize: { xs: "1.5rem", sm: "1.75rem" },
|
||||
}}
|
||||
>
|
||||
{t('title')}
|
||||
{t("title")}
|
||||
</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>
|
||||
</Box>
|
||||
|
||||
{/* 2) Farm Data Card */}
|
||||
<Card
|
||||
elevation={0}
|
||||
className='mb-6 overflow-hidden transition-all duration-300 hover:shadow-lg animate-fade-in'
|
||||
className="mb-6 overflow-hidden transition-all duration-300 hover:shadow-lg animate-fade-in"
|
||||
sx={{
|
||||
borderRadius: '28px',
|
||||
borderRadius: "28px",
|
||||
background: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 50%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||
boxShadow: `0 4px 24px ${alpha(primaryMain, 0.08)}, 0 4px 12px ${alpha(primaryMain, 0.04)}, 0 1px 3px rgba(0,0,0,0.04)`,
|
||||
border: `1px solid ${alpha(primaryMain, 0.12)}`
|
||||
border: `1px solid ${alpha(primaryMain, 0.12)}`,
|
||||
}}
|
||||
>
|
||||
<CardContent className='p-5'>
|
||||
<Box className='flex items-center justify-between mbe-4'>
|
||||
<Typography variant='subtitle2' fontWeight={600} color='text.secondary'>
|
||||
{t('farmData.title')}
|
||||
<CardContent className="p-5">
|
||||
<Box className="flex items-center justify-between mbe-4">
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={600}
|
||||
color="text.secondary"
|
||||
>
|
||||
{t("farmData.title")}
|
||||
</Typography>
|
||||
<Box
|
||||
className='px-2.5 py-1 rounded-full text-xs font-medium flex items-center gap-1.5'
|
||||
className="px-2.5 py-1 rounded-full text-xs font-medium flex items-center gap-1.5"
|
||||
sx={{
|
||||
background: (th) => `linear-gradient(135deg, ${th.palette.success.main} 0%, ${th.palette.success.dark} 100%)`,
|
||||
color: 'white',
|
||||
boxShadow: (th) => `0 2px 8px ${alpha(th.palette.success.main, 0.3)}`
|
||||
background: (th) =>
|
||||
`linear-gradient(135deg, ${th.palette.success.main} 0%, ${th.palette.success.dark} 100%)`,
|
||||
color: "white",
|
||||
boxShadow: (th) =>
|
||||
`0 2px 8px ${alpha(th.palette.success.main, 0.3)}`,
|
||||
}}
|
||||
>
|
||||
<i className='tabler-circle-check text-sm' />
|
||||
{t('verifiedBadge')}
|
||||
<i className="tabler-circle-check text-sm" />
|
||||
{t("verifiedBadge")}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box className='flex flex-wrap gap-3'>
|
||||
<FarmBadge icon='tabler-seedling' label={t('farmData.soilType')} value={farmData.soilType} />
|
||||
<Box className="flex flex-wrap gap-3">
|
||||
<FarmBadge
|
||||
icon='tabler-atom-2'
|
||||
label={t('farmData.organicMatter')}
|
||||
icon="tabler-seedling"
|
||||
label={t("farmData.soilType")}
|
||||
value={farmData.soilType}
|
||||
/>
|
||||
<FarmBadge
|
||||
icon="tabler-atom-2"
|
||||
label={t("farmData.organicMatter")}
|
||||
value={farmData.organicMatter}
|
||||
/>
|
||||
<FarmBadge icon='tabler-droplet' label={t('farmData.waterEC')} value={farmData.waterEC} />
|
||||
<FarmBadge
|
||||
icon="tabler-droplet"
|
||||
label={t("farmData.waterEC")}
|
||||
value={farmData.waterEC}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3) Growth Stage Selector */}
|
||||
<Typography variant='subtitle2' fontWeight={600} color='text.secondary' className='mbe-3'>
|
||||
{t('growthStage.title')}
|
||||
<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'>
|
||||
<Box className="flex gap-2 mb-6 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-hide">
|
||||
{growthStages.map((stage, idx) => {
|
||||
const isSelected = growthStage === stage.id
|
||||
const isPast = idx < stageIndex
|
||||
const isSelected = growthStage === stage.id;
|
||||
const isPast = idx < stageIndex;
|
||||
return (
|
||||
<Box
|
||||
key={stage.id}
|
||||
component='button'
|
||||
type='button'
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => setGrowthStage(stage.id)}
|
||||
className='flex flex-col items-center gap-1.5 px-4 py-3 rounded-2xl shrink-0 transition-all duration-300 ease-out border-2 cursor-pointer min-w-[72px]'
|
||||
className="flex flex-col items-center gap-1.5 px-4 py-3 rounded-2xl shrink-0 transition-all duration-300 ease-out border-2 cursor-pointer min-w-[72px]"
|
||||
sx={{
|
||||
borderColor: isSelected ? primaryMain : 'transparent',
|
||||
borderColor: isSelected ? primaryMain : "transparent",
|
||||
background: isSelected
|
||||
? `linear-gradient(145deg, ${alpha(primaryMain, 0.15)} 0%, ${alpha(primaryMain, 0.06)} 100%)`
|
||||
: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||
boxShadow: isSelected
|
||||
? `0 4px 20px ${alpha(primaryMain, 0.2)}, inset 0 1px 0 rgba(255,255,255,0.8)`
|
||||
: '0 2px 8px rgba(0,0,0,0.04)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
: "0 2px 8px rgba(0,0,0,0.04)",
|
||||
"&:hover": {
|
||||
transform: "translateY(-2px)",
|
||||
boxShadow: isSelected
|
||||
? `0 6px 24px ${alpha(primaryMain, 0.25)}`
|
||||
: `0 4px 16px ${alpha(primaryMain, 0.1)}`
|
||||
}
|
||||
: `0 4px 16px ${alpha(primaryMain, 0.1)}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className='w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300'
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300"
|
||||
sx={{
|
||||
background: isSelected
|
||||
? `linear-gradient(135deg, ${primaryMain} 0%, ${primaryDark} 100%)`
|
||||
: isPast
|
||||
? `linear-gradient(145deg, ${alpha(primaryMain, 0.2)} 0%, ${alpha(primaryMain, 0.1)} 100%)`
|
||||
: alpha(primaryMain, 0.08)
|
||||
: alpha(primaryMain, 0.08),
|
||||
}}
|
||||
>
|
||||
<i
|
||||
className={`${stage.icon} text-xl transition-colors duration-300 ${isSelected ? 'text-white' : ''}`}
|
||||
className={`${stage.icon} text-xl transition-colors duration-300 ${isSelected ? "text-white" : ""}`}
|
||||
style={!isSelected ? { color: primaryMain } : undefined}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
variant='caption'
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
sx={{
|
||||
color: isSelected ? 'primary.main' : 'text.secondary',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.2
|
||||
color: isSelected ? "primary.main" : "text.secondary",
|
||||
textAlign: "center",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{t(`growthStage.${stage.id}`)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* 4) Plant Selection */}
|
||||
<Typography variant='subtitle2' fontWeight={600} color='text.secondary' className='mbe-3'>
|
||||
{t('plantSelection.title')}
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={600}
|
||||
color="text.secondary"
|
||||
className="mbe-3"
|
||||
>
|
||||
{t("plantSelection.title")}
|
||||
</Typography>
|
||||
{configLoading ? (
|
||||
<Box className='flex justify-center py-8 mb-6'>
|
||||
<CircularProgress size={32} sx={{ color: 'primary.main' }} />
|
||||
<Box className="flex justify-center py-8 mb-6">
|
||||
<CircularProgress size={32} sx={{ color: "primary.main" }} />
|
||||
</Box>
|
||||
) : configError ? (
|
||||
<Typography variant='body2' color='error' className='mb-6'>
|
||||
<Typography variant="body2" color="error" className="mb-6">
|
||||
{configError}
|
||||
</Typography>
|
||||
) : (
|
||||
<Box className='flex flex-wrap gap-3 mb-6'>
|
||||
{cropOptions.map(crop => (
|
||||
<CropCard
|
||||
key={crop.id}
|
||||
crop={crop}
|
||||
label={t(`crops.${crop.labelKey}`)}
|
||||
selected={selectedCrop === crop.id}
|
||||
onClick={() =>
|
||||
setSelectedCrop(prev => (prev === crop.id ? null : crop.id))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box className="flex flex-wrap gap-3 mb-6">
|
||||
{cropOptions.map((crop) => (
|
||||
<CropCard
|
||||
key={crop.id}
|
||||
crop={crop}
|
||||
label={t(`crops.${crop.labelKey}`)}
|
||||
selected={selectedCrop === crop.id}
|
||||
onClick={() =>
|
||||
setSelectedCrop((prev) => (prev === crop.id ? null : crop.id))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 5) Primary CTA Button - End of form */}
|
||||
<Box className='mb-8'>
|
||||
<Box className="mb-8">
|
||||
<Button
|
||||
fullWidth
|
||||
variant='contained'
|
||||
variant="contained"
|
||||
disabled={!selectedCrop || loading || configLoading}
|
||||
onClick={handleGenerate}
|
||||
startIcon={<i className='tabler-sparkles text-xl' />}
|
||||
className='rounded-2xl py-3.5 text-base font-semibold shadow-lg transition-all duration-300 hover:shadow-xl hover:scale-[1.01] active:scale-[0.99]'
|
||||
startIcon={<i className="tabler-sparkles text-xl" />}
|
||||
className="rounded-2xl py-3.5 text-base font-semibold shadow-lg transition-all duration-300 hover:shadow-xl hover:scale-[1.01] active:scale-[0.99]"
|
||||
sx={{
|
||||
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
||||
boxShadow: `0 4px 20px ${alpha(primaryMain, 0.4)}`,
|
||||
'&:hover': {
|
||||
"&:hover": {
|
||||
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
||||
boxShadow: `0 6px 28px ${alpha(primaryMain, 0.5)}`,
|
||||
filter: 'brightness(1.05)'
|
||||
filter: "brightness(1.05)",
|
||||
},
|
||||
"&:disabled": {
|
||||
background: "action.disabledBackground",
|
||||
color: "action.disabled",
|
||||
},
|
||||
'&:disabled': {
|
||||
background: 'action.disabledBackground',
|
||||
color: 'action.disabled'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('generateCta')}
|
||||
{t("generateCta")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{requestError && !loading && (
|
||||
<Typography variant="body2" color="error" className="mb-6">
|
||||
{requestError}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 6) Result Section - Prescription style */}
|
||||
{plan && (
|
||||
<Box className='mb-6 animate-fade-in'>
|
||||
<Box className="mb-6 animate-fade-in">
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: '28px',
|
||||
borderRadius: "28px",
|
||||
background: `linear-gradient(160deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 40%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||
boxShadow: `0 8px 32px ${alpha(primaryMain, 0.12)}, 0 4px 16px ${alpha(primaryMain, 0.06)}, 0 2px 8px rgba(0,0,0,0.04)`,
|
||||
border: `1px solid ${alpha(primaryMain, 0.15)}`,
|
||||
overflow: 'visible'
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
<CardContent className='p-6'>
|
||||
<Box className='flex items-center gap-2 mbe-5'>
|
||||
<i className='tabler-prescription text-2xl' style={{ color: primaryMain }} />
|
||||
<Typography variant='h6' fontWeight={700} color='text.primary'>
|
||||
{t('result.title')}
|
||||
<CardContent className="p-6">
|
||||
<Box className="flex items-center gap-2 mbe-5">
|
||||
<i
|
||||
className="tabler-prescription text-2xl"
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={700}
|
||||
color="text.primary"
|
||||
>
|
||||
{t("result.title")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box className='space-y-3'>
|
||||
<Box className="space-y-3">
|
||||
<PrescriptionRow
|
||||
icon='tabler-atom-2'
|
||||
label={t('result.fertilizerType')}
|
||||
icon="tabler-atom-2"
|
||||
label={t("result.fertilizerType")}
|
||||
value={plan.npkRatio}
|
||||
/>
|
||||
<PrescriptionRow
|
||||
icon='tabler-scale'
|
||||
label={t('result.amountPerHectare')}
|
||||
icon="tabler-scale"
|
||||
label={t("result.amountPerHectare")}
|
||||
value={plan.amountPerHectare}
|
||||
/>
|
||||
<PrescriptionRow
|
||||
icon='tabler-spray'
|
||||
label={t('result.applicationMethod')}
|
||||
icon="tabler-spray"
|
||||
label={t("result.applicationMethod")}
|
||||
value={plan.applicationMethod}
|
||||
/>
|
||||
<PrescriptionRow
|
||||
icon='tabler-calendar-repeat'
|
||||
label={t('result.applicationInterval')}
|
||||
icon="tabler-calendar-repeat"
|
||||
label={t("result.applicationInterval")}
|
||||
value={plan.applicationInterval}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Expandable "Why this recommendation?" */}
|
||||
<Box
|
||||
className='mt-5 rounded-2xl overflow-hidden transition-all duration-300'
|
||||
className="mt-5 rounded-2xl overflow-hidden transition-all duration-300"
|
||||
sx={{
|
||||
border: `1px solid ${alpha(primaryMain, 0.15)}`,
|
||||
background: alpha(primaryMain, 0.04)
|
||||
background: alpha(primaryMain, 0.04),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component='button'
|
||||
type='button'
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => setReasoningExpanded(!reasoningExpanded)}
|
||||
className='w-full flex items-center justify-between px-4 py-3 text-start cursor-pointer'
|
||||
sx={{ '&:hover': { bgcolor: alpha(primaryMain, 0.06) } }}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-start cursor-pointer"
|
||||
sx={{ "&:hover": { bgcolor: alpha(primaryMain, 0.06) } }}
|
||||
>
|
||||
<Box className='flex items-center gap-2'>
|
||||
<i className='tabler-brain text-lg' style={{ color: primaryMain }} />
|
||||
<Typography variant='subtitle2' fontWeight={600} color='text.primary'>
|
||||
{t('result.whyRecommendation')}
|
||||
<Box className="flex items-center gap-2">
|
||||
<i
|
||||
className="tabler-brain text-lg"
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={600}
|
||||
color="text.primary"
|
||||
>
|
||||
{t("result.whyRecommendation")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<i
|
||||
className={`tabler-chevron-down text-xl transition-transform duration-300 ${
|
||||
reasoningExpanded ? 'rotate-180' : ''
|
||||
reasoningExpanded ? "rotate-180" : ""
|
||||
}`}
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
</Box>
|
||||
<Collapse in={reasoningExpanded}>
|
||||
<Box className='px-4 pb-4'>
|
||||
<Box className="px-4 pb-4">
|
||||
<Typography
|
||||
variant='body2'
|
||||
color='text.secondary'
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ lineHeight: 1.7 }}
|
||||
>
|
||||
{plan.reasoning}
|
||||
@@ -383,32 +491,35 @@ export default function SmartFertilizationRecommendation() {
|
||||
{loading && (
|
||||
<Card
|
||||
elevation={0}
|
||||
className='mb-6 animate-fade-in'
|
||||
className="mb-6 animate-fade-in"
|
||||
sx={{
|
||||
borderRadius: '28px',
|
||||
borderRadius: "28px",
|
||||
background: `linear-gradient(160deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 100%)`,
|
||||
boxShadow: `0 8px 32px ${alpha(primaryMain, 0.1)}`,
|
||||
border: `1px solid ${alpha(primaryMain, 0.12)}`
|
||||
border: `1px solid ${alpha(primaryMain, 0.12)}`,
|
||||
}}
|
||||
>
|
||||
<CardContent className='p-12 flex flex-col items-center gap-4'>
|
||||
<CardContent className="p-12 flex flex-col items-center gap-4">
|
||||
<Box
|
||||
className='w-14 h-14 rounded-2xl flex items-center justify-center animate-pulse'
|
||||
className="w-14 h-14 rounded-2xl flex items-center justify-center animate-pulse"
|
||||
sx={{
|
||||
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 className='tabler-sparkles text-2xl' style={{ color: primaryMain }} />
|
||||
<i
|
||||
className="tabler-sparkles text-2xl"
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
{t('generating')}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{statusMessage ?? t("generating")}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||
@@ -416,129 +527,140 @@ export default function SmartFertilizationRecommendation() {
|
||||
function FarmBadge({
|
||||
icon,
|
||||
label,
|
||||
value
|
||||
value,
|
||||
}: {
|
||||
icon: string
|
||||
label: string
|
||||
value: string
|
||||
icon: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const primaryMain = theme.palette.primary.main
|
||||
const theme = useTheme();
|
||||
const primaryMain = theme.palette.primary.main;
|
||||
return (
|
||||
<Box
|
||||
className='flex items-center gap-2 px-4 py-2.5 rounded-2xl transition-all duration-200 hover:scale-[1.02] hover:shadow-md'
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-2xl transition-all duration-200 hover:scale-[1.02] hover:shadow-md"
|
||||
sx={{
|
||||
background: `linear-gradient(145deg, ${alpha(primaryMain, 0.1)} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||
border: `1px solid ${alpha(primaryMain, 0.15)}`,
|
||||
boxShadow: 'inset 0 1px 2px rgba(255,255,255,0.5)'
|
||||
boxShadow: "inset 0 1px 2px rgba(255,255,255,0.5)",
|
||||
}}
|
||||
>
|
||||
<i className={`${icon} text-xl`} style={{ color: primaryMain }} />
|
||||
<Box>
|
||||
<Typography variant='caption' color='text.secondary' display='block' lineHeight={1.2}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
display="block"
|
||||
lineHeight={1.2}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant='body2' fontWeight={600} color='text.primary'>
|
||||
<Typography variant="body2" fontWeight={600} color="text.primary">
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CropCard({
|
||||
crop,
|
||||
label,
|
||||
selected,
|
||||
onClick
|
||||
onClick,
|
||||
}: {
|
||||
crop: CropOption
|
||||
label: string
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
crop: CropOption;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const primaryMain = theme.palette.primary.main
|
||||
const primaryDark = theme.palette.primary.dark
|
||||
const paperBg = theme.palette.background.paper
|
||||
const theme = useTheme();
|
||||
const primaryMain = theme.palette.primary.main;
|
||||
const primaryDark = theme.palette.primary.dark;
|
||||
const paperBg = theme.palette.background.paper;
|
||||
return (
|
||||
<Card
|
||||
component='button'
|
||||
type='button'
|
||||
component="button"
|
||||
type="button"
|
||||
elevation={0}
|
||||
onClick={onClick}
|
||||
className='flex items-center gap-3 px-4 py-3 rounded-2xl cursor-pointer transition-all duration-300 border-2 text-start'
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-2xl cursor-pointer transition-all duration-300 border-2 text-start"
|
||||
sx={{
|
||||
borderColor: selected ? primaryMain : 'transparent',
|
||||
borderColor: selected ? primaryMain : "transparent",
|
||||
background: selected
|
||||
? `linear-gradient(145deg, ${alpha(primaryMain, 0.15)} 0%, ${alpha(primaryMain, 0.06)} 100%)`
|
||||
: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||
boxShadow: selected
|
||||
? `0 4px 20px ${alpha(primaryMain, 0.2)}, inset 0 1px 0 rgba(255,255,255,0.8)`
|
||||
: '0 2px 8px rgba(0,0,0,0.04), inset 0 1px 0 rgba(255,255,255,0.9)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
: "0 2px 8px rgba(0,0,0,0.04), inset 0 1px 0 rgba(255,255,255,0.9)",
|
||||
"&:hover": {
|
||||
transform: "translateY(-2px)",
|
||||
boxShadow: selected
|
||||
? `0 6px 24px ${alpha(primaryMain, 0.25)}`
|
||||
: `0 4px 16px ${alpha(primaryMain, 0.12)}`
|
||||
}
|
||||
: `0 4px 16px ${alpha(primaryMain, 0.12)}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className='w-11 h-11 rounded-xl flex items-center justify-center shrink-0 transition-all duration-300'
|
||||
className="w-11 h-11 rounded-xl flex items-center justify-center shrink-0 transition-all duration-300"
|
||||
sx={{
|
||||
background: selected
|
||||
? `linear-gradient(135deg, ${primaryMain} 0%, ${primaryDark} 100%)`
|
||||
: `linear-gradient(145deg, ${alpha(primaryMain, 0.1)} 0%, ${alpha(primaryMain, 0.05)} 100%)`
|
||||
: `linear-gradient(145deg, ${alpha(primaryMain, 0.1)} 0%, ${alpha(primaryMain, 0.05)} 100%)`,
|
||||
}}
|
||||
>
|
||||
<i
|
||||
className={`${crop.icon} text-xl ${selected ? 'text-white' : ''}`}
|
||||
className={`${crop.icon} text-xl ${selected ? "text-white" : ""}`}
|
||||
style={!selected ? { color: primaryMain } : undefined}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
variant='body2'
|
||||
variant="body2"
|
||||
fontWeight={600}
|
||||
color={selected ? 'primary.main' : 'text.primary'}
|
||||
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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function PrescriptionRow({
|
||||
icon,
|
||||
label,
|
||||
value
|
||||
value,
|
||||
}: {
|
||||
icon: string
|
||||
label: string
|
||||
value: string
|
||||
icon: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const primaryMain = theme.palette.primary.main
|
||||
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'
|
||||
className="flex items-center gap-4 p-3 rounded-2xl transition-colors duration-200"
|
||||
sx={{
|
||||
background: alpha(primaryMain, 0.06),
|
||||
border: `1px solid ${alpha(primaryMain, 0.08)}`
|
||||
border: `1px solid ${alpha(primaryMain, 0.08)}`,
|
||||
}}
|
||||
>
|
||||
<i className={`${icon} text-2xl shrink-0`} style={{ color: primaryMain }} />
|
||||
<Box className='flex-1 min-w-0'>
|
||||
<Typography variant='caption' color='text.secondary'>
|
||||
<i
|
||||
className={`${icon} text-2xl shrink-0`}
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
<Box className="flex-1 min-w-0">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant='body1' fontWeight={600} color='text.primary'>
|
||||
<Typography variant="body1" fontWeight={600} color="text.primary">
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,286 +1,480 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Box from '@mui/material/Box'
|
||||
import Card from '@mui/material/Card'
|
||||
import CardContent from '@mui/material/CardContent'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Button from '@mui/material/Button'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import { useTheme, alpha } from '@mui/material/styles'
|
||||
import type { FarmInfo, CropOption, IrrigationPlan } from '@/libs/api/services/irrigationRecommendationService'
|
||||
import { irrigationRecommendationService } from '@/libs/api/services/irrigationRecommendationService'
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Box from "@mui/material/Box";
|
||||
import Card from "@mui/material/Card";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Button from "@mui/material/Button";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { useTheme, alpha } from "@mui/material/styles";
|
||||
import type {
|
||||
FarmInfo,
|
||||
CropOption,
|
||||
IrrigationPlan,
|
||||
WaterBalance,
|
||||
} from "@/libs/api/services/irrigationRecommendationService";
|
||||
import { irrigationRecommendationService } from "@/libs/api/services/irrigationRecommendationService";
|
||||
import { isRecommendationTaskRunning } from "@/libs/api/services/recommendationTask";
|
||||
|
||||
const DEFAULT_FARM_INFO: FarmInfo = {
|
||||
soilType: 'Loamy',
|
||||
waterQuality: 'Medium EC',
|
||||
climateZone: 'Temperate'
|
||||
}
|
||||
soilType: "Loamy",
|
||||
waterQuality: "Medium EC",
|
||||
climateZone: "Temperate",
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const getErrorMessage = (error: unknown, fallback: string) =>
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"message" in error &&
|
||||
typeof error.message === "string"
|
||||
? error.message
|
||||
: fallback;
|
||||
|
||||
export default function SmartIrrigationRecommendation() {
|
||||
const t = useTranslations('irrigation')
|
||||
const theme = useTheme()
|
||||
const [farmInfo, setFarmInfo] = useState<FarmInfo>(DEFAULT_FARM_INFO)
|
||||
const [cropOptions, setCropOptions] = useState<CropOption[]>([])
|
||||
const [configLoading, setConfigLoading] = useState(true)
|
||||
const [configError, setConfigError] = useState<string | null>(null)
|
||||
const [selectedCrop, setSelectedCrop] = useState<string | null>(null)
|
||||
const [plan, setPlan] = useState<IrrigationPlan | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const primaryMain = theme.palette.primary.main
|
||||
const primaryLight = theme.palette.primary.light
|
||||
const primaryDark = theme.palette.primary.dark
|
||||
const paperBg = theme.palette.background.paper
|
||||
const t = useTranslations("irrigation");
|
||||
const theme = useTheme();
|
||||
const [farmInfo, setFarmInfo] = useState<FarmInfo>(DEFAULT_FARM_INFO);
|
||||
const [cropOptions, setCropOptions] = useState<CropOption[]>([]);
|
||||
const [configLoading, setConfigLoading] = useState(true);
|
||||
const [configError, setConfigError] = useState<string | null>(null);
|
||||
const [selectedCrop, setSelectedCrop] = useState<string | null>(null);
|
||||
const [plan, setPlan] = useState<IrrigationPlan | null>(null);
|
||||
const [waterBalance, setWaterBalance] = useState<WaterBalance | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [requestError, setRequestError] = useState<string | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const primaryMain = theme.palette.primary.main;
|
||||
const primaryLight = theme.palette.primary.light;
|
||||
const primaryDark = theme.palette.primary.dark;
|
||||
const paperBg = theme.palette.background.paper;
|
||||
|
||||
useEffect(() => {
|
||||
irrigationRecommendationService
|
||||
.getConfig()
|
||||
.then(({ farmInfo: info, cropOptions: crops }) => {
|
||||
setFarmInfo(info)
|
||||
setCropOptions(crops.length > 0 ? crops : [])
|
||||
setFarmInfo(info);
|
||||
setCropOptions(crops.length > 0 ? crops : []);
|
||||
})
|
||||
.catch((err: { message?: string }) => {
|
||||
setConfigError(err?.message ?? 'Failed to load config')
|
||||
setConfigError(err?.message ?? "Failed to load config");
|
||||
})
|
||||
.finally(() => setConfigLoading(false))
|
||||
}, [])
|
||||
.finally(() => setConfigLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedCrop) return
|
||||
setLoading(true)
|
||||
setPlan(null)
|
||||
if (!selectedCrop) return;
|
||||
setLoading(true);
|
||||
setPlan(null);
|
||||
setWaterBalance(null);
|
||||
setRequestError(null);
|
||||
setStatusMessage(t("generating"));
|
||||
try {
|
||||
const { plan: nextPlan } = await irrigationRecommendationService.recommend({
|
||||
const recommendation = await irrigationRecommendationService.recommend({
|
||||
crop_id: selectedCrop,
|
||||
})
|
||||
setPlan(nextPlan)
|
||||
} catch {
|
||||
setPlan(null)
|
||||
farm_data: {
|
||||
soilType: farmInfo.soilType,
|
||||
waterQuality: farmInfo.waterQuality,
|
||||
climateZone: farmInfo.climateZone,
|
||||
},
|
||||
soilType: farmInfo.soilType,
|
||||
waterQuality: farmInfo.waterQuality,
|
||||
climateZone: farmInfo.climateZone,
|
||||
});
|
||||
|
||||
if ("task_id" in recommendation) {
|
||||
let attempts = 0;
|
||||
let taskStatus =
|
||||
await irrigationRecommendationService.getRecommendStatus(
|
||||
recommendation.task_id,
|
||||
);
|
||||
|
||||
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 irrigationRecommendationService.getRecommendStatus(
|
||||
recommendation.task_id,
|
||||
);
|
||||
}
|
||||
|
||||
if (taskStatus.status === "failed" || !taskStatus.result?.plan) {
|
||||
throw new Error(taskStatus.error ?? t("errors.generateFailed"));
|
||||
}
|
||||
|
||||
setPlan(taskStatus.result.plan);
|
||||
setWaterBalance(taskStatus.result.water_balance ?? null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recommendation.plan) {
|
||||
throw new Error(t("errors.generateFailed"));
|
||||
}
|
||||
|
||||
setPlan(recommendation.plan);
|
||||
setWaterBalance(recommendation.water_balance ?? null);
|
||||
} catch (error) {
|
||||
setPlan(null);
|
||||
setWaterBalance(null);
|
||||
setRequestError(getErrorMessage(error, t("errors.generateFailed")));
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
setStatusMessage(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const moistureLevelValue =
|
||||
typeof plan?.moistureLevel === "number"
|
||||
? plan.moistureLevel
|
||||
: Number(plan?.moistureLevel);
|
||||
const hasNumericMoistureLevel = Number.isFinite(moistureLevelValue);
|
||||
const nextWaterBalanceDay = waterBalance?.daily?.[0];
|
||||
|
||||
return (
|
||||
<Box
|
||||
className='min-bs-screen'
|
||||
className="min-bs-screen"
|
||||
sx={{
|
||||
background: (theme) =>
|
||||
`linear-gradient(165deg, ${alpha(theme.palette.primary.main, 0.08)} 0%, ${alpha(theme.palette.primary.main, 0.04)} 35%, ${alpha(theme.palette.primary.main, 0.02)} 70%, ${theme.palette.background.default} 100%)`,
|
||||
minHeight: '100vh'
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<Box className='max-w-lg mx-auto px-4 py-6 sm:py-8'>
|
||||
<Box className="max-w-lg mx-auto px-4 py-6 sm:py-8">
|
||||
{/* 1) Dynamic Header */}
|
||||
<Box className='mb-8'>
|
||||
<Box className="mb-8">
|
||||
<Typography
|
||||
variant='h4'
|
||||
className='font-bold tracking-tight'
|
||||
variant="h4"
|
||||
className="font-bold tracking-tight"
|
||||
sx={{
|
||||
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
fontSize: { xs: '1.5rem', sm: '1.75rem' }
|
||||
backgroundClip: "text",
|
||||
WebkitBackgroundClip: "text",
|
||||
color: "transparent",
|
||||
fontSize: { xs: "1.5rem", sm: "1.75rem" },
|
||||
}}
|
||||
>
|
||||
{t('title')}
|
||||
{t("title")}
|
||||
</Typography>
|
||||
<Typography variant='body2' color='text.secondary' className='mt-1'>
|
||||
{t('subtitle')}
|
||||
<Typography variant="body2" color="text.secondary" className="mt-1">
|
||||
{t("subtitle")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 2) Farm Info Card */}
|
||||
<Card
|
||||
elevation={0}
|
||||
className='mb-6 overflow-hidden transition-all duration-300 hover:shadow-lg'
|
||||
className="mb-6 overflow-hidden transition-all duration-300 hover:shadow-lg"
|
||||
sx={{
|
||||
borderRadius: '24px',
|
||||
borderRadius: "24px",
|
||||
background: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||
boxShadow: `0 4px 24px ${alpha(primaryMain, 0.08)}, 0 1px 3px rgba(0,0,0,0.04)`,
|
||||
border: `1px solid ${alpha(primaryMain, 0.12)}`
|
||||
border: `1px solid ${alpha(primaryMain, 0.12)}`,
|
||||
}}
|
||||
>
|
||||
<CardContent className='p-5'>
|
||||
<Box className='flex items-center justify-between mbe-4'>
|
||||
<Typography variant='subtitle2' fontWeight={600} color='text.secondary'>
|
||||
{t('farmInfo.title')}
|
||||
<CardContent className="p-5">
|
||||
<Box className="flex items-center justify-between mbe-4">
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={600}
|
||||
color="text.secondary"
|
||||
>
|
||||
{t("farmInfo.title")}
|
||||
</Typography>
|
||||
<Box className='flex items-center gap-2'>
|
||||
<Box className="flex items-center gap-2">
|
||||
<Box
|
||||
className='px-2.5 py-1 rounded-full text-xs font-medium'
|
||||
className="px-2.5 py-1 rounded-full text-xs font-medium"
|
||||
sx={{
|
||||
background: (t) => `linear-gradient(135deg, ${t.palette.success.main} 0%, ${t.palette.success.dark} 100%)`,
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
background: (t) =>
|
||||
`linear-gradient(135deg, ${t.palette.success.main} 0%, ${t.palette.success.dark} 100%)`,
|
||||
color: "white",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<i className='tabler-circle-check text-sm' />
|
||||
{t('verifiedBadge')}
|
||||
<i className="tabler-circle-check text-sm" />
|
||||
{t("verifiedBadge")}
|
||||
</Box>
|
||||
<IconButton size='small' sx={{ color: 'text.secondary' }} aria-label={t('editFarmInfo')}>
|
||||
<i className='tabler-pencil text-lg' />
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{ color: "text.secondary" }}
|
||||
aria-label={t("editFarmInfo")}
|
||||
>
|
||||
<i className="tabler-pencil text-lg" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box className='flex flex-wrap gap-3'>
|
||||
<FarmBadge icon='tabler-seedling' label={t('farmInfo.soilType')} value={farmInfo.soilType} />
|
||||
<FarmBadge icon='tabler-droplet' label={t('farmInfo.waterQuality')} value={farmInfo.waterQuality} />
|
||||
<FarmBadge icon='tabler-temperature' label={t('farmInfo.climateZone')} value={farmInfo.climateZone} />
|
||||
<Box className="flex flex-wrap gap-3">
|
||||
<FarmBadge
|
||||
icon="tabler-seedling"
|
||||
label={t("farmInfo.soilType")}
|
||||
value={farmInfo.soilType}
|
||||
/>
|
||||
<FarmBadge
|
||||
icon="tabler-droplet"
|
||||
label={t("farmInfo.waterQuality")}
|
||||
value={farmInfo.waterQuality}
|
||||
/>
|
||||
<FarmBadge
|
||||
icon="tabler-temperature"
|
||||
label={t("farmInfo.climateZone")}
|
||||
value={farmInfo.climateZone}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3) Plant Selection Section */}
|
||||
<Typography variant='subtitle2' fontWeight={600} color='text.secondary' className='mbe-3'>
|
||||
{t('plantSelection.title')}
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={600}
|
||||
color="text.secondary"
|
||||
className="mbe-3"
|
||||
>
|
||||
{t("plantSelection.title")}
|
||||
</Typography>
|
||||
{configLoading ? (
|
||||
<Box className='flex justify-center py-8'>
|
||||
<CircularProgress size={32} sx={{ color: 'primary.main' }} />
|
||||
<Box className="flex justify-center py-8">
|
||||
<CircularProgress size={32} sx={{ color: "primary.main" }} />
|
||||
</Box>
|
||||
) : configError ? (
|
||||
<Typography variant='body2' color='error' className='mb-6'>
|
||||
<Typography variant="body2" color="error" className="mb-6">
|
||||
{configError}
|
||||
</Typography>
|
||||
) : (
|
||||
<Box className='flex flex-wrap gap-3 mb-6'>
|
||||
{(cropOptions.length > 0 ? cropOptions : []).map(crop => (
|
||||
<CropCard
|
||||
key={crop.id}
|
||||
crop={crop}
|
||||
label={t(`crops.${crop.labelKey}`)}
|
||||
selected={selectedCrop === crop.id}
|
||||
onClick={() => setSelectedCrop(prev => (prev === crop.id ? null : crop.id))}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box className="flex flex-wrap gap-3 mb-6">
|
||||
{(cropOptions.length > 0 ? cropOptions : []).map((crop) => (
|
||||
<CropCard
|
||||
key={crop.id}
|
||||
crop={crop}
|
||||
label={t(`crops.${crop.labelKey}`)}
|
||||
selected={selectedCrop === crop.id}
|
||||
onClick={() =>
|
||||
setSelectedCrop((prev) => (prev === crop.id ? null : crop.id))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 4) Primary CTA Button - End of form */}
|
||||
<Box className='mb-8'>
|
||||
<Box className="mb-8">
|
||||
<Button
|
||||
fullWidth
|
||||
variant='contained'
|
||||
variant="contained"
|
||||
disabled={!selectedCrop || loading || configLoading}
|
||||
onClick={handleGenerate}
|
||||
startIcon={<i className='tabler-sparkles text-xl' />}
|
||||
className='rounded-2xl py-3.5 text-base font-semibold shadow-lg transition-all duration-300 hover:shadow-xl hover:scale-[1.01] active:scale-[0.99]'
|
||||
startIcon={<i className="tabler-sparkles text-xl" />}
|
||||
className="rounded-2xl py-3.5 text-base font-semibold shadow-lg transition-all duration-300 hover:shadow-xl hover:scale-[1.01] active:scale-[0.99]"
|
||||
sx={{
|
||||
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
||||
boxShadow: `0 4px 20px ${alpha(primaryMain, 0.4)}`,
|
||||
'&:hover': {
|
||||
"&:hover": {
|
||||
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
||||
boxShadow: `0 6px 28px ${alpha(primaryMain, 0.5)}`,
|
||||
filter: 'brightness(1.05)'
|
||||
filter: "brightness(1.05)",
|
||||
},
|
||||
"&:disabled": {
|
||||
background: "action.disabledBackground",
|
||||
color: "action.disabled",
|
||||
},
|
||||
'&:disabled': {
|
||||
background: 'action.disabledBackground',
|
||||
color: 'action.disabled'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('generateCta')}
|
||||
{t("generateCta")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{requestError && !loading && (
|
||||
<Typography variant="body2" color="error" className="mb-6">
|
||||
{requestError}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 5) Result Card (after click) */}
|
||||
{plan && (
|
||||
<Box className='mb-6 animate-fade-in'>
|
||||
<Box className="mb-6 animate-fade-in">
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: '24px',
|
||||
borderRadius: "24px",
|
||||
background: `linear-gradient(160deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 100%)`,
|
||||
boxShadow: `0 8px 32px ${alpha(primaryMain, 0.15)}, 0 2px 8px rgba(0,0,0,0.06)`,
|
||||
border: `1px solid ${alpha(primaryMain, 0.18)}`,
|
||||
overflow: 'visible'
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
<CardContent className='p-6'>
|
||||
<CardContent className="p-6">
|
||||
{/* Circular moisture indicator */}
|
||||
<Box className='flex justify-center mbe-6'>
|
||||
<Box className='relative'>
|
||||
<svg width={120} height={120} className='-rotate-90'>
|
||||
<circle
|
||||
cx={60}
|
||||
cy={60}
|
||||
r={52}
|
||||
fill='none'
|
||||
stroke={alpha(primaryMain, 0.12)}
|
||||
strokeWidth={10}
|
||||
/>
|
||||
<circle
|
||||
cx={60}
|
||||
cy={60}
|
||||
r={52}
|
||||
fill='none'
|
||||
stroke='url(#moistureGradient)'
|
||||
strokeWidth={10}
|
||||
strokeLinecap='round'
|
||||
strokeDasharray={`${(plan.moistureLevel / 100) * 327} 327`}
|
||||
className='transition-all duration-1000 ease-out'
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id='moistureGradient' x1='0%' y1='0%' x2='100%' y2='100%'>
|
||||
<stop offset='0%' stopColor={primaryLight} />
|
||||
<stop offset='100%' stopColor={primaryMain} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<Box className="flex justify-center mbe-6">
|
||||
{hasNumericMoistureLevel ? (
|
||||
<Box className="relative">
|
||||
<svg width={120} height={120} className="-rotate-90">
|
||||
<circle
|
||||
cx={60}
|
||||
cy={60}
|
||||
r={52}
|
||||
fill="none"
|
||||
stroke={alpha(primaryMain, 0.12)}
|
||||
strokeWidth={10}
|
||||
/>
|
||||
<circle
|
||||
cx={60}
|
||||
cy={60}
|
||||
r={52}
|
||||
fill="none"
|
||||
stroke="url(#moistureGradient)"
|
||||
strokeWidth={10}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${(moistureLevelValue / 100) * 327} 327`}
|
||||
className="transition-all duration-1000 ease-out"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="moistureGradient"
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="100%"
|
||||
>
|
||||
<stop offset="0%" stopColor={primaryLight} />
|
||||
<stop offset="100%" stopColor={primaryMain} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<Box
|
||||
className="absolute inset-0 flex flex-col items-center justify-center"
|
||||
sx={{ top: 0, left: 0, right: 0, bottom: 0 }}
|
||||
>
|
||||
<i
|
||||
className="tabler-droplet text-3xl mbe-0.5"
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
<Typography
|
||||
variant="h4"
|
||||
fontWeight={700}
|
||||
color="primary.main"
|
||||
>
|
||||
{moistureLevelValue}%
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("result.moistureLevel")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
className='absolute inset-0 flex flex-col items-center justify-center'
|
||||
sx={{ top: 0, left: 0, right: 0, bottom: 0 }}
|
||||
className="flex flex-col items-center justify-center px-8 py-6 rounded-[28px] text-center"
|
||||
sx={{
|
||||
minWidth: 180,
|
||||
background: `linear-gradient(145deg, ${alpha(primaryMain, 0.12)} 0%, ${alpha(primaryMain, 0.05)} 100%)`,
|
||||
border: `1px solid ${alpha(primaryMain, 0.15)}`,
|
||||
}}
|
||||
>
|
||||
<i className='tabler-droplet text-3xl mbe-0.5' style={{ color: primaryMain }} />
|
||||
<Typography variant='h4' fontWeight={700} color='primary.main'>
|
||||
{plan.moistureLevel}%
|
||||
<i
|
||||
className="tabler-droplet text-4xl mbe-2"
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
<Typography
|
||||
variant="h5"
|
||||
fontWeight={700}
|
||||
color="primary.main"
|
||||
className="mbe-1"
|
||||
>
|
||||
{String(plan.moistureLevel)}
|
||||
</Typography>
|
||||
<Typography variant='caption' color='text.secondary'>
|
||||
{t('result.moistureLevel')}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("result.moistureLevel")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box className='space-y-4'>
|
||||
<Box className="space-y-4">
|
||||
<ResultRow
|
||||
icon='tabler-calendar-week'
|
||||
label={t('result.frequency')}
|
||||
value={`${plan.frequencyPerWeek} ${t('result.timesPerWeek')}`}
|
||||
icon="tabler-calendar-week"
|
||||
label={t("result.frequency")}
|
||||
value={`${plan.frequencyPerWeek} ${t("result.timesPerWeek")}`}
|
||||
/>
|
||||
<ResultRow
|
||||
icon='tabler-clock'
|
||||
label={t('result.duration')}
|
||||
value={`${plan.durationMinutes} ${t('result.minutes')}`}
|
||||
icon="tabler-clock"
|
||||
label={t("result.duration")}
|
||||
value={`${plan.durationMinutes} ${t("result.minutes")}`}
|
||||
/>
|
||||
<ResultRow
|
||||
icon='tabler-sunrise'
|
||||
label={t('result.bestTime')}
|
||||
icon="tabler-sunrise"
|
||||
label={t("result.bestTime")}
|
||||
value={plan.bestTimeOfDay}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{waterBalance && (
|
||||
<Box className="mt-5 space-y-4">
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={600}
|
||||
color="text.secondary"
|
||||
>
|
||||
{t("result.waterBalance")}
|
||||
</Typography>
|
||||
{nextWaterBalanceDay && (
|
||||
<>
|
||||
<ResultRow
|
||||
icon="tabler-calendar-event"
|
||||
label={t("result.forecastDate")}
|
||||
value={nextWaterBalanceDay.forecast_date}
|
||||
/>
|
||||
<ResultRow
|
||||
icon="tabler-droplet"
|
||||
label={t("result.grossIrrigation")}
|
||||
value={`${nextWaterBalanceDay.gross_irrigation_mm} mm`}
|
||||
/>
|
||||
<ResultRow
|
||||
icon="tabler-clock-hour-6"
|
||||
label={t("result.irrigationTiming")}
|
||||
value={nextWaterBalanceDay.irrigation_timing}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{typeof waterBalance.active_kc === "number" && (
|
||||
<ResultRow
|
||||
icon="tabler-chart-line"
|
||||
label={t("result.activeKc")}
|
||||
value={String(waterBalance.active_kc)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{plan.warning && (
|
||||
<Box
|
||||
className='mt-4 p-4 rounded-2xl'
|
||||
className="mt-4 p-4 rounded-2xl"
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, rgba(251, 191, 36, 0.12) 0%, rgba(245, 158, 11, 0.08) 100%)',
|
||||
border: '1px solid rgba(251, 191, 36, 0.35)'
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(251, 191, 36, 0.12) 0%, rgba(245, 158, 11, 0.08) 100%)",
|
||||
border: "1px solid rgba(251, 191, 36, 0.35)",
|
||||
}}
|
||||
>
|
||||
<Box className='flex gap-2'>
|
||||
<i className='tabler-alert-triangle text-xl text-amber-600 mt-0.5 shrink-0' />
|
||||
<Box className="flex gap-2">
|
||||
<i className="tabler-alert-triangle text-xl text-amber-600 mt-0.5 shrink-0" />
|
||||
<Box>
|
||||
<Typography variant='subtitle2' fontWeight={600} color='warning.dark' className='mbe-1'>
|
||||
{t('result.smartWarning')}
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={600}
|
||||
color="warning.dark"
|
||||
className="mbe-1"
|
||||
>
|
||||
{t("result.smartWarning")}
|
||||
</Typography>
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{plan.warning}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -296,25 +490,25 @@ export default function SmartIrrigationRecommendation() {
|
||||
{loading && (
|
||||
<Card
|
||||
elevation={0}
|
||||
className='mb-6'
|
||||
className="mb-6"
|
||||
sx={{
|
||||
borderRadius: '24px',
|
||||
borderRadius: "24px",
|
||||
background: `linear-gradient(160deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 100%)`,
|
||||
boxShadow: `0 8px 32px ${alpha(primaryMain, 0.1)}`,
|
||||
border: `1px solid ${alpha(primaryMain, 0.12)}`
|
||||
border: `1px solid ${alpha(primaryMain, 0.12)}`,
|
||||
}}
|
||||
>
|
||||
<CardContent className='p-12 flex flex-col items-center gap-4'>
|
||||
<CircularProgress size={48} sx={{ color: 'primary.main' }} />
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
{t('generating')}
|
||||
<CardContent className="p-12 flex flex-col items-center gap-4">
|
||||
<CircularProgress size={48} sx={{ color: "primary.main" }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{statusMessage ?? t("generating")}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||
@@ -322,116 +516,137 @@ export default function SmartIrrigationRecommendation() {
|
||||
function FarmBadge({
|
||||
icon,
|
||||
label,
|
||||
value
|
||||
value,
|
||||
}: {
|
||||
icon: string
|
||||
label: string
|
||||
value: string
|
||||
icon: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const primaryMain = theme.palette.primary.main
|
||||
const theme = useTheme();
|
||||
const primaryMain = theme.palette.primary.main;
|
||||
return (
|
||||
<Box
|
||||
className='flex items-center gap-2 px-4 py-2.5 rounded-2xl transition-transform duration-200 hover:scale-[1.02]'
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-2xl transition-transform duration-200 hover:scale-[1.02]"
|
||||
sx={{
|
||||
background: `linear-gradient(145deg, ${alpha(primaryMain, 0.08)} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||
border: `1px solid ${alpha(primaryMain, 0.15)}`,
|
||||
boxShadow: 'inset 0 1px 2px rgba(255,255,255,0.5)'
|
||||
boxShadow: "inset 0 1px 2px rgba(255,255,255,0.5)",
|
||||
}}
|
||||
>
|
||||
<i className={`${icon} text-xl`} style={{ color: primaryMain }} />
|
||||
<Box>
|
||||
<Typography variant='caption' color='text.secondary' display='block' lineHeight={1.2}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
display="block"
|
||||
lineHeight={1.2}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant='body2' fontWeight={600} color='text.primary'>
|
||||
<Typography variant="body2" fontWeight={600} color="text.primary">
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CropCard({
|
||||
crop,
|
||||
label,
|
||||
selected,
|
||||
onClick
|
||||
onClick,
|
||||
}: {
|
||||
crop: CropOption
|
||||
label: string
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
crop: CropOption;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const primaryMain = theme.palette.primary.main
|
||||
const primaryDark = theme.palette.primary.dark
|
||||
const paperBg = theme.palette.background.paper
|
||||
const theme = useTheme();
|
||||
const primaryMain = theme.palette.primary.main;
|
||||
const primaryDark = theme.palette.primary.dark;
|
||||
const paperBg = theme.palette.background.paper;
|
||||
return (
|
||||
<Card
|
||||
component='button'
|
||||
type='button'
|
||||
component="button"
|
||||
type="button"
|
||||
elevation={0}
|
||||
onClick={onClick}
|
||||
className='flex items-center gap-3 px-4 py-3 rounded-2xl cursor-pointer transition-all duration-300 border-2 text-start'
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-2xl cursor-pointer transition-all duration-300 border-2 text-start"
|
||||
sx={{
|
||||
borderColor: selected ? primaryMain : 'transparent',
|
||||
borderColor: selected ? primaryMain : "transparent",
|
||||
background: selected
|
||||
? `linear-gradient(145deg, ${alpha(primaryMain, 0.12)} 0%, ${alpha(primaryMain, 0.06)} 100%)`
|
||||
: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||
boxShadow: selected
|
||||
? `0 4px 20px ${alpha(primaryMain, 0.2)}, inset 0 1px 0 rgba(255,255,255,0.8)`
|
||||
: '0 2px 8px rgba(0,0,0,0.04), inset 0 1px 0 rgba(255,255,255,0.9)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
: "0 2px 8px rgba(0,0,0,0.04), inset 0 1px 0 rgba(255,255,255,0.9)",
|
||||
"&:hover": {
|
||||
transform: "translateY(-2px)",
|
||||
boxShadow: selected
|
||||
? `0 6px 24px ${alpha(primaryMain, 0.25)}`
|
||||
: `0 4px 16px ${alpha(primaryMain, 0.12)}`
|
||||
}
|
||||
: `0 4px 16px ${alpha(primaryMain, 0.12)}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className='w-11 h-11 rounded-xl flex items-center justify-center shrink-0'
|
||||
className="w-11 h-11 rounded-xl flex items-center justify-center shrink-0"
|
||||
sx={{
|
||||
background: selected
|
||||
? `linear-gradient(135deg, ${primaryMain} 0%, ${primaryDark} 100%)`
|
||||
: `linear-gradient(145deg, ${alpha(primaryMain, 0.1)} 0%, ${alpha(primaryMain, 0.05)} 100%)`
|
||||
: `linear-gradient(145deg, ${alpha(primaryMain, 0.1)} 0%, ${alpha(primaryMain, 0.05)} 100%)`,
|
||||
}}
|
||||
>
|
||||
<i className={`${crop.icon} text-xl ${selected ? 'text-white' : ''}`} style={!selected ? { color: primaryMain } : undefined} />
|
||||
<i
|
||||
className={`${crop.icon} text-xl ${selected ? "text-white" : ""}`}
|
||||
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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ResultRow({
|
||||
icon,
|
||||
label,
|
||||
value
|
||||
value,
|
||||
}: {
|
||||
icon: string
|
||||
label: string
|
||||
value: string
|
||||
icon: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const primaryMain = theme.palette.primary.main
|
||||
const theme = useTheme();
|
||||
const primaryMain = theme.palette.primary.main;
|
||||
return (
|
||||
<Box className='flex items-center gap-4 p-3 rounded-2xl' sx={{ bgcolor: alpha(primaryMain, 0.06) }}>
|
||||
<i className={`${icon} text-2xl shrink-0`} style={{ color: primaryMain }} />
|
||||
<Box className='flex-1 min-w-0'>
|
||||
<Typography variant='caption' color='text.secondary'>
|
||||
<Box
|
||||
className="flex items-center gap-4 p-3 rounded-2xl"
|
||||
sx={{ bgcolor: alpha(primaryMain, 0.06) }}
|
||||
>
|
||||
<i
|
||||
className={`${icon} text-2xl shrink-0`}
|
||||
style={{ color: primaryMain }}
|
||||
/>
|
||||
<Box className="flex-1 min-w-0">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant='body1' fontWeight={600} color='text.primary'>
|
||||
<Typography variant="body1" fontWeight={600} color="text.primary">
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user