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
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ RUN rm -f /etc/apt/sources.list /etc/apt/sources.list.d/* && \
|
|||||||
|
|
||||||
# npm mirrors (Iranian)
|
# npm mirrors (Iranian)
|
||||||
RUN npm config set registry https://package-mirror.liara.ir/repository/npm/ && \
|
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 @chabokan:registry https://mirror2.chabokan.net/npm/ && \
|
||||||
npm config set strict-ssl false && \
|
npm config set strict-ssl false && \
|
||||||
npm config set fetch-retries 5 && \
|
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": "ویزارد فرم",
|
"formWizard": "ویزارد فرم",
|
||||||
"apexCharts": "چارت Apex",
|
"apexCharts": "چارت Apex",
|
||||||
"analytics": "تحلیلها",
|
"analytics": "تحلیلها",
|
||||||
"todo": "وظایف",
|
|
||||||
"accountSettings": "تنظیمات حساب",
|
"accountSettings": "تنظیمات حساب",
|
||||||
"faq": "سوالات متداول",
|
"faq": "سوالات متداول",
|
||||||
"pricing": "قیمتگذاری",
|
"pricing": "قیمتگذاری",
|
||||||
@@ -532,7 +531,18 @@
|
|||||||
"cropZoning": {
|
"cropZoning": {
|
||||||
"title": "زونبندی پیشنهادی کشت",
|
"title": "زونبندی پیشنهادی کشت",
|
||||||
"drawLand": "زمین خود را روی نقشه با Polygon رسم کنید",
|
"drawLand": "زمین خود را روی نقشه با Polygon رسم کنید",
|
||||||
|
"loadingArea": "در حال دریافت محدوده زمین...",
|
||||||
"optimizeAgain": "بهینهسازی دوباره",
|
"optimizeAgain": "بهینهسازی دوباره",
|
||||||
|
"taskStatus": {
|
||||||
|
"pending": "تسک در صف پردازش است...",
|
||||||
|
"processing": "در حال پردازش محدوده زمین...",
|
||||||
|
"completed": "پردازش محدوده زمین کامل شد.",
|
||||||
|
"failed": "پردازش محدوده زمین ناموفق بود."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"areaLoadFailed": "بارگذاری محدوده زمین با خطا مواجه شد.",
|
||||||
|
"timeout": "دریافت نتیجه محدوده زمین بیش از حد طول کشید. دوباره تلاش کنید."
|
||||||
|
},
|
||||||
"layers": {
|
"layers": {
|
||||||
"crops": "محصولات پیشنهادی",
|
"crops": "محصولات پیشنهادی",
|
||||||
"waterNeed": "نیاز آبی",
|
"waterNeed": "نیاز آبی",
|
||||||
@@ -625,6 +635,10 @@
|
|||||||
},
|
},
|
||||||
"generateCta": "تولید برنامه آبیاری",
|
"generateCta": "تولید برنامه آبیاری",
|
||||||
"generating": "در حال تحلیل و تولید برنامه آبیاری...",
|
"generating": "در حال تحلیل و تولید برنامه آبیاری...",
|
||||||
|
"errors": {
|
||||||
|
"generateFailed": "دریافت برنامه آبیاری با خطا مواجه شد.",
|
||||||
|
"timeout": "دریافت نتیجه بیش از حد طول کشید. دوباره تلاش کنید."
|
||||||
|
},
|
||||||
"result": {
|
"result": {
|
||||||
"moistureLevel": "سطح رطوبت هدف",
|
"moistureLevel": "سطح رطوبت هدف",
|
||||||
"frequency": "تناوب آبیاری",
|
"frequency": "تناوب آبیاری",
|
||||||
@@ -632,7 +646,12 @@
|
|||||||
"duration": "مدت هر نوبت",
|
"duration": "مدت هر نوبت",
|
||||||
"minutes": "دقیقه",
|
"minutes": "دقیقه",
|
||||||
"bestTime": "بهترین زمان آبیاری",
|
"bestTime": "بهترین زمان آبیاری",
|
||||||
"smartWarning": "هشدار هوشمند"
|
"smartWarning": "هشدار هوشمند",
|
||||||
|
"waterBalance": "جزئیات تراز آب",
|
||||||
|
"forecastDate": "تاریخ پیشبینی",
|
||||||
|
"grossIrrigation": "نیاز آبیاری ناخالص",
|
||||||
|
"irrigationTiming": "زمان آبیاری",
|
||||||
|
"activeKc": "ضریب رشد فعال"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fertilization": {
|
"fertilization": {
|
||||||
@@ -666,6 +685,10 @@
|
|||||||
},
|
},
|
||||||
"generateCta": "تولید برنامه کوددهی",
|
"generateCta": "تولید برنامه کوددهی",
|
||||||
"generating": "در حال تحلیل و تولید نسخه تغذیهای...",
|
"generating": "در حال تحلیل و تولید نسخه تغذیهای...",
|
||||||
|
"errors": {
|
||||||
|
"generateFailed": "دریافت برنامه کوددهی با خطا مواجه شد.",
|
||||||
|
"timeout": "دریافت نتیجه بیش از حد طول کشید. دوباره تلاش کنید."
|
||||||
|
},
|
||||||
"result": {
|
"result": {
|
||||||
"title": "نسخه تغذیه گیاه",
|
"title": "نسخه تغذیه گیاه",
|
||||||
"fertilizerType": "نوع کود توصیهشده",
|
"fertilizerType": "نوع کود توصیهشده",
|
||||||
@@ -737,7 +760,15 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"contextLoad": "بارگذاری زمینه مزرعه ناموفق بود.",
|
"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
|
* @see CROP_ZONING_APIS.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Feature, FeatureCollection, Polygon } from 'geojson'
|
import type { Feature, FeatureCollection, Polygon } from "geojson";
|
||||||
import { apiClient } from '../client'
|
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 {
|
export interface Product {
|
||||||
id: string
|
id: string;
|
||||||
label: string
|
label: string;
|
||||||
color: string
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ZoneInitialData {
|
export interface ZoneInitialData {
|
||||||
zoneId: string
|
zoneId: string;
|
||||||
geometry: Polygon
|
geometry: Polygon;
|
||||||
/** اگر null/خالی/uncultivable باشد، زون غیرقابل کشت و خاکستری نمایش داده میشود */
|
/** اگر null/خالی/uncultivable باشد، زون غیرقابل کشت و خاکستری نمایش داده میشود */
|
||||||
crop?: string | null
|
crop?: string | null;
|
||||||
matchPercent?: number | null
|
matchPercent?: number | null;
|
||||||
waterNeed?: string | null
|
waterNeed?: string | null;
|
||||||
estimatedProfit?: string | null
|
estimatedProfit?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ZonesInitialResponse {
|
export interface ZonesInitialResponse {
|
||||||
total_area_hectares: number
|
total_area_hectares: number;
|
||||||
total_area_sqm: number
|
total_area_sqm: number;
|
||||||
zone_count: number
|
zone_count: number;
|
||||||
zones: ZoneInitialData[]
|
zones: ZoneInitialData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AreaResponse {
|
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 {
|
export interface ZoneDetailData {
|
||||||
zoneId: string
|
zoneId: string;
|
||||||
crop: string
|
crop: string;
|
||||||
matchPercent: number
|
matchPercent: number;
|
||||||
waterNeed: string
|
waterNeed: string;
|
||||||
estimatedProfit: string
|
estimatedProfit: string;
|
||||||
reason: string
|
reason: string;
|
||||||
criteria: { name: string; value: number }[]
|
criteria: { name: string; value: number }[];
|
||||||
area_hectares?: number
|
area_hectares?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** دیتای نیاز آبی هر زون — لایهٔ نیاز آبی */
|
/** دیتای نیاز آبی هر زون — لایهٔ نیاز آبی */
|
||||||
export interface ZoneWaterNeedData {
|
export interface ZoneWaterNeedData {
|
||||||
zoneId: string
|
zoneId: string;
|
||||||
geometry: Polygon
|
geometry: Polygon;
|
||||||
level: 'low' | 'medium' | 'high'
|
level: "low" | "medium" | "high";
|
||||||
value?: string
|
value?: string;
|
||||||
color: string
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** دیتای کیفیت خاک هر زون — لایهٔ کیفیت خاک */
|
/** دیتای کیفیت خاک هر زون — لایهٔ کیفیت خاک */
|
||||||
export interface ZoneSoilQualityData {
|
export interface ZoneSoilQualityData {
|
||||||
zoneId: string
|
zoneId: string;
|
||||||
geometry: Polygon
|
geometry: Polygon;
|
||||||
level: 'low' | 'medium' | 'high'
|
level: "low" | "medium" | "high";
|
||||||
score?: number
|
score?: number;
|
||||||
color: string
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** دیتای ریسک کشت هر زون — لایهٔ ریسک کشت */
|
/** دیتای ریسک کشت هر زون — لایهٔ ریسک کشت */
|
||||||
export interface ZoneCultivationRiskData {
|
export interface ZoneCultivationRiskData {
|
||||||
zoneId: string
|
zoneId: string;
|
||||||
geometry: Polygon
|
geometry: Polygon;
|
||||||
level: 'low' | 'medium' | 'high'
|
level: "low" | "medium" | "high";
|
||||||
color: string
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** دادهٔ نمایشی هر زون روی نقشه — خروجی تبدیل از تمام لایهها */
|
/** دادهٔ نمایشی هر زون روی نقشه — خروجی تبدیل از تمام لایهها */
|
||||||
export interface ZoneMapData {
|
export interface ZoneMapData {
|
||||||
zoneId: string
|
zoneId: string;
|
||||||
geometry: Polygon
|
geometry: Polygon;
|
||||||
color: string
|
color: string;
|
||||||
tooltipContent: string
|
tooltipContent: string;
|
||||||
cultivable: boolean
|
cultivable: boolean;
|
||||||
zoneInitialData?: ZoneInitialData
|
zoneInitialData?: ZoneInitialData;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
status: string
|
status: string;
|
||||||
data: T
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unwrap<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
|
async function unwrap<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
|
||||||
const res = await promise
|
const res = await promise;
|
||||||
return res.data
|
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 = {
|
export const cropZoningService = {
|
||||||
getProducts(): Promise<{ products: Product[] }> {
|
getProducts(): Promise<{ products: Product[] }> {
|
||||||
return unwrap(apiClient.get<ApiResponse<{ products: Product[] }>>(`${PREFIX}/products/`))
|
return unwrap(
|
||||||
|
apiClient.get<ApiResponse<{ products: Product[] }>>(
|
||||||
|
`${PREFIX}/products/`,
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
getZonesInitial(body: {
|
getZonesInitial(body: {
|
||||||
zones: FeatureCollection<Polygon>
|
zones: FeatureCollection<Polygon>;
|
||||||
products?: string[]
|
products?: string[];
|
||||||
}): Promise<ZonesInitialResponse> {
|
}): 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> {
|
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> {
|
getArea(sensorUuid: string): Promise<CropZoningAreaResponse> {
|
||||||
return unwrap(apiClient.get<ApiResponse<AreaResponse>>(`${PREFIX}/area/`))
|
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(
|
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(
|
return unwrap(
|
||||||
apiClient.post<ApiResponse<{ zones: ZoneSoilQualityData[] }>>(`${PREFIX}/zones/soil-quality/`, body)
|
apiClient.post<ApiResponse<{ zones: ZoneSoilQualityData[] }>>(
|
||||||
)
|
`${PREFIX}/zones/soil-quality/`,
|
||||||
|
body,
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** ریسک کشت هر منطقه — برای لایهٔ ریسک کشت */
|
/** ریسک کشت هر منطقه — برای لایهٔ ریسک کشت */
|
||||||
getZonesCultivationRisk(body: {
|
getZonesCultivationRisk(body: {
|
||||||
zones: FeatureCollection<Polygon>
|
zones: FeatureCollection<Polygon>;
|
||||||
}): Promise<{ zones: ZoneCultivationRiskData[] }> {
|
}): Promise<{ zones: ZoneCultivationRiskData[] }> {
|
||||||
return unwrap(
|
return unwrap(
|
||||||
apiClient.post<ApiResponse<{ zones: ZoneCultivationRiskData[] }>>(
|
apiClient.post<ApiResponse<{ zones: ZoneCultivationRiskData[] }>>(
|
||||||
`${PREFIX}/zones/cultivation-risk/`,
|
`${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 { 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'
|
const PREFIX = '/api/farm-ai-assistant'
|
||||||
|
|
||||||
@@ -29,17 +24,57 @@ export interface ChatSection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatPayload {
|
export interface ChatPayload {
|
||||||
content: string
|
content?: string
|
||||||
farm_context?: FarmContext
|
|
||||||
images?: string[]
|
images?: string[]
|
||||||
conversation_id?: 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
|
message_id: string
|
||||||
conversation_id: string
|
conversation_id: string
|
||||||
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
sections: ChatSection[]
|
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> {
|
interface ApiResponse<T> {
|
||||||
@@ -52,21 +87,33 @@ function unwrap<T>(res: ApiResponse<T>): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const farmAiAssistantService = {
|
export const farmAiAssistantService = {
|
||||||
/**
|
|
||||||
* Returns farm context for the context bar (soilType, waterEC, selectedCrop, growthStage, lastIrrigationStatus).
|
|
||||||
*/
|
|
||||||
getContext(): Promise<FarmContextResponse> {
|
getContext(): Promise<FarmContextResponse> {
|
||||||
return apiClient
|
return apiClient.get<ApiResponse<FarmContextResponse>>(`${PREFIX}/context/`).then(unwrap)
|
||||||
.get<ApiResponse<FarmContextResponse>>(`${PREFIX}/context/`)
|
|
||||||
.then(unwrap)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
createChatTask(payload: ChatPayload): Promise<ChatTaskInitResponse> {
|
||||||
* Send user message (and optional farm_context, images, conversation_id). Returns message with sections.
|
return apiClient.post<ApiResponse<ChatTaskInitResponse>>(`${PREFIX}/chat/task/`, payload).then(unwrap)
|
||||||
*/
|
},
|
||||||
chat(payload: ChatPayload): Promise<ChatResponseData> {
|
|
||||||
|
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
|
return apiClient
|
||||||
.post<ApiResponse<ChatResponseData>>(`${PREFIX}/chat/`, payload)
|
.get<ApiResponse<ConversationMessagesResponse>>(`${PREFIX}/chats/${conversationId}/messages/`)
|
||||||
.then(unwrap)
|
.then(unwrap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,65 +3,135 @@
|
|||||||
* @see RECOMMENDATION_APIS.md
|
* @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 {
|
export interface FarmData {
|
||||||
soilType: string
|
soilType: string;
|
||||||
organicMatter: string
|
organicMatter: string;
|
||||||
waterEC: string
|
waterEC: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GrowthStage {
|
export interface GrowthStage {
|
||||||
id: string
|
id: string;
|
||||||
icon: string
|
icon: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CropOption {
|
export interface CropOption {
|
||||||
id: string
|
id: string;
|
||||||
labelKey: string
|
labelKey: string;
|
||||||
icon: string
|
icon: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FertilizationConfigResponse {
|
export interface FertilizationConfigResponse {
|
||||||
farmData: FarmData
|
farmData: FarmData;
|
||||||
growthStages: GrowthStage[]
|
growthStages: GrowthStage[];
|
||||||
cropOptions: CropOption[]
|
cropOptions: CropOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FertilizationPlan {
|
export interface FertilizationPlan {
|
||||||
npkRatio: string
|
npkRatio: string;
|
||||||
amountPerHectare: string
|
amountPerHectare: string;
|
||||||
applicationMethod: string
|
applicationMethod: string;
|
||||||
applicationInterval: string
|
applicationInterval: string;
|
||||||
reasoning: string
|
reasoning: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FertilizationRecommendPayload {
|
export interface FertilizationRecommendPayload {
|
||||||
crop_id?: string
|
crop_id?: string;
|
||||||
growth_stage?: string
|
growth_stage?: string;
|
||||||
soilType?: string
|
farm_data?: Partial<FarmData>;
|
||||||
organicMatter?: string
|
soilType?: string;
|
||||||
waterEC?: string
|
organicMatter?: string;
|
||||||
|
waterEC?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FertilizationRecommendationResult {
|
||||||
|
plan: FertilizationPlan;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FertilizationRecommendResponse =
|
||||||
|
| FertilizationRecommendationResult
|
||||||
|
| RecommendationTaskInitResponse;
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
status: string
|
status: string;
|
||||||
data: T
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unwrap<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
|
async function unwrap<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
|
||||||
const res = await promise
|
const res = await promise;
|
||||||
return res.data
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTaskInitResponse(
|
||||||
|
task: RecommendationTaskInitResponse,
|
||||||
|
): RecommendationTaskInitResponse {
|
||||||
|
return {
|
||||||
|
...task,
|
||||||
|
status: normalizeRecommendationTaskStatus(task.status),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRecommendationResult(
|
||||||
|
result: FertilizationRecommendationResult,
|
||||||
|
): FertilizationRecommendationResult {
|
||||||
|
return result.status
|
||||||
|
? {
|
||||||
|
...result,
|
||||||
|
status: normalizeRecommendationTaskStatus(result.status),
|
||||||
|
}
|
||||||
|
: result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fertilizationRecommendationService = {
|
export const fertilizationRecommendationService = {
|
||||||
getConfig(): Promise<FertilizationConfigResponse> {
|
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 }> {
|
recommend(
|
||||||
return unwrap(apiClient.post<ApiResponse<{ plan: FertilizationPlan }>>(`${PREFIX}/recommend/`, payload ?? {}))
|
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
|
* @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 {
|
export interface FarmInfo {
|
||||||
soilType: string
|
soilType: string;
|
||||||
waterQuality: string
|
waterQuality: string;
|
||||||
climateZone: string
|
climateZone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CropOption {
|
export interface CropOption {
|
||||||
id: string
|
id: string;
|
||||||
labelKey: string
|
labelKey: string;
|
||||||
icon: string
|
icon: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IrrigationConfigResponse {
|
export interface IrrigationConfigResponse {
|
||||||
farmInfo: FarmInfo
|
farmInfo: FarmInfo;
|
||||||
cropOptions: CropOption[]
|
cropOptions: CropOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IrrigationPlan {
|
export interface IrrigationPlan {
|
||||||
frequencyPerWeek: number
|
frequencyPerWeek: number | string;
|
||||||
durationMinutes: number
|
durationMinutes: number | string;
|
||||||
bestTimeOfDay: string
|
bestTimeOfDay: string;
|
||||||
moistureLevel: number
|
moistureLevel: number | string;
|
||||||
warning?: string
|
warning?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IrrigationRecommendPayload {
|
export interface IrrigationRecommendPayload {
|
||||||
crop_id?: string
|
crop_id?: string;
|
||||||
soilType?: string
|
farm_data?: Partial<FarmInfo>;
|
||||||
waterQuality?: string
|
soilType?: string;
|
||||||
climateZone?: 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> {
|
interface ApiResponse<T> {
|
||||||
status: string
|
status: string;
|
||||||
data: T
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unwrap<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
|
async function unwrap<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
|
||||||
const res = await promise
|
const res = await promise;
|
||||||
return res.data
|
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 = {
|
export const irrigationRecommendationService = {
|
||||||
getConfig(): Promise<IrrigationConfigResponse> {
|
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 }> {
|
recommend(
|
||||||
return unwrap(apiClient.post<ApiResponse<{ plan: IrrigationPlan }>>(`${PREFIX}/recommend/`, payload ?? {}))
|
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)
|
geoJsonLayer.addTo(map)
|
||||||
zonesLayerRef.current = geoJsonLayer
|
zonesLayerRef.current = geoJsonLayer
|
||||||
|
|
||||||
|
const bounds = geoJsonLayer.getBounds()
|
||||||
|
if (bounds.isValid()) {
|
||||||
|
map.fitBounds(bounds, { padding: [24, 24] })
|
||||||
|
}
|
||||||
|
|
||||||
let idx = 0
|
let idx = 0
|
||||||
geoJsonLayer.eachLayer((layer: L.Layer) => {
|
geoJsonLayer.eachLayer((layer: L.Layer) => {
|
||||||
const leafLayer = layer as L.Polygon
|
const leafLayer = layer as L.Polygon
|
||||||
@@ -183,6 +188,10 @@ export default function CropZoningMap({
|
|||||||
drawnItems.clearLayers()
|
drawnItems.clearLayers()
|
||||||
L.geoJSON(initialAreaGeoJson as unknown as Feature<Polygon>).eachLayer((layer) => drawnItems.addLayer(layer))
|
L.geoJSON(initialAreaGeoJson as unknown as Feature<Polygon>).eachLayer((layer) => drawnItems.addLayer(layer))
|
||||||
emitAreaChange()
|
emitAreaChange()
|
||||||
|
const bounds = drawnItems.getBounds()
|
||||||
|
if (bounds.isValid()) {
|
||||||
|
map.fitBounds(bounds, { padding: [24, 24] })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCreated = (e: L.LeafletEvent) => {
|
const onCreated = (e: L.LeafletEvent) => {
|
||||||
|
|||||||
@@ -1,303 +1,211 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useMemo } from 'react'
|
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from "next/dynamic";
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from "next-intl";
|
||||||
import Box from '@mui/material/Box'
|
import { useSensorHub } from "@/hooks/useSensorHub";
|
||||||
import CircularProgress from '@mui/material/CircularProgress'
|
import Box from "@mui/material/Box";
|
||||||
import Button from '@mui/material/Button'
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
import CropZoningMap from './CropZoningMap'
|
import LinearProgress from "@mui/material/LinearProgress";
|
||||||
import ZoneLegend from './ZoneLegend'
|
import CropZoningMap from "./CropZoningMap";
|
||||||
import LayerControl from './LayerControl'
|
import ZoneLegend from "./ZoneLegend";
|
||||||
import ZoneDetailPanel from './ZoneDetailPanel'
|
import LayerControl from "./LayerControl";
|
||||||
import CropZoningWeatherSection from './CropZoningWeatherSection'
|
import ZoneDetailPanel from "./ZoneDetailPanel";
|
||||||
import { createGridFromPolygon } from './cropZoningUtils'
|
import CropZoningWeatherSection from "./CropZoningWeatherSection";
|
||||||
import {
|
import {
|
||||||
cropZoningService,
|
cropZoningService,
|
||||||
type Product,
|
type Product,
|
||||||
type ZoneInitialData,
|
type ZoneInitialData,
|
||||||
type ZoneDetailData,
|
type ZoneDetailData,
|
||||||
type ZoneMapData,
|
} from "@/libs/api/services/cropZoningService";
|
||||||
type ZoneWaterNeedData,
|
import { CROP_COLORS, type CropType } from "./cropZoningTypes";
|
||||||
type ZoneSoilQualityData,
|
import type { LayerType } from "./cropZoningTypes";
|
||||||
type ZoneCultivationRiskData
|
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), {
|
const MapComponent = dynamic(() => Promise.resolve(CropZoningMap), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
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} />
|
<CircularProgress size={48} />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
),
|
||||||
})
|
});
|
||||||
|
|
||||||
function isPolygon(geojson: MapDrawGeoJSON): geojson is MapDrawGeoJSON & { geometry: { type: 'Polygon'; coordinates: unknown[] } } {
|
const POLL_INTERVAL = 2000;
|
||||||
return !!geojson?.geometry && (geojson.geometry as { type: string }).type === 'Polygon'
|
const MAX_POLLS = 100;
|
||||||
}
|
const getNormalizedTaskStatus = (status?: string) => status?.toLowerCase();
|
||||||
|
|
||||||
export default function CropZoningWrapper() {
|
export default function CropZoningWrapper() {
|
||||||
const t = useTranslations('cropZoning')
|
const t = useTranslations("cropZoning");
|
||||||
const [areaGeoJson, setAreaGeoJson] = useState<MapDrawGeoJSON>(null)
|
const { sensorHub } = useSensorHub();
|
||||||
const [areaLoading, setAreaLoading] = useState(true)
|
const [areaGeoJson, setAreaGeoJson] = useState<MapDrawGeoJSON>(null);
|
||||||
const [zonesData, setZonesData] = useState<ZoneInitialData[] | null>(null)
|
const [isClientReady, setIsClientReady] = useState(false);
|
||||||
const [zonesWaterNeed, setZonesWaterNeed] = useState<ZoneWaterNeedData[] | null>(null)
|
const [loading, setLoading] = useState(true);
|
||||||
const [zonesSoilQuality, setZonesSoilQuality] = useState<ZoneSoilQualityData[] | null>(null)
|
const [progress, setProgress] = useState<{ message: string; percent: number } | null>(null);
|
||||||
const [zonesCultivationRisk, setZonesCultivationRisk] = useState<ZoneCultivationRiskData[] | null>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [products, setProducts] = useState<Product[]>([])
|
const [zonesData, setZonesData] = useState<ZoneInitialData[] | null>(null);
|
||||||
const [productsLoading, setProductsLoading] = useState(true)
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [zonesLoading, setZonesLoading] = useState(false)
|
const [activeLayer, setActiveLayer] = useState<LayerType>("crops");
|
||||||
const [layerDataLoading, setLayerDataLoading] = useState(false)
|
const [selectedZone, setSelectedZone] = useState<ZoneDetailData | null>(null);
|
||||||
const [activeLayer, setActiveLayer] = useState<LayerType>('crops')
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
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]))
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cropZoningService
|
setIsClientReady(true);
|
||||||
.getProducts()
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cropZoningService.getProducts()
|
||||||
.then(res => setProducts(res.products))
|
.then(res => setProducts(res.products))
|
||||||
.catch(() => setProducts([]))
|
.catch(() => setProducts([]));
|
||||||
.finally(() => setProductsLoading(false))
|
}, []);
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAreaLoading(true)
|
let cancelled = false;
|
||||||
cropZoningService
|
|
||||||
.getArea()
|
|
||||||
.then(res => setAreaGeoJson(res.area as unknown as MapDrawGeoJSON))
|
|
||||||
.catch(() => setAreaGeoJson(null))
|
|
||||||
.finally(() => setAreaLoading(false))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const fetchZones = useCallback((geojson: MapDrawGeoJSON) => {
|
const loadArea = async () => {
|
||||||
if (!isPolygon(geojson)) {
|
if (!isClientReady) return;
|
||||||
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))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (!sensorHub?.id) {
|
||||||
if (isPolygon(areaGeoJson)) {
|
setError(t("errors.noSensor"));
|
||||||
fetchZones(areaGeoJson)
|
setLoading(false);
|
||||||
} else {
|
return;
|
||||||
setZonesData(null)
|
}
|
||||||
setZonesWaterNeed(null)
|
|
||||||
setZonesSoilQuality(null)
|
|
||||||
setZonesCultivationRisk(null)
|
|
||||||
}
|
|
||||||
}, [areaGeoJson, optimizationKey, fetchZones])
|
|
||||||
|
|
||||||
const gridForLayers = isPolygon(areaGeoJson)
|
setLoading(true);
|
||||||
? createGridFromPolygon(areaGeoJson as unknown as import('geojson').Feature<import('geojson').Polygon>)
|
setError(null);
|
||||||
: null
|
setProgress({ message: t("loadingArea"), percent: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
try {
|
||||||
if (!gridForLayers || zonesLoading) return
|
let polls = 0;
|
||||||
if (activeLayer === 'waterNeed' && zonesWaterNeed === null) {
|
|
||||||
setLayerDataLoading(true)
|
while (!cancelled && polls < MAX_POLLS) {
|
||||||
cropZoningService
|
const res = await cropZoningService.getArea(sensorHub.id);
|
||||||
.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])
|
|
||||||
|
|
||||||
const mapZonesData = useMemo((): ZoneMapData[] | null => {
|
if (!("area" in res)) break;
|
||||||
const labels = Object.fromEntries(products.map(p => [p.id, p.label]))
|
|
||||||
if (activeLayer === 'crops' && zonesData) {
|
const task = res.task;
|
||||||
const isCultivable = (crop: string | null | undefined) =>
|
const taskStatus = getNormalizedTaskStatus(task?.status);
|
||||||
!!crop && crop !== 'uncultivable' && crop.toLowerCase() !== 'uncultivable'
|
|
||||||
return zonesData.map(z => {
|
if (task) {
|
||||||
const cultivable = isCultivable(z.crop)
|
setProgress({
|
||||||
const cropLabel = cultivable ? (labels[z.crop!] ?? z.crop) : 'غیر قابل کشت'
|
message: task.message || task.stage_label || t("loadingArea"),
|
||||||
const tooltipContent = cultivable
|
percent: task.progress_percent || 0,
|
||||||
? `<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>
|
if (taskStatus === "completed" || taskStatus === "success") {
|
||||||
<div>سود تخمینی: ${z.estimatedProfit ?? '-'}</div>
|
setAreaGeoJson(res.area as unknown as MapDrawGeoJSON);
|
||||||
</div>`
|
if (res.zones) setZonesData(res.zones as ZoneInitialData[]);
|
||||||
: `<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
|
break;
|
||||||
<div style="font-weight: 600; margin-bottom: 6px; color: #64748b;">غیر قابل کشت</div>
|
}
|
||||||
<div style="color: #94a3b8;">این بخش برای کشت مناسب تشخیص داده نشده است.</div>
|
|
||||||
</div>`
|
if ((!task && res.area) || (res.area && taskStatus !== "pending" && taskStatus !== "processing")) {
|
||||||
const color = cultivable ? (CROP_COLORS[z.crop as CropType] ?? '#94a3b8') : '#94a3b8'
|
setAreaGeoJson(res.area as unknown as MapDrawGeoJSON);
|
||||||
return {
|
if (res.zones) setZonesData(res.zones as ZoneInitialData[]);
|
||||||
zoneId: z.zoneId,
|
break;
|
||||||
geometry: z.geometry,
|
}
|
||||||
color,
|
|
||||||
tooltipContent,
|
if (taskStatus === "failed" || taskStatus === "failure") {
|
||||||
cultivable,
|
throw new Error(task.message || t("errors.areaLoadFailed"));
|
||||||
zoneInitialData: z
|
}
|
||||||
|
|
||||||
|
if (taskStatus === "pending" || taskStatus === "processing") {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL));
|
||||||
|
polls++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
if (polls >= MAX_POLLS) {
|
||||||
if (activeLayer === 'waterNeed' && zonesWaterNeed) {
|
throw new Error(t("errors.timeout"));
|
||||||
const levelLabels = { low: 'کم', medium: 'متوسط', high: 'زیاد' }
|
}
|
||||||
return zonesWaterNeed.map(z => ({
|
} catch (err) {
|
||||||
zoneId: z.zoneId,
|
setError(err instanceof Error ? err.message : t("errors.areaLoadFailed"));
|
||||||
geometry: z.geometry,
|
} finally {
|
||||||
color: z.color,
|
if (!cancelled) {
|
||||||
tooltipContent: `<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
|
setLoading(false);
|
||||||
<div style="font-weight: 600; margin-bottom: 6px;">نیاز آبی: ${levelLabels[z.level]}</div>
|
setProgress(null);
|
||||||
<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) => {
|
loadArea();
|
||||||
setAreaGeoJson(geojson)
|
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) => {
|
const handleZoneClick = useCallback((zone: ZoneInitialData) => {
|
||||||
setZoneDetailLoading(true)
|
setPanelOpen(true);
|
||||||
setPanelOpen(true)
|
setSelectedZone(null);
|
||||||
setSelectedZone(null)
|
cropZoningService.getZoneDetails(zone.zoneId)
|
||||||
cropZoningService
|
.then(setSelectedZone)
|
||||||
.getZoneDetails(zone.zoneId)
|
.catch(() => setSelectedZone(null));
|
||||||
.then(details => setSelectedZone(details))
|
}, []);
|
||||||
.catch(() => setSelectedZone(null))
|
|
||||||
.finally(() => setZoneDetailLoading(false))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleOptimize = useCallback(() => {
|
|
||||||
setOptimizationKey(k => k + 1)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className='flex flex-col gap-6 is-full'>
|
<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="relative min-bs-[400px] rounded-xl overflow-hidden" sx={{ height: "min(60vh, 500px)" }}>
|
||||||
<Box className='absolute inset-0 z-0'>
|
<Box className="absolute inset-0 z-0">
|
||||||
{areaGeoJson ? (
|
{areaGeoJson ? (
|
||||||
<MapComponent
|
<MapComponent
|
||||||
key='crop-zoning-map'
|
|
||||||
center={[35.6892, 51.389]}
|
center={[35.6892, 51.389]}
|
||||||
zoom={13}
|
zoom={13}
|
||||||
height='100%'
|
height="100%"
|
||||||
activeLayer={activeLayer}
|
activeLayer={activeLayer}
|
||||||
onAreaChange={handleAreaChange}
|
|
||||||
onZoneClick={handleZoneClick}
|
onZoneClick={handleZoneClick}
|
||||||
optimizationKey={optimizationKey}
|
|
||||||
className='min-bs-[400px]'
|
|
||||||
initialAreaGeoJson={areaGeoJson}
|
initialAreaGeoJson={areaGeoJson}
|
||||||
zonesData={mapZonesData}
|
zonesData={mapZonesData}
|
||||||
productLabels={productLabels}
|
productLabels={Object.fromEntries(products.map(p => [p.id, p.label]))}
|
||||||
readOnly
|
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>
|
</Box>
|
||||||
|
|
||||||
{(areaLoading || zonesLoading || (activeLayer !== 'crops' && layerDataLoading)) && (
|
{loading && (
|
||||||
<Box
|
<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 }}>
|
||||||
className='absolute inset-0 z-[500] flex items-center justify-center bg-white/60 dark:bg-gray-900/60'
|
|
||||||
sx={{ borderRadius: 12 }}
|
|
||||||
>
|
|
||||||
<CircularProgress size={48} />
|
<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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<LayerControl activeLayer={activeLayer} onLayerChange={setActiveLayer} />
|
<LayerControl activeLayer={activeLayer} onLayerChange={setActiveLayer} />
|
||||||
|
<ZoneLegend activeLayer={activeLayer} products={products} loading={false} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<ZoneDetailPanel
|
<ZoneDetailPanel open={panelOpen} onClose={() => setPanelOpen(false)} zone={selectedZone} products={products} loading={false} />
|
||||||
open={panelOpen}
|
|
||||||
onClose={() => setPanelOpen(false)}
|
|
||||||
zone={selectedZone}
|
|
||||||
products={products}
|
|
||||||
loading={zoneDetailLoading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CropZoningWeatherSection />
|
<CropZoningWeatherSection />
|
||||||
</Box>
|
</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 Typography from '@mui/material/Typography'
|
||||||
import Card from '@mui/material/Card'
|
import Card from '@mui/material/Card'
|
||||||
import IconButton from '@mui/material/IconButton'
|
import IconButton from '@mui/material/IconButton'
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
import Collapse from '@mui/material/Collapse'
|
import Collapse from '@mui/material/Collapse'
|
||||||
import { useTheme } from '@mui/material/styles'
|
import { useTheme } from '@mui/material/styles'
|
||||||
@@ -17,7 +18,8 @@ import classnames from 'classnames'
|
|||||||
import { commonLayoutClasses } from '@layouts/utils/layoutClasses'
|
import { commonLayoutClasses } from '@layouts/utils/layoutClasses'
|
||||||
import { farmAiAssistantService } from '@/libs/api/services/farmAiAssistantService'
|
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 ─────────────────────────────────────────────────────────────
|
// ─── Constants ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -36,6 +38,8 @@ const SUGGESTION_CHIPS = [
|
|||||||
{ id: 'plant-disease', labelKey: 'suggestions.plantDisease' }
|
{ id: 'plant-disease', labelKey: 'suggestions.plantDisease' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
|
||||||
// ─── Main Component ────────────────────────────────────────────────────────
|
// ─── Main Component ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function FarmAiAssistantChat() {
|
export default function FarmAiAssistantChat() {
|
||||||
@@ -50,10 +54,46 @@ export default function FarmAiAssistantChat() {
|
|||||||
const [farmContext, setFarmContext] = useState<FarmContext>(DEFAULT_FARM_CONTEXT)
|
const [farmContext, setFarmContext] = useState<FarmContext>(DEFAULT_FARM_CONTEXT)
|
||||||
const [contextLoading, setContextLoading] = useState(true)
|
const [contextLoading, setContextLoading] = useState(true)
|
||||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
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 scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const { primary, info, warning } = theme.palette
|
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
|
// Fetch farm context on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -83,6 +123,16 @@ export default function FarmAiAssistantChat() {
|
|||||||
}
|
}
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConversations()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sidebarOpen) {
|
||||||
|
loadConversations()
|
||||||
|
}
|
||||||
|
}, [sidebarOpen])
|
||||||
|
|
||||||
// Scroll to bottom on new messages
|
// Scroll to bottom on new messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollRef.current?.scrollTo({
|
scrollRef.current?.scrollTo({
|
||||||
@@ -114,20 +164,39 @@ export default function FarmAiAssistantChat() {
|
|||||||
setIsTyping(true)
|
setIsTyping(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await farmAiAssistantService.chat({
|
const task = await farmAiAssistantService.createChatTask({
|
||||||
content,
|
content,
|
||||||
|
title: !conversationId ? content.slice(0, 60) : undefined,
|
||||||
farm_context: farmContext,
|
farm_context: farmContext,
|
||||||
...(conversationId ? { conversation_id: conversationId } : {})
|
...(conversationId ? { conversation_id: conversationId } : {})
|
||||||
})
|
})
|
||||||
if (res.conversation_id) setConversationId(res.conversation_id)
|
|
||||||
const aiMessage: FarmAIMessage = {
|
if (task.conversation_id) {
|
||||||
id: res.message_id,
|
setConversationId(task.conversation_id)
|
||||||
role: 'assistant',
|
|
||||||
content: res.content ?? '',
|
|
||||||
timestamp: new Date(),
|
|
||||||
sections: res.sections ?? []
|
|
||||||
}
|
}
|
||||||
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 {
|
} catch {
|
||||||
toast.error(t('errors.chatSend'))
|
toast.error(t('errors.chatSend'))
|
||||||
} finally {
|
} 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) => {
|
const toggleExplanation = (id: string) => {
|
||||||
setExpandedExplanations(prev => {
|
setExpandedExplanations(prev => {
|
||||||
const next = new Set(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')}
|
className={classnames(commonLayoutClasses.contentHeightFixed, 'flex flex-col is-full overflow-hidden rounded')}
|
||||||
sx={{
|
sx={{
|
||||||
background: `linear-gradient(180deg, ${theme.palette.background.default} 0%, ${alpha(primary.main, 0.04)} 30%, ${alpha(primary.main, 0.08)} 100%)`,
|
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 */}
|
{/* 1) Smart Header */}
|
||||||
|
<ChatSidebar
|
||||||
|
open={sidebarOpen}
|
||||||
|
loading={conversationLoading}
|
||||||
|
conversations={conversations}
|
||||||
|
onClose={() => setSidebarOpen(false)}
|
||||||
|
activeConversationId={conversationId}
|
||||||
|
onSelectConversation={handleSelectConversation}
|
||||||
|
getConversationLabel={getConversationLabel}
|
||||||
|
/>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
className='flex items-center gap-3 px-4 pt-4 pb-3 flex-shrink-0'
|
className='flex items-center gap-3 px-4 pt-4 pb-3 flex-shrink-0'
|
||||||
sx={{
|
sx={{
|
||||||
@@ -160,6 +279,33 @@ export default function FarmAiAssistantChat() {
|
|||||||
borderBottom: `1px solid ${alpha(primary.main, 0.12)}`
|
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
|
<Box
|
||||||
className='w-12 h-12 rounded-2xl flex items-center justify-center shrink-0'
|
className='w-12 h-12 rounded-2xl flex items-center justify-center shrink-0'
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -34,3 +34,10 @@ export interface FarmAIMessage {
|
|||||||
// For structured AI responses
|
// For structured AI responses
|
||||||
sections?: AIResponseSection[]
|
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 { useState, useEffect } from "react";
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from "next-intl";
|
||||||
import Box from '@mui/material/Box'
|
import Box from "@mui/material/Box";
|
||||||
import Card from '@mui/material/Card'
|
import Card from "@mui/material/Card";
|
||||||
import CardContent from '@mui/material/CardContent'
|
import CardContent from "@mui/material/CardContent";
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from "@mui/material/Typography";
|
||||||
import Button from '@mui/material/Button'
|
import Button from "@mui/material/Button";
|
||||||
import Collapse from '@mui/material/Collapse'
|
import Collapse from "@mui/material/Collapse";
|
||||||
import CircularProgress from '@mui/material/CircularProgress'
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
import { useTheme, alpha } from '@mui/material/styles'
|
import { useTheme, alpha } from "@mui/material/styles";
|
||||||
import type {
|
import type {
|
||||||
FarmData,
|
FarmData,
|
||||||
GrowthStage,
|
GrowthStage,
|
||||||
CropOption,
|
CropOption,
|
||||||
FertilizationPlan,
|
FertilizationPlan,
|
||||||
} from '@/libs/api/services/fertilizationRecommendationService'
|
} from "@/libs/api/services/fertilizationRecommendationService";
|
||||||
import { fertilizationRecommendationService } from '@/libs/api/services/fertilizationRecommendationService'
|
import { fertilizationRecommendationService } from "@/libs/api/services/fertilizationRecommendationService";
|
||||||
|
import { isRecommendationTaskRunning } from "@/libs/api/services/recommendationTask";
|
||||||
|
|
||||||
const DEFAULT_FARM_DATA: FarmData = {
|
const DEFAULT_FARM_DATA: FarmData = {
|
||||||
soilType: 'Loamy',
|
soilType: "Loamy",
|
||||||
organicMatter: 'Medium (2.5%)',
|
organicMatter: "Medium (2.5%)",
|
||||||
waterEC: '1.2 dS/m'
|
waterEC: "1.2 dS/m",
|
||||||
}
|
};
|
||||||
|
|
||||||
const DEFAULT_GROWTH_STAGES: GrowthStage[] = [
|
const DEFAULT_GROWTH_STAGES: GrowthStage[] = [
|
||||||
{ id: 'prePlanting', icon: 'tabler-seedling' },
|
{ id: "prePlanting", icon: "tabler-seedling" },
|
||||||
{ id: 'earlyGrowth', icon: 'tabler-leaf' },
|
{ id: "earlyGrowth", icon: "tabler-leaf" },
|
||||||
{ id: 'flowering', icon: 'tabler-flower' },
|
{ id: "flowering", icon: "tabler-flower" },
|
||||||
{ id: 'fruiting', icon: 'tabler-apple' },
|
{ id: "fruiting", icon: "tabler-apple" },
|
||||||
{ id: 'postHarvest', icon: 'tabler-basket' }
|
{ id: "postHarvest", icon: "tabler-basket" },
|
||||||
]
|
];
|
||||||
|
|
||||||
const DEFAULT_CROP_OPTIONS: CropOption[] = [
|
const DEFAULT_CROP_OPTIONS: CropOption[] = [
|
||||||
{ id: 'wheat', labelKey: 'wheat', icon: 'tabler-wheat' },
|
{ id: "wheat", labelKey: "wheat", icon: "tabler-wheat" },
|
||||||
{ id: 'corn', labelKey: 'corn', icon: 'tabler-plant-2' },
|
{ id: "corn", labelKey: "corn", icon: "tabler-plant-2" },
|
||||||
{ id: 'cotton', labelKey: 'cotton', icon: 'tabler-flower' },
|
{ id: "cotton", labelKey: "cotton", icon: "tabler-flower" },
|
||||||
{ id: 'saffron', labelKey: 'saffron', icon: 'tabler-flower-2' },
|
{ id: "saffron", labelKey: "saffron", icon: "tabler-flower-2" },
|
||||||
{ id: 'canola', labelKey: 'canola', icon: 'tabler-leaf' },
|
{ id: "canola", labelKey: "canola", icon: "tabler-leaf" },
|
||||||
{ id: 'vegetables', labelKey: 'vegetables', icon: 'tabler-carrot' }
|
{ 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() {
|
export default function SmartFertilizationRecommendation() {
|
||||||
const t = useTranslations('fertilization')
|
const t = useTranslations("fertilization");
|
||||||
const theme = useTheme()
|
const theme = useTheme();
|
||||||
const primaryMain = theme.palette.primary.main
|
const primaryMain = theme.palette.primary.main;
|
||||||
const primaryLight = theme.palette.primary.light
|
const primaryLight = theme.palette.primary.light;
|
||||||
const primaryDark = theme.palette.primary.dark
|
const primaryDark = theme.palette.primary.dark;
|
||||||
const paperBg = theme.palette.background.paper
|
const paperBg = theme.palette.background.paper;
|
||||||
const [farmData, setFarmData] = useState<FarmData>(DEFAULT_FARM_DATA)
|
const [farmData, setFarmData] = useState<FarmData>(DEFAULT_FARM_DATA);
|
||||||
const [growthStages, setGrowthStages] = useState<GrowthStage[]>(DEFAULT_GROWTH_STAGES)
|
const [growthStages, setGrowthStages] = useState<GrowthStage[]>(
|
||||||
const [cropOptions, setCropOptions] = useState<CropOption[]>(DEFAULT_CROP_OPTIONS)
|
DEFAULT_GROWTH_STAGES,
|
||||||
const [configLoading, setConfigLoading] = useState(true)
|
);
|
||||||
const [configError, setConfigError] = useState<string | null>(null)
|
const [cropOptions, setCropOptions] =
|
||||||
const [growthStage, setGrowthStage] = useState<string>(DEFAULT_GROWTH_STAGES[0].id)
|
useState<CropOption[]>(DEFAULT_CROP_OPTIONS);
|
||||||
const [selectedCrop, setSelectedCrop] = useState<string | null>(null)
|
const [configLoading, setConfigLoading] = useState(true);
|
||||||
const [plan, setPlan] = useState<FertilizationPlan | null>(null)
|
const [configError, setConfigError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false)
|
const [growthStage, setGrowthStage] = useState<string>(
|
||||||
const [reasoningExpanded, setReasoningExpanded] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
fertilizationRecommendationService
|
fertilizationRecommendationService
|
||||||
.getConfig()
|
.getConfig()
|
||||||
.then(({ farmData: farm, growthStages: stages, cropOptions: crops }) => {
|
.then(({ farmData: farm, growthStages: stages, cropOptions: crops }) => {
|
||||||
if (farm) setFarmData(farm)
|
if (farm) setFarmData(farm);
|
||||||
if (stages?.length) {
|
if (stages?.length) {
|
||||||
setGrowthStages(stages)
|
setGrowthStages(stages);
|
||||||
setGrowthStage(stages[0].id)
|
setGrowthStage(stages[0].id);
|
||||||
}
|
}
|
||||||
if (crops?.length) setCropOptions(crops)
|
if (crops?.length) setCropOptions(crops);
|
||||||
})
|
})
|
||||||
.catch((err: { message?: string }) => {
|
.catch((err: { message?: string }) => {
|
||||||
setConfigError(err?.message ?? 'Failed to load config')
|
setConfigError(err?.message ?? "Failed to load config");
|
||||||
})
|
})
|
||||||
.finally(() => setConfigLoading(false))
|
.finally(() => setConfigLoading(false));
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
if (!selectedCrop) return
|
if (!selectedCrop) return;
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
setPlan(null)
|
setPlan(null);
|
||||||
setReasoningExpanded(false)
|
setRequestError(null);
|
||||||
|
setStatusMessage(t("generating"));
|
||||||
|
setReasoningExpanded(false);
|
||||||
try {
|
try {
|
||||||
const { plan: nextPlan } = await fertilizationRecommendationService.recommend({
|
const recommendation = await fertilizationRecommendationService.recommend(
|
||||||
crop_id: selectedCrop,
|
{
|
||||||
growth_stage: growthStage,
|
crop_id: selectedCrop,
|
||||||
soilType: farmData.soilType,
|
growth_stage: growthStage,
|
||||||
organicMatter: farmData.organicMatter,
|
farm_data: {
|
||||||
waterEC: farmData.waterEC,
|
soilType: farmData.soilType,
|
||||||
})
|
organicMatter: farmData.organicMatter,
|
||||||
setPlan(nextPlan)
|
waterEC: farmData.waterEC,
|
||||||
} catch {
|
},
|
||||||
setPlan(null)
|
soilType: farmData.soilType,
|
||||||
} finally {
|
organicMatter: farmData.organicMatter,
|
||||||
setLoading(false)
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className='min-bs-screen'
|
className="min-bs-screen"
|
||||||
sx={{
|
sx={{
|
||||||
background: (th) =>
|
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%)`,
|
`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 */}
|
{/* 1) Header */}
|
||||||
<Box className='mb-8'>
|
<Box className="mb-8">
|
||||||
<Typography
|
<Typography
|
||||||
variant='h4'
|
variant="h4"
|
||||||
className='font-bold tracking-tight'
|
className="font-bold tracking-tight"
|
||||||
sx={{
|
sx={{
|
||||||
background: `linear-gradient(135deg, ${primaryDark} 0%, ${primaryMain} 40%, ${primaryLight} 100%)`,
|
background: `linear-gradient(135deg, ${primaryDark} 0%, ${primaryMain} 40%, ${primaryLight} 100%)`,
|
||||||
backgroundClip: 'text',
|
backgroundClip: "text",
|
||||||
WebkitBackgroundClip: 'text',
|
WebkitBackgroundClip: "text",
|
||||||
color: 'transparent',
|
color: "transparent",
|
||||||
fontSize: { xs: '1.5rem', sm: '1.75rem' }
|
fontSize: { xs: "1.5rem", sm: "1.75rem" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('title')}
|
{t("title")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant='body2'
|
variant="body2"
|
||||||
color='text.secondary'
|
color="text.secondary"
|
||||||
className='mt-1 transition-colors duration-300'
|
className="mt-1 transition-colors duration-300"
|
||||||
>
|
>
|
||||||
{t('subtitle')}
|
{t("subtitle")}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 2) Farm Data Card */}
|
{/* 2) Farm Data Card */}
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
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={{
|
sx={{
|
||||||
borderRadius: '28px',
|
borderRadius: "28px",
|
||||||
background: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 50%, ${alpha(primaryMain, 0.04)} 100%)`,
|
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)`,
|
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'>
|
<CardContent className="p-5">
|
||||||
<Box className='flex items-center justify-between mbe-4'>
|
<Box className="flex items-center justify-between mbe-4">
|
||||||
<Typography variant='subtitle2' fontWeight={600} color='text.secondary'>
|
<Typography
|
||||||
{t('farmData.title')}
|
variant="subtitle2"
|
||||||
|
fontWeight={600}
|
||||||
|
color="text.secondary"
|
||||||
|
>
|
||||||
|
{t("farmData.title")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<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={{
|
sx={{
|
||||||
background: (th) => `linear-gradient(135deg, ${th.palette.success.main} 0%, ${th.palette.success.dark} 100%)`,
|
background: (th) =>
|
||||||
color: 'white',
|
`linear-gradient(135deg, ${th.palette.success.main} 0%, ${th.palette.success.dark} 100%)`,
|
||||||
boxShadow: (th) => `0 2px 8px ${alpha(th.palette.success.main, 0.3)}`
|
color: "white",
|
||||||
|
boxShadow: (th) =>
|
||||||
|
`0 2px 8px ${alpha(th.palette.success.main, 0.3)}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<i className='tabler-circle-check text-sm' />
|
<i className="tabler-circle-check text-sm" />
|
||||||
{t('verifiedBadge')}
|
{t("verifiedBadge")}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box className='flex flex-wrap gap-3'>
|
<Box className="flex flex-wrap gap-3">
|
||||||
<FarmBadge icon='tabler-seedling' label={t('farmData.soilType')} value={farmData.soilType} />
|
|
||||||
<FarmBadge
|
<FarmBadge
|
||||||
icon='tabler-atom-2'
|
icon="tabler-seedling"
|
||||||
label={t('farmData.organicMatter')}
|
label={t("farmData.soilType")}
|
||||||
|
value={farmData.soilType}
|
||||||
|
/>
|
||||||
|
<FarmBadge
|
||||||
|
icon="tabler-atom-2"
|
||||||
|
label={t("farmData.organicMatter")}
|
||||||
value={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>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 3) Growth Stage Selector */}
|
{/* 3) Growth Stage Selector */}
|
||||||
<Typography variant='subtitle2' fontWeight={600} color='text.secondary' className='mbe-3'>
|
<Typography
|
||||||
{t('growthStage.title')}
|
variant="subtitle2"
|
||||||
|
fontWeight={600}
|
||||||
|
color="text.secondary"
|
||||||
|
className="mbe-3"
|
||||||
|
>
|
||||||
|
{t("growthStage.title")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box className='flex gap-2 mb-6 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-hide'>
|
<Box className="flex gap-2 mb-6 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-hide">
|
||||||
{growthStages.map((stage, idx) => {
|
{growthStages.map((stage, idx) => {
|
||||||
const isSelected = growthStage === stage.id
|
const isSelected = growthStage === stage.id;
|
||||||
const isPast = idx < stageIndex
|
const isPast = idx < stageIndex;
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={stage.id}
|
key={stage.id}
|
||||||
component='button'
|
component="button"
|
||||||
type='button'
|
type="button"
|
||||||
onClick={() => setGrowthStage(stage.id)}
|
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={{
|
sx={{
|
||||||
borderColor: isSelected ? primaryMain : 'transparent',
|
borderColor: isSelected ? primaryMain : "transparent",
|
||||||
background: isSelected
|
background: isSelected
|
||||||
? `linear-gradient(145deg, ${alpha(primaryMain, 0.15)} 0%, ${alpha(primaryMain, 0.06)} 100%)`
|
? `linear-gradient(145deg, ${alpha(primaryMain, 0.15)} 0%, ${alpha(primaryMain, 0.06)} 100%)`
|
||||||
: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||||
boxShadow: isSelected
|
boxShadow: isSelected
|
||||||
? `0 4px 20px ${alpha(primaryMain, 0.2)}, inset 0 1px 0 rgba(255,255,255,0.8)`
|
? `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)',
|
: "0 2px 8px rgba(0,0,0,0.04)",
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
transform: 'translateY(-2px)',
|
transform: "translateY(-2px)",
|
||||||
boxShadow: isSelected
|
boxShadow: isSelected
|
||||||
? `0 6px 24px ${alpha(primaryMain, 0.25)}`
|
? `0 6px 24px ${alpha(primaryMain, 0.25)}`
|
||||||
: `0 4px 16px ${alpha(primaryMain, 0.1)}`
|
: `0 4px 16px ${alpha(primaryMain, 0.1)}`,
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<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={{
|
sx={{
|
||||||
background: isSelected
|
background: isSelected
|
||||||
? `linear-gradient(135deg, ${primaryMain} 0%, ${primaryDark} 100%)`
|
? `linear-gradient(135deg, ${primaryMain} 0%, ${primaryDark} 100%)`
|
||||||
: isPast
|
: isPast
|
||||||
? `linear-gradient(145deg, ${alpha(primaryMain, 0.2)} 0%, ${alpha(primaryMain, 0.1)} 100%)`
|
? `linear-gradient(145deg, ${alpha(primaryMain, 0.2)} 0%, ${alpha(primaryMain, 0.1)} 100%)`
|
||||||
: alpha(primaryMain, 0.08)
|
: alpha(primaryMain, 0.08),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<i
|
<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}
|
style={!isSelected ? { color: primaryMain } : undefined}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography
|
<Typography
|
||||||
variant='caption'
|
variant="caption"
|
||||||
fontWeight={600}
|
fontWeight={600}
|
||||||
sx={{
|
sx={{
|
||||||
color: isSelected ? 'primary.main' : 'text.secondary',
|
color: isSelected ? "primary.main" : "text.secondary",
|
||||||
textAlign: 'center',
|
textAlign: "center",
|
||||||
lineHeight: 1.2
|
lineHeight: 1.2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t(`growthStage.${stage.id}`)}
|
{t(`growthStage.${stage.id}`)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 4) Plant Selection */}
|
{/* 4) Plant Selection */}
|
||||||
<Typography variant='subtitle2' fontWeight={600} color='text.secondary' className='mbe-3'>
|
<Typography
|
||||||
{t('plantSelection.title')}
|
variant="subtitle2"
|
||||||
|
fontWeight={600}
|
||||||
|
color="text.secondary"
|
||||||
|
className="mbe-3"
|
||||||
|
>
|
||||||
|
{t("plantSelection.title")}
|
||||||
</Typography>
|
</Typography>
|
||||||
{configLoading ? (
|
{configLoading ? (
|
||||||
<Box className='flex justify-center py-8 mb-6'>
|
<Box className="flex justify-center py-8 mb-6">
|
||||||
<CircularProgress size={32} sx={{ color: 'primary.main' }} />
|
<CircularProgress size={32} sx={{ color: "primary.main" }} />
|
||||||
</Box>
|
</Box>
|
||||||
) : configError ? (
|
) : configError ? (
|
||||||
<Typography variant='body2' color='error' className='mb-6'>
|
<Typography variant="body2" color="error" className="mb-6">
|
||||||
{configError}
|
{configError}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : (
|
) : (
|
||||||
<Box className='flex flex-wrap gap-3 mb-6'>
|
<Box className="flex flex-wrap gap-3 mb-6">
|
||||||
{cropOptions.map(crop => (
|
{cropOptions.map((crop) => (
|
||||||
<CropCard
|
<CropCard
|
||||||
key={crop.id}
|
key={crop.id}
|
||||||
crop={crop}
|
crop={crop}
|
||||||
label={t(`crops.${crop.labelKey}`)}
|
label={t(`crops.${crop.labelKey}`)}
|
||||||
selected={selectedCrop === crop.id}
|
selected={selectedCrop === crop.id}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setSelectedCrop(prev => (prev === crop.id ? null : crop.id))
|
setSelectedCrop((prev) => (prev === crop.id ? null : crop.id))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 5) Primary CTA Button - End of form */}
|
{/* 5) Primary CTA Button - End of form */}
|
||||||
<Box className='mb-8'>
|
<Box className="mb-8">
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
variant='contained'
|
variant="contained"
|
||||||
disabled={!selectedCrop || loading || configLoading}
|
disabled={!selectedCrop || loading || configLoading}
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
startIcon={<i className='tabler-sparkles text-xl' />}
|
startIcon={<i className="tabler-sparkles text-xl" />}
|
||||||
className='rounded-2xl py-3.5 text-base font-semibold shadow-lg transition-all duration-300 hover:shadow-xl hover:scale-[1.01] active:scale-[0.99]'
|
className="rounded-2xl py-3.5 text-base font-semibold shadow-lg transition-all duration-300 hover:shadow-xl hover:scale-[1.01] active:scale-[0.99]"
|
||||||
sx={{
|
sx={{
|
||||||
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
||||||
boxShadow: `0 4px 20px ${alpha(primaryMain, 0.4)}`,
|
boxShadow: `0 4px 20px ${alpha(primaryMain, 0.4)}`,
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
||||||
boxShadow: `0 6px 28px ${alpha(primaryMain, 0.5)}`,
|
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>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{requestError && !loading && (
|
||||||
|
<Typography variant="body2" color="error" className="mb-6">
|
||||||
|
{requestError}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 6) Result Section - Prescription style */}
|
{/* 6) Result Section - Prescription style */}
|
||||||
{plan && (
|
{plan && (
|
||||||
<Box className='mb-6 animate-fade-in'>
|
<Box className="mb-6 animate-fade-in">
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: '28px',
|
borderRadius: "28px",
|
||||||
background: `linear-gradient(160deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 40%, ${alpha(primaryMain, 0.04)} 100%)`,
|
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)`,
|
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)}`,
|
border: `1px solid ${alpha(primaryMain, 0.15)}`,
|
||||||
overflow: 'visible'
|
overflow: "visible",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent className='p-6'>
|
<CardContent className="p-6">
|
||||||
<Box className='flex items-center gap-2 mbe-5'>
|
<Box className="flex items-center gap-2 mbe-5">
|
||||||
<i className='tabler-prescription text-2xl' style={{ color: primaryMain }} />
|
<i
|
||||||
<Typography variant='h6' fontWeight={700} color='text.primary'>
|
className="tabler-prescription text-2xl"
|
||||||
{t('result.title')}
|
style={{ color: primaryMain }}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
fontWeight={700}
|
||||||
|
color="text.primary"
|
||||||
|
>
|
||||||
|
{t("result.title")}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className='space-y-3'>
|
<Box className="space-y-3">
|
||||||
<PrescriptionRow
|
<PrescriptionRow
|
||||||
icon='tabler-atom-2'
|
icon="tabler-atom-2"
|
||||||
label={t('result.fertilizerType')}
|
label={t("result.fertilizerType")}
|
||||||
value={plan.npkRatio}
|
value={plan.npkRatio}
|
||||||
/>
|
/>
|
||||||
<PrescriptionRow
|
<PrescriptionRow
|
||||||
icon='tabler-scale'
|
icon="tabler-scale"
|
||||||
label={t('result.amountPerHectare')}
|
label={t("result.amountPerHectare")}
|
||||||
value={plan.amountPerHectare}
|
value={plan.amountPerHectare}
|
||||||
/>
|
/>
|
||||||
<PrescriptionRow
|
<PrescriptionRow
|
||||||
icon='tabler-spray'
|
icon="tabler-spray"
|
||||||
label={t('result.applicationMethod')}
|
label={t("result.applicationMethod")}
|
||||||
value={plan.applicationMethod}
|
value={plan.applicationMethod}
|
||||||
/>
|
/>
|
||||||
<PrescriptionRow
|
<PrescriptionRow
|
||||||
icon='tabler-calendar-repeat'
|
icon="tabler-calendar-repeat"
|
||||||
label={t('result.applicationInterval')}
|
label={t("result.applicationInterval")}
|
||||||
value={plan.applicationInterval}
|
value={plan.applicationInterval}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Expandable "Why this recommendation?" */}
|
{/* Expandable "Why this recommendation?" */}
|
||||||
<Box
|
<Box
|
||||||
className='mt-5 rounded-2xl overflow-hidden transition-all duration-300'
|
className="mt-5 rounded-2xl overflow-hidden transition-all duration-300"
|
||||||
sx={{
|
sx={{
|
||||||
border: `1px solid ${alpha(primaryMain, 0.15)}`,
|
border: `1px solid ${alpha(primaryMain, 0.15)}`,
|
||||||
background: alpha(primaryMain, 0.04)
|
background: alpha(primaryMain, 0.04),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
component='button'
|
component="button"
|
||||||
type='button'
|
type="button"
|
||||||
onClick={() => setReasoningExpanded(!reasoningExpanded)}
|
onClick={() => setReasoningExpanded(!reasoningExpanded)}
|
||||||
className='w-full flex items-center justify-between px-4 py-3 text-start cursor-pointer'
|
className="w-full flex items-center justify-between px-4 py-3 text-start cursor-pointer"
|
||||||
sx={{ '&:hover': { bgcolor: alpha(primaryMain, 0.06) } }}
|
sx={{ "&:hover": { bgcolor: alpha(primaryMain, 0.06) } }}
|
||||||
>
|
>
|
||||||
<Box className='flex items-center gap-2'>
|
<Box className="flex items-center gap-2">
|
||||||
<i className='tabler-brain text-lg' style={{ color: primaryMain }} />
|
<i
|
||||||
<Typography variant='subtitle2' fontWeight={600} color='text.primary'>
|
className="tabler-brain text-lg"
|
||||||
{t('result.whyRecommendation')}
|
style={{ color: primaryMain }}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
fontWeight={600}
|
||||||
|
color="text.primary"
|
||||||
|
>
|
||||||
|
{t("result.whyRecommendation")}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<i
|
<i
|
||||||
className={`tabler-chevron-down text-xl transition-transform duration-300 ${
|
className={`tabler-chevron-down text-xl transition-transform duration-300 ${
|
||||||
reasoningExpanded ? 'rotate-180' : ''
|
reasoningExpanded ? "rotate-180" : ""
|
||||||
}`}
|
}`}
|
||||||
style={{ color: primaryMain }}
|
style={{ color: primaryMain }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Collapse in={reasoningExpanded}>
|
<Collapse in={reasoningExpanded}>
|
||||||
<Box className='px-4 pb-4'>
|
<Box className="px-4 pb-4">
|
||||||
<Typography
|
<Typography
|
||||||
variant='body2'
|
variant="body2"
|
||||||
color='text.secondary'
|
color="text.secondary"
|
||||||
sx={{ lineHeight: 1.7 }}
|
sx={{ lineHeight: 1.7 }}
|
||||||
>
|
>
|
||||||
{plan.reasoning}
|
{plan.reasoning}
|
||||||
@@ -383,32 +491,35 @@ export default function SmartFertilizationRecommendation() {
|
|||||||
{loading && (
|
{loading && (
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
className='mb-6 animate-fade-in'
|
className="mb-6 animate-fade-in"
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: '28px',
|
borderRadius: "28px",
|
||||||
background: `linear-gradient(160deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 100%)`,
|
background: `linear-gradient(160deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 100%)`,
|
||||||
boxShadow: `0 8px 32px ${alpha(primaryMain, 0.1)}`,
|
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
|
<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={{
|
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>
|
</Box>
|
||||||
<Typography variant='body2' color='text.secondary'>
|
<Typography variant="body2" color="text.secondary">
|
||||||
{t('generating')}
|
{statusMessage ?? t("generating")}
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||||
@@ -416,129 +527,140 @@ export default function SmartFertilizationRecommendation() {
|
|||||||
function FarmBadge({
|
function FarmBadge({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
value
|
value,
|
||||||
}: {
|
}: {
|
||||||
icon: string
|
icon: string;
|
||||||
label: string
|
label: string;
|
||||||
value: string
|
value: string;
|
||||||
}) {
|
}) {
|
||||||
const theme = useTheme()
|
const theme = useTheme();
|
||||||
const primaryMain = theme.palette.primary.main
|
const primaryMain = theme.palette.primary.main;
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className='flex items-center gap-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={{
|
sx={{
|
||||||
background: `linear-gradient(145deg, ${alpha(primaryMain, 0.1)} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
background: `linear-gradient(145deg, ${alpha(primaryMain, 0.1)} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||||
border: `1px solid ${alpha(primaryMain, 0.15)}`,
|
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 }} />
|
<i className={`${icon} text-xl`} style={{ color: primaryMain }} />
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant='caption' color='text.secondary' display='block' lineHeight={1.2}>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
display="block"
|
||||||
|
lineHeight={1.2}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant='body2' fontWeight={600} color='text.primary'>
|
<Typography variant="body2" fontWeight={600} color="text.primary">
|
||||||
{value}
|
{value}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CropCard({
|
function CropCard({
|
||||||
crop,
|
crop,
|
||||||
label,
|
label,
|
||||||
selected,
|
selected,
|
||||||
onClick
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
crop: CropOption
|
crop: CropOption;
|
||||||
label: string
|
label: string;
|
||||||
selected: boolean
|
selected: boolean;
|
||||||
onClick: () => void
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const theme = useTheme()
|
const theme = useTheme();
|
||||||
const primaryMain = theme.palette.primary.main
|
const primaryMain = theme.palette.primary.main;
|
||||||
const primaryDark = theme.palette.primary.dark
|
const primaryDark = theme.palette.primary.dark;
|
||||||
const paperBg = theme.palette.background.paper
|
const paperBg = theme.palette.background.paper;
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
component='button'
|
component="button"
|
||||||
type='button'
|
type="button"
|
||||||
elevation={0}
|
elevation={0}
|
||||||
onClick={onClick}
|
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={{
|
sx={{
|
||||||
borderColor: selected ? primaryMain : 'transparent',
|
borderColor: selected ? primaryMain : "transparent",
|
||||||
background: selected
|
background: selected
|
||||||
? `linear-gradient(145deg, ${alpha(primaryMain, 0.15)} 0%, ${alpha(primaryMain, 0.06)} 100%)`
|
? `linear-gradient(145deg, ${alpha(primaryMain, 0.15)} 0%, ${alpha(primaryMain, 0.06)} 100%)`
|
||||||
: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||||
boxShadow: selected
|
boxShadow: selected
|
||||||
? `0 4px 20px ${alpha(primaryMain, 0.2)}, inset 0 1px 0 rgba(255,255,255,0.8)`
|
? `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)',
|
: "0 2px 8px rgba(0,0,0,0.04), inset 0 1px 0 rgba(255,255,255,0.9)",
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
transform: 'translateY(-2px)',
|
transform: "translateY(-2px)",
|
||||||
boxShadow: selected
|
boxShadow: selected
|
||||||
? `0 6px 24px ${alpha(primaryMain, 0.25)}`
|
? `0 6px 24px ${alpha(primaryMain, 0.25)}`
|
||||||
: `0 4px 16px ${alpha(primaryMain, 0.12)}`
|
: `0 4px 16px ${alpha(primaryMain, 0.12)}`,
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<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={{
|
sx={{
|
||||||
background: selected
|
background: selected
|
||||||
? `linear-gradient(135deg, ${primaryMain} 0%, ${primaryDark} 100%)`
|
? `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
|
<i
|
||||||
className={`${crop.icon} text-xl ${selected ? 'text-white' : ''}`}
|
className={`${crop.icon} text-xl ${selected ? "text-white" : ""}`}
|
||||||
style={!selected ? { color: primaryMain } : undefined}
|
style={!selected ? { color: primaryMain } : undefined}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography
|
<Typography
|
||||||
variant='body2'
|
variant="body2"
|
||||||
fontWeight={600}
|
fontWeight={600}
|
||||||
color={selected ? 'primary.main' : 'text.primary'}
|
color={selected ? "primary.main" : "text.primary"}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
{selected && (
|
{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>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PrescriptionRow({
|
function PrescriptionRow({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
value
|
value,
|
||||||
}: {
|
}: {
|
||||||
icon: string
|
icon: string;
|
||||||
label: string
|
label: string;
|
||||||
value: string
|
value: string;
|
||||||
}) {
|
}) {
|
||||||
const theme = useTheme()
|
const theme = useTheme();
|
||||||
const primaryMain = theme.palette.primary.main
|
const primaryMain = theme.palette.primary.main;
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className='flex items-center gap-4 p-3 rounded-2xl transition-colors duration-200'
|
className="flex items-center gap-4 p-3 rounded-2xl transition-colors duration-200"
|
||||||
sx={{
|
sx={{
|
||||||
background: alpha(primaryMain, 0.06),
|
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 }} />
|
<i
|
||||||
<Box className='flex-1 min-w-0'>
|
className={`${icon} text-2xl shrink-0`}
|
||||||
<Typography variant='caption' color='text.secondary'>
|
style={{ color: primaryMain }}
|
||||||
|
/>
|
||||||
|
<Box className="flex-1 min-w-0">
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant='body1' fontWeight={600} color='text.primary'>
|
<Typography variant="body1" fontWeight={600} color="text.primary">
|
||||||
{value}
|
{value}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,286 +1,480 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from "next-intl";
|
||||||
import Box from '@mui/material/Box'
|
import Box from "@mui/material/Box";
|
||||||
import Card from '@mui/material/Card'
|
import Card from "@mui/material/Card";
|
||||||
import CardContent from '@mui/material/CardContent'
|
import CardContent from "@mui/material/CardContent";
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from "@mui/material/Typography";
|
||||||
import Button from '@mui/material/Button'
|
import Button from "@mui/material/Button";
|
||||||
import IconButton from '@mui/material/IconButton'
|
import IconButton from "@mui/material/IconButton";
|
||||||
import CircularProgress from '@mui/material/CircularProgress'
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
import { useTheme, alpha } from '@mui/material/styles'
|
import { useTheme, alpha } from "@mui/material/styles";
|
||||||
import type { FarmInfo, CropOption, IrrigationPlan } from '@/libs/api/services/irrigationRecommendationService'
|
import type {
|
||||||
import { irrigationRecommendationService } from '@/libs/api/services/irrigationRecommendationService'
|
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 = {
|
const DEFAULT_FARM_INFO: FarmInfo = {
|
||||||
soilType: 'Loamy',
|
soilType: "Loamy",
|
||||||
waterQuality: 'Medium EC',
|
waterQuality: "Medium EC",
|
||||||
climateZone: 'Temperate'
|
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() {
|
export default function SmartIrrigationRecommendation() {
|
||||||
const t = useTranslations('irrigation')
|
const t = useTranslations("irrigation");
|
||||||
const theme = useTheme()
|
const theme = useTheme();
|
||||||
const [farmInfo, setFarmInfo] = useState<FarmInfo>(DEFAULT_FARM_INFO)
|
const [farmInfo, setFarmInfo] = useState<FarmInfo>(DEFAULT_FARM_INFO);
|
||||||
const [cropOptions, setCropOptions] = useState<CropOption[]>([])
|
const [cropOptions, setCropOptions] = useState<CropOption[]>([]);
|
||||||
const [configLoading, setConfigLoading] = useState(true)
|
const [configLoading, setConfigLoading] = useState(true);
|
||||||
const [configError, setConfigError] = useState<string | null>(null)
|
const [configError, setConfigError] = useState<string | null>(null);
|
||||||
const [selectedCrop, setSelectedCrop] = useState<string | null>(null)
|
const [selectedCrop, setSelectedCrop] = useState<string | null>(null);
|
||||||
const [plan, setPlan] = useState<IrrigationPlan | null>(null)
|
const [plan, setPlan] = useState<IrrigationPlan | null>(null);
|
||||||
const [loading, setLoading] = useState(false)
|
const [waterBalance, setWaterBalance] = useState<WaterBalance | null>(null);
|
||||||
const primaryMain = theme.palette.primary.main
|
const [loading, setLoading] = useState(false);
|
||||||
const primaryLight = theme.palette.primary.light
|
const [requestError, setRequestError] = useState<string | null>(null);
|
||||||
const primaryDark = theme.palette.primary.dark
|
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||||
const paperBg = theme.palette.background.paper
|
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(() => {
|
useEffect(() => {
|
||||||
irrigationRecommendationService
|
irrigationRecommendationService
|
||||||
.getConfig()
|
.getConfig()
|
||||||
.then(({ farmInfo: info, cropOptions: crops }) => {
|
.then(({ farmInfo: info, cropOptions: crops }) => {
|
||||||
setFarmInfo(info)
|
setFarmInfo(info);
|
||||||
setCropOptions(crops.length > 0 ? crops : [])
|
setCropOptions(crops.length > 0 ? crops : []);
|
||||||
})
|
})
|
||||||
.catch((err: { message?: string }) => {
|
.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 () => {
|
const handleGenerate = async () => {
|
||||||
if (!selectedCrop) return
|
if (!selectedCrop) return;
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
setPlan(null)
|
setPlan(null);
|
||||||
|
setWaterBalance(null);
|
||||||
|
setRequestError(null);
|
||||||
|
setStatusMessage(t("generating"));
|
||||||
try {
|
try {
|
||||||
const { plan: nextPlan } = await irrigationRecommendationService.recommend({
|
const recommendation = await irrigationRecommendationService.recommend({
|
||||||
crop_id: selectedCrop,
|
crop_id: selectedCrop,
|
||||||
})
|
farm_data: {
|
||||||
setPlan(nextPlan)
|
soilType: farmInfo.soilType,
|
||||||
} catch {
|
waterQuality: farmInfo.waterQuality,
|
||||||
setPlan(null)
|
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 {
|
} 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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className='min-bs-screen'
|
className="min-bs-screen"
|
||||||
sx={{
|
sx={{
|
||||||
background: (theme) =>
|
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%)`,
|
`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 */}
|
{/* 1) Dynamic Header */}
|
||||||
<Box className='mb-8'>
|
<Box className="mb-8">
|
||||||
<Typography
|
<Typography
|
||||||
variant='h4'
|
variant="h4"
|
||||||
className='font-bold tracking-tight'
|
className="font-bold tracking-tight"
|
||||||
sx={{
|
sx={{
|
||||||
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
||||||
backgroundClip: 'text',
|
backgroundClip: "text",
|
||||||
WebkitBackgroundClip: 'text',
|
WebkitBackgroundClip: "text",
|
||||||
color: 'transparent',
|
color: "transparent",
|
||||||
fontSize: { xs: '1.5rem', sm: '1.75rem' }
|
fontSize: { xs: "1.5rem", sm: "1.75rem" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('title')}
|
{t("title")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant='body2' color='text.secondary' className='mt-1'>
|
<Typography variant="body2" color="text.secondary" className="mt-1">
|
||||||
{t('subtitle')}
|
{t("subtitle")}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 2) Farm Info Card */}
|
{/* 2) Farm Info Card */}
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
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={{
|
sx={{
|
||||||
borderRadius: '24px',
|
borderRadius: "24px",
|
||||||
background: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
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)`,
|
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'>
|
<CardContent className="p-5">
|
||||||
<Box className='flex items-center justify-between mbe-4'>
|
<Box className="flex items-center justify-between mbe-4">
|
||||||
<Typography variant='subtitle2' fontWeight={600} color='text.secondary'>
|
<Typography
|
||||||
{t('farmInfo.title')}
|
variant="subtitle2"
|
||||||
|
fontWeight={600}
|
||||||
|
color="text.secondary"
|
||||||
|
>
|
||||||
|
{t("farmInfo.title")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box className='flex items-center gap-2'>
|
<Box className="flex items-center gap-2">
|
||||||
<Box
|
<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={{
|
sx={{
|
||||||
background: (t) => `linear-gradient(135deg, ${t.palette.success.main} 0%, ${t.palette.success.dark} 100%)`,
|
background: (t) =>
|
||||||
color: 'white',
|
`linear-gradient(135deg, ${t.palette.success.main} 0%, ${t.palette.success.dark} 100%)`,
|
||||||
display: 'flex',
|
color: "white",
|
||||||
alignItems: 'center',
|
display: "flex",
|
||||||
gap: 4
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<i className='tabler-circle-check text-sm' />
|
<i className="tabler-circle-check text-sm" />
|
||||||
{t('verifiedBadge')}
|
{t("verifiedBadge")}
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size='small' sx={{ color: 'text.secondary' }} aria-label={t('editFarmInfo')}>
|
<IconButton
|
||||||
<i className='tabler-pencil text-lg' />
|
size="small"
|
||||||
|
sx={{ color: "text.secondary" }}
|
||||||
|
aria-label={t("editFarmInfo")}
|
||||||
|
>
|
||||||
|
<i className="tabler-pencil text-lg" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box className='flex flex-wrap gap-3'>
|
<Box className="flex flex-wrap gap-3">
|
||||||
<FarmBadge icon='tabler-seedling' label={t('farmInfo.soilType')} value={farmInfo.soilType} />
|
<FarmBadge
|
||||||
<FarmBadge icon='tabler-droplet' label={t('farmInfo.waterQuality')} value={farmInfo.waterQuality} />
|
icon="tabler-seedling"
|
||||||
<FarmBadge icon='tabler-temperature' label={t('farmInfo.climateZone')} value={farmInfo.climateZone} />
|
label={t("farmInfo.soilType")}
|
||||||
|
value={farmInfo.soilType}
|
||||||
|
/>
|
||||||
|
<FarmBadge
|
||||||
|
icon="tabler-droplet"
|
||||||
|
label={t("farmInfo.waterQuality")}
|
||||||
|
value={farmInfo.waterQuality}
|
||||||
|
/>
|
||||||
|
<FarmBadge
|
||||||
|
icon="tabler-temperature"
|
||||||
|
label={t("farmInfo.climateZone")}
|
||||||
|
value={farmInfo.climateZone}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 3) Plant Selection Section */}
|
{/* 3) Plant Selection Section */}
|
||||||
<Typography variant='subtitle2' fontWeight={600} color='text.secondary' className='mbe-3'>
|
<Typography
|
||||||
{t('plantSelection.title')}
|
variant="subtitle2"
|
||||||
|
fontWeight={600}
|
||||||
|
color="text.secondary"
|
||||||
|
className="mbe-3"
|
||||||
|
>
|
||||||
|
{t("plantSelection.title")}
|
||||||
</Typography>
|
</Typography>
|
||||||
{configLoading ? (
|
{configLoading ? (
|
||||||
<Box className='flex justify-center py-8'>
|
<Box className="flex justify-center py-8">
|
||||||
<CircularProgress size={32} sx={{ color: 'primary.main' }} />
|
<CircularProgress size={32} sx={{ color: "primary.main" }} />
|
||||||
</Box>
|
</Box>
|
||||||
) : configError ? (
|
) : configError ? (
|
||||||
<Typography variant='body2' color='error' className='mb-6'>
|
<Typography variant="body2" color="error" className="mb-6">
|
||||||
{configError}
|
{configError}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : (
|
) : (
|
||||||
<Box className='flex flex-wrap gap-3 mb-6'>
|
<Box className="flex flex-wrap gap-3 mb-6">
|
||||||
{(cropOptions.length > 0 ? cropOptions : []).map(crop => (
|
{(cropOptions.length > 0 ? cropOptions : []).map((crop) => (
|
||||||
<CropCard
|
<CropCard
|
||||||
key={crop.id}
|
key={crop.id}
|
||||||
crop={crop}
|
crop={crop}
|
||||||
label={t(`crops.${crop.labelKey}`)}
|
label={t(`crops.${crop.labelKey}`)}
|
||||||
selected={selectedCrop === crop.id}
|
selected={selectedCrop === crop.id}
|
||||||
onClick={() => setSelectedCrop(prev => (prev === crop.id ? null : crop.id))}
|
onClick={() =>
|
||||||
/>
|
setSelectedCrop((prev) => (prev === crop.id ? null : crop.id))
|
||||||
))}
|
}
|
||||||
</Box>
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 4) Primary CTA Button - End of form */}
|
{/* 4) Primary CTA Button - End of form */}
|
||||||
<Box className='mb-8'>
|
<Box className="mb-8">
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
variant='contained'
|
variant="contained"
|
||||||
disabled={!selectedCrop || loading || configLoading}
|
disabled={!selectedCrop || loading || configLoading}
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
startIcon={<i className='tabler-sparkles text-xl' />}
|
startIcon={<i className="tabler-sparkles text-xl" />}
|
||||||
className='rounded-2xl py-3.5 text-base font-semibold shadow-lg transition-all duration-300 hover:shadow-xl hover:scale-[1.01] active:scale-[0.99]'
|
className="rounded-2xl py-3.5 text-base font-semibold shadow-lg transition-all duration-300 hover:shadow-xl hover:scale-[1.01] active:scale-[0.99]"
|
||||||
sx={{
|
sx={{
|
||||||
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
||||||
boxShadow: `0 4px 20px ${alpha(primaryMain, 0.4)}`,
|
boxShadow: `0 4px 20px ${alpha(primaryMain, 0.4)}`,
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
|
||||||
boxShadow: `0 6px 28px ${alpha(primaryMain, 0.5)}`,
|
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>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{requestError && !loading && (
|
||||||
|
<Typography variant="body2" color="error" className="mb-6">
|
||||||
|
{requestError}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 5) Result Card (after click) */}
|
{/* 5) Result Card (after click) */}
|
||||||
{plan && (
|
{plan && (
|
||||||
<Box className='mb-6 animate-fade-in'>
|
<Box className="mb-6 animate-fade-in">
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: '24px',
|
borderRadius: "24px",
|
||||||
background: `linear-gradient(160deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 100%)`,
|
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)`,
|
boxShadow: `0 8px 32px ${alpha(primaryMain, 0.15)}, 0 2px 8px rgba(0,0,0,0.06)`,
|
||||||
border: `1px solid ${alpha(primaryMain, 0.18)}`,
|
border: `1px solid ${alpha(primaryMain, 0.18)}`,
|
||||||
overflow: 'visible'
|
overflow: "visible",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent className='p-6'>
|
<CardContent className="p-6">
|
||||||
{/* Circular moisture indicator */}
|
{/* Circular moisture indicator */}
|
||||||
<Box className='flex justify-center mbe-6'>
|
<Box className="flex justify-center mbe-6">
|
||||||
<Box className='relative'>
|
{hasNumericMoistureLevel ? (
|
||||||
<svg width={120} height={120} className='-rotate-90'>
|
<Box className="relative">
|
||||||
<circle
|
<svg width={120} height={120} className="-rotate-90">
|
||||||
cx={60}
|
<circle
|
||||||
cy={60}
|
cx={60}
|
||||||
r={52}
|
cy={60}
|
||||||
fill='none'
|
r={52}
|
||||||
stroke={alpha(primaryMain, 0.12)}
|
fill="none"
|
||||||
strokeWidth={10}
|
stroke={alpha(primaryMain, 0.12)}
|
||||||
/>
|
strokeWidth={10}
|
||||||
<circle
|
/>
|
||||||
cx={60}
|
<circle
|
||||||
cy={60}
|
cx={60}
|
||||||
r={52}
|
cy={60}
|
||||||
fill='none'
|
r={52}
|
||||||
stroke='url(#moistureGradient)'
|
fill="none"
|
||||||
strokeWidth={10}
|
stroke="url(#moistureGradient)"
|
||||||
strokeLinecap='round'
|
strokeWidth={10}
|
||||||
strokeDasharray={`${(plan.moistureLevel / 100) * 327} 327`}
|
strokeLinecap="round"
|
||||||
className='transition-all duration-1000 ease-out'
|
strokeDasharray={`${(moistureLevelValue / 100) * 327} 327`}
|
||||||
/>
|
className="transition-all duration-1000 ease-out"
|
||||||
<defs>
|
/>
|
||||||
<linearGradient id='moistureGradient' x1='0%' y1='0%' x2='100%' y2='100%'>
|
<defs>
|
||||||
<stop offset='0%' stopColor={primaryLight} />
|
<linearGradient
|
||||||
<stop offset='100%' stopColor={primaryMain} />
|
id="moistureGradient"
|
||||||
</linearGradient>
|
x1="0%"
|
||||||
</defs>
|
y1="0%"
|
||||||
</svg>
|
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
|
<Box
|
||||||
className='absolute inset-0 flex flex-col items-center justify-center'
|
className="flex flex-col items-center justify-center px-8 py-6 rounded-[28px] text-center"
|
||||||
sx={{ top: 0, left: 0, right: 0, bottom: 0 }}
|
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 }} />
|
<i
|
||||||
<Typography variant='h4' fontWeight={700} color='primary.main'>
|
className="tabler-droplet text-4xl mbe-2"
|
||||||
{plan.moistureLevel}%
|
style={{ color: primaryMain }}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
fontWeight={700}
|
||||||
|
color="primary.main"
|
||||||
|
className="mbe-1"
|
||||||
|
>
|
||||||
|
{String(plan.moistureLevel)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant='caption' color='text.secondary'>
|
<Typography variant="caption" color="text.secondary">
|
||||||
{t('result.moistureLevel')}
|
{t("result.moistureLevel")}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className='space-y-4'>
|
<Box className="space-y-4">
|
||||||
<ResultRow
|
<ResultRow
|
||||||
icon='tabler-calendar-week'
|
icon="tabler-calendar-week"
|
||||||
label={t('result.frequency')}
|
label={t("result.frequency")}
|
||||||
value={`${plan.frequencyPerWeek} ${t('result.timesPerWeek')}`}
|
value={`${plan.frequencyPerWeek} ${t("result.timesPerWeek")}`}
|
||||||
/>
|
/>
|
||||||
<ResultRow
|
<ResultRow
|
||||||
icon='tabler-clock'
|
icon="tabler-clock"
|
||||||
label={t('result.duration')}
|
label={t("result.duration")}
|
||||||
value={`${plan.durationMinutes} ${t('result.minutes')}`}
|
value={`${plan.durationMinutes} ${t("result.minutes")}`}
|
||||||
/>
|
/>
|
||||||
<ResultRow
|
<ResultRow
|
||||||
icon='tabler-sunrise'
|
icon="tabler-sunrise"
|
||||||
label={t('result.bestTime')}
|
label={t("result.bestTime")}
|
||||||
value={plan.bestTimeOfDay}
|
value={plan.bestTimeOfDay}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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 && (
|
{plan.warning && (
|
||||||
<Box
|
<Box
|
||||||
className='mt-4 p-4 rounded-2xl'
|
className="mt-4 p-4 rounded-2xl"
|
||||||
sx={{
|
sx={{
|
||||||
background: 'linear-gradient(135deg, rgba(251, 191, 36, 0.12) 0%, rgba(245, 158, 11, 0.08) 100%)',
|
background:
|
||||||
border: '1px solid rgba(251, 191, 36, 0.35)'
|
"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'>
|
<Box className="flex gap-2">
|
||||||
<i className='tabler-alert-triangle text-xl text-amber-600 mt-0.5 shrink-0' />
|
<i className="tabler-alert-triangle text-xl text-amber-600 mt-0.5 shrink-0" />
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant='subtitle2' fontWeight={600} color='warning.dark' className='mbe-1'>
|
<Typography
|
||||||
{t('result.smartWarning')}
|
variant="subtitle2"
|
||||||
|
fontWeight={600}
|
||||||
|
color="warning.dark"
|
||||||
|
className="mbe-1"
|
||||||
|
>
|
||||||
|
{t("result.smartWarning")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant='body2' color='text.secondary'>
|
<Typography variant="body2" color="text.secondary">
|
||||||
{plan.warning}
|
{plan.warning}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -296,25 +490,25 @@ export default function SmartIrrigationRecommendation() {
|
|||||||
{loading && (
|
{loading && (
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
className='mb-6'
|
className="mb-6"
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: '24px',
|
borderRadius: "24px",
|
||||||
background: `linear-gradient(160deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 100%)`,
|
background: `linear-gradient(160deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 100%)`,
|
||||||
boxShadow: `0 8px 32px ${alpha(primaryMain, 0.1)}`,
|
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">
|
||||||
<CircularProgress size={48} sx={{ color: 'primary.main' }} />
|
<CircularProgress size={48} sx={{ color: "primary.main" }} />
|
||||||
<Typography variant='body2' color='text.secondary'>
|
<Typography variant="body2" color="text.secondary">
|
||||||
{t('generating')}
|
{statusMessage ?? t("generating")}
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||||
@@ -322,116 +516,137 @@ export default function SmartIrrigationRecommendation() {
|
|||||||
function FarmBadge({
|
function FarmBadge({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
value
|
value,
|
||||||
}: {
|
}: {
|
||||||
icon: string
|
icon: string;
|
||||||
label: string
|
label: string;
|
||||||
value: string
|
value: string;
|
||||||
}) {
|
}) {
|
||||||
const theme = useTheme()
|
const theme = useTheme();
|
||||||
const primaryMain = theme.palette.primary.main
|
const primaryMain = theme.palette.primary.main;
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className='flex items-center gap-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={{
|
sx={{
|
||||||
background: `linear-gradient(145deg, ${alpha(primaryMain, 0.08)} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
background: `linear-gradient(145deg, ${alpha(primaryMain, 0.08)} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||||
border: `1px solid ${alpha(primaryMain, 0.15)}`,
|
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 }} />
|
<i className={`${icon} text-xl`} style={{ color: primaryMain }} />
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant='caption' color='text.secondary' display='block' lineHeight={1.2}>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
display="block"
|
||||||
|
lineHeight={1.2}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant='body2' fontWeight={600} color='text.primary'>
|
<Typography variant="body2" fontWeight={600} color="text.primary">
|
||||||
{value}
|
{value}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CropCard({
|
function CropCard({
|
||||||
crop,
|
crop,
|
||||||
label,
|
label,
|
||||||
selected,
|
selected,
|
||||||
onClick
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
crop: CropOption
|
crop: CropOption;
|
||||||
label: string
|
label: string;
|
||||||
selected: boolean
|
selected: boolean;
|
||||||
onClick: () => void
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const theme = useTheme()
|
const theme = useTheme();
|
||||||
const primaryMain = theme.palette.primary.main
|
const primaryMain = theme.palette.primary.main;
|
||||||
const primaryDark = theme.palette.primary.dark
|
const primaryDark = theme.palette.primary.dark;
|
||||||
const paperBg = theme.palette.background.paper
|
const paperBg = theme.palette.background.paper;
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
component='button'
|
component="button"
|
||||||
type='button'
|
type="button"
|
||||||
elevation={0}
|
elevation={0}
|
||||||
onClick={onClick}
|
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={{
|
sx={{
|
||||||
borderColor: selected ? primaryMain : 'transparent',
|
borderColor: selected ? primaryMain : "transparent",
|
||||||
background: selected
|
background: selected
|
||||||
? `linear-gradient(145deg, ${alpha(primaryMain, 0.12)} 0%, ${alpha(primaryMain, 0.06)} 100%)`
|
? `linear-gradient(145deg, ${alpha(primaryMain, 0.12)} 0%, ${alpha(primaryMain, 0.06)} 100%)`
|
||||||
: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
|
||||||
boxShadow: selected
|
boxShadow: selected
|
||||||
? `0 4px 20px ${alpha(primaryMain, 0.2)}, inset 0 1px 0 rgba(255,255,255,0.8)`
|
? `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)',
|
: "0 2px 8px rgba(0,0,0,0.04), inset 0 1px 0 rgba(255,255,255,0.9)",
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
transform: 'translateY(-2px)',
|
transform: "translateY(-2px)",
|
||||||
boxShadow: selected
|
boxShadow: selected
|
||||||
? `0 6px 24px ${alpha(primaryMain, 0.25)}`
|
? `0 6px 24px ${alpha(primaryMain, 0.25)}`
|
||||||
: `0 4px 16px ${alpha(primaryMain, 0.12)}`
|
: `0 4px 16px ${alpha(primaryMain, 0.12)}`,
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<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={{
|
sx={{
|
||||||
background: selected
|
background: selected
|
||||||
? `linear-gradient(135deg, ${primaryMain} 0%, ${primaryDark} 100%)`
|
? `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>
|
</Box>
|
||||||
<Typography variant='body2' fontWeight={600} color={selected ? 'primary.main' : 'text.primary'}>
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
fontWeight={600}
|
||||||
|
color={selected ? "primary.main" : "text.primary"}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
{selected && (
|
{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>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResultRow({
|
function ResultRow({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
value
|
value,
|
||||||
}: {
|
}: {
|
||||||
icon: string
|
icon: string;
|
||||||
label: string
|
label: string;
|
||||||
value: string
|
value: string;
|
||||||
}) {
|
}) {
|
||||||
const theme = useTheme()
|
const theme = useTheme();
|
||||||
const primaryMain = theme.palette.primary.main
|
const primaryMain = theme.palette.primary.main;
|
||||||
return (
|
return (
|
||||||
<Box className='flex items-center gap-4 p-3 rounded-2xl' sx={{ bgcolor: alpha(primaryMain, 0.06) }}>
|
<Box
|
||||||
<i className={`${icon} text-2xl shrink-0`} style={{ color: primaryMain }} />
|
className="flex items-center gap-4 p-3 rounded-2xl"
|
||||||
<Box className='flex-1 min-w-0'>
|
sx={{ bgcolor: alpha(primaryMain, 0.06) }}
|
||||||
<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}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant='body1' fontWeight={600} color='text.primary'>
|
<Typography variant="body1" fontWeight={600} color="text.primary">
|
||||||
{value}
|
{value}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user