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