diff --git a/Dockerfile b/Dockerfile index e475e42..12cebee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 && \ diff --git a/docs/crop-zoning-backend-handoff.md b/docs/crop-zoning-backend-handoff.md new file mode 100644 index 0000000..e2bdaa6 --- /dev/null +++ b/docs/crop-zoning-backend-handoff.md @@ -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` +- مقدار `cellSideKm` با مقدار پیش‌فرض `0.15` + +خروجی: +- یک `FeatureCollection` شامل سلول‌های مربعی داخل پلیگان +- برای هر سلول فقط `properties.index` ثبت می‌شود + +رفتار: +- با استفاده از bounding box پلیگان، گرید مربعی ساخته می‌شود +- فقط بخش‌های داخل پلیگان نگه داشته می‌شوند (`mask`) +- هر سلول یک اندیس ترتیبی می‌گیرد + +### 2) `createZonedGrid` + +ورودی: +- یک `GeoJSON Feature` +- مقدار `cellSideKm` با مقدار پیش‌فرض `0.15` + +خروجی: +- یک `FeatureCollection` + +برای هر زون این فیلدها تولید می‌شوند: +- `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` برگرداند تا فرانت با کمترین تغییر از آن استفاده کند diff --git a/messages/fa.json b/messages/fa.json index 54c6afe..d982d17 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -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": "چت" } } } diff --git a/recommend_task_status_frontend_backend.md b/recommend_task_status_frontend_backend.md new file mode 100644 index 0000000..6d9d98e --- /dev/null +++ b/recommend_task_status_frontend_backend.md @@ -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** را هندل کند. diff --git a/src/app/(dashboard)/(private)/farm-ai-assistant/FARM_AI_ASSISTANT_APIS.md b/src/app/(dashboard)/(private)/farm-ai-assistant/FARM_AI_ASSISTANT_APIS.md deleted file mode 100644 index 7c7fc50..0000000 --- a/src/app/(dashboard)/(private)/farm-ai-assistant/FARM_AI_ASSISTANT_APIS.md +++ /dev/null @@ -1,126 +0,0 @@ -# مستندات APIهای دستیار هوشمند مزرعه (Farm AI Assistant) - -این سند تمام APIهای مورد نیاز برای صفحه **Farm AI Assistant** را شرح می‌دهد: ورودی‌ها، خروجی‌ها و استفاده در UI. - -**مسیر صفحه:** `(dashboard)/(private)/farm-ai-assistant` -**کامپوننت اصلی:** `FarmAiAssistantChat` - ---- - -## نمای کلی - -دستیار هوشمند مزرعه برای کار به موارد زیر نیاز دارد: - -| ردیف | API | هدف | -|------|-----|------| -| ۱ | **ارسال پیام به دستیار (Chat/Complete)** | دریافت پاسخ ساخت‌یافته (توصیه، لیست، هشدار) بر اساس پیام کاربر و زمینه مزرعه | -| ۲ | **دریافت زمینه مزرعه (Farm Context)** | پر کردن نوار «زمینه مزرعه» (نوع خاک، EC آب، محصول، مرحله رشد، آخرین آبیاری) | -| ۳ | **توصیه آبیاری** | در صورت درخواست کاربر یا تصمیم دستیار برای توصیه آبیاری | -| ۴ | **توصیه کوددهی** | در صورت درخواست کاربر یا توصیه کود | -| ۵ | **تشخیص آفت از تصویر** | وقتی کاربر تصویر گیاه را ارسال می‌کند | - ---- - -## ۱. API ارسال پیام به دستیار (Farm AI Chat) - -این API هسته اصلی دستیار است و در حال حاضر در فرانت با پاسخ دمو شبیه‌سازی شده است؛ باید با API واقعی جایگزین شود. - -### ۱.۱ مشخصات - -- **متد:** `POST` -- **آدرس پیشنهادی:** `POST /api/farm-ai-assistant/chat/` یا `POST /api/farm-ai-assistant/messages/` -- **هدف:** ارسال پیام کاربر (و در صورت وجود تصویر) به همراه زمینه مزرعه و دریافت پاسخ ساخت‌یافته دستیار. - -### ۱.۲ ورودی (Request Body) - -| فیلد | نوع | اجباری | توضیح | -|------|-----|--------|--------| -| `content` | string | بله | متن پیام کاربر | -| `images` | string[] یا base64[] | خیر | آرایه آدرس تصاویر یا داده base64 (در صورت استفاده از آپلود تصویر دوربین در چت) | -| `conversation_id` | string | خیر | شناسه مکالمه برای ادامه گفتگو؛ در اولین پیام ارسال نشود | -| `farm_context` | object | توصیه | زمینه مزرعه برای پاسخ شخصی‌سازی‌شده (در صورت نبودن، بک‌اند می‌تواند از پیش‌فرض استفاده کند) | - -ساختار پیشنهادی `farm_context` (هم‌خوان با `FarmContext` در فرانت): - -```json -{ - "content": "برنامه آبیاری برای گوجه در مرحله گلدهی چطور باشد؟", - "farm_context": { - "soilType": "Loamy", - "waterEC": "1.2 dS/m", - "selectedCrop": "Tomato", - "growthStage": "Flowering", - "lastIrrigationStatus": "2 days ago" - } -} -``` - -اگر از **تصویر** استفاده شود (دکمه دوربین در input): - -```json -{ - "content": "این برگ زرد شده، چه مشکلی داره؟", - "images": ["data:image/jpeg;base64,..."], - "farm_context": { ... } -} -``` - -### ۱.۳ خروجی (Response Body) - -پاسخ باید شامل **بخش‌های ساخت‌یافته** (sections) باشد تا در UI به صورت کارت (توصیه، لیست، هشدار) رندر شود. - -**قالب پیشنهادی:** - -```json -{ - "status": "success", - "data": { - "message_id": "a-1739123456789", - "conversation_id": "conv-abc123", - "content": "", - "sections": [ - { - "type": "recommendation", - "title": "Irrigation recommendation", - "icon": "droplet", - "frequency": "3 times per week", - "amount": "15–20 L per plant", - "timing": "Early morning (05:00–07:00)", - "expandableExplanation": "Your loamy soil holds moisture well..." - }, - { - "type": "list", - "title": "Key points", - "icon": "leaf", - "items": [ - "Avoid midday watering to reduce evaporation", - "Drip irrigation preferred for root zone targeting" - ] - }, - { - "type": "warning", - "title": "Weather advisory", - "icon": "warning", - "content": "High temps forecasted next week. Consider increasing frequency." - } - ] - } -} -``` - -**ساختار هر بخش (Section) مطابق `AIResponseSection` در فرانت:** - -| فیلد | نوع | اجباری | توضیح | -|------|-----|--------|--------| -| `type` | string | بله | یکی از: `text` \| `list` \| `recommendation` \| `warning` | -| `title` | string | خیر | عنوان بخش | -| `content` | string | خیر | برای `type: "text"` یا `type: "warning"` | -| `items` | string[] | خیر | برای `type: "list"` | -| `icon` | string | خیر | یکی از: `droplet` \| `leaf` \| `warning` \| `fertilizer` \| `calendar` | -| `frequency` | string | خیر | فقط برای `recommendation`: تعداد دفعات (مثلاً در هفته) | -| `amount` | string | خیر | فقط برای `recommendation`: مقدار (مثلاً لیتر یا کیلوگرم) | -| `timing` | string | خیر | فقط برای `recommendation`: زمان پیشنهادی | -| `expandableExplanation` | string | خیر | فقط برای `recommendation`: توضیح قابل گسترش «چرا این توصیه» | - -- اگر `content` خالی باشد و فقط `sections` برگردد، در UI فقط کارت‌ها نمایش داده می‌شوند (مطابق پیاده‌سازی فعلی). -- در صورت خطا انتظار می‌رود پاسخ با `status: "error"` و پیام مناسب برگردد. diff --git a/src/libs/api/services/cropZoningService.ts b/src/libs/api/services/cropZoningService.ts index 392cbc8..84c33cd 100644 --- a/src/libs/api/services/cropZoningService.ts +++ b/src/libs/api/services/cropZoningService.ts @@ -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 + area: Feature | 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 { - status: string - data: T + status: string; + data: T; } async function unwrap(promise: Promise>): Promise { - 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>(`${PREFIX}/products/`)) + return unwrap( + apiClient.get>( + `${PREFIX}/products/`, + ), + ); }, getZonesInitial(body: { - zones: FeatureCollection - products?: string[] + zones: FeatureCollection; + products?: string[]; }): Promise { - return unwrap(apiClient.post>(`${PREFIX}/zones/initial/`, body)) + return unwrap( + apiClient.post>( + `${PREFIX}/zones/initial/`, + body, + ), + ); }, getZoneDetails(zoneId: string): Promise { - return unwrap(apiClient.get>(`${PREFIX}/zones/${zoneId}/details/`)) + return unwrap( + apiClient.get>( + `${PREFIX}/zones/${zoneId}/details/`, + ), + ); }, - getArea(): Promise { - return unwrap(apiClient.get>(`${PREFIX}/area/`)) + getArea(sensorUuid: string): Promise { + return unwrap( + apiClient.get>(`${PREFIX}/area/?sensor_uuid=${sensorUuid}`), + ).then((response) => + "task_id" in response + ? normalizeTaskInitResponse(response) + : normalizeAreaResult(response), + ); + }, + + getAreaStatus( + taskId: string, + ): Promise> { + return unwrap( + apiClient.get< + ApiResponse> + >(`${PREFIX}/area/status/${taskId}/`), + ).then((response) => ({ + ...response, + status: normalizeRecommendationTaskStatus(response.status), + result: response.result + ? normalizeAreaResult(response.result) + : undefined, + })); }, /** نیاز آبی هر منطقه — برای لایهٔ نیاز آبی */ - getZonesWaterNeed(body: { zones: FeatureCollection }): Promise<{ zones: ZoneWaterNeedData[] }> { + getZonesWaterNeed(body: { + zones: FeatureCollection; + }): Promise<{ zones: ZoneWaterNeedData[] }> { return unwrap( - apiClient.post>(`${PREFIX}/zones/water-need/`, body) - ) + apiClient.post>( + `${PREFIX}/zones/water-need/`, + body, + ), + ); }, /** کیفیت خاک هر منطقه — برای لایهٔ کیفیت خاک */ - getZonesSoilQuality(body: { zones: FeatureCollection }): Promise<{ zones: ZoneSoilQualityData[] }> { + getZonesSoilQuality(body: { + zones: FeatureCollection; + }): Promise<{ zones: ZoneSoilQualityData[] }> { return unwrap( - apiClient.post>(`${PREFIX}/zones/soil-quality/`, body) - ) + apiClient.post>( + `${PREFIX}/zones/soil-quality/`, + body, + ), + ); }, /** ریسک کشت هر منطقه — برای لایهٔ ریسک کشت */ getZonesCultivationRisk(body: { - zones: FeatureCollection + zones: FeatureCollection; }): Promise<{ zones: ZoneCultivationRiskData[] }> { return unwrap( apiClient.post>( `${PREFIX}/zones/cultivation-risk/`, - body - ) - ) - } -} + body, + ), + ); + }, +}; diff --git a/src/libs/api/services/farmAiAssistantService.ts b/src/libs/api/services/farmAiAssistantService.ts index 41a2131..27b32ce 100644 --- a/src/libs/api/services/farmAiAssistantService.ts +++ b/src/libs/api/services/farmAiAssistantService.ts @@ -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 } -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 +} + +export interface CreateConversationResponse { + id: string + message_count: number + title?: string + updated_at?: string } interface ApiResponse { @@ -52,21 +87,33 @@ function unwrap(res: ApiResponse): T { } export const farmAiAssistantService = { - /** - * Returns farm context for the context bar (soilType, waterEC, selectedCrop, growthStage, lastIrrigationStatus). - */ getContext(): Promise { - return apiClient - .get>(`${PREFIX}/context/`) - .then(unwrap) + return apiClient.get>(`${PREFIX}/context/`).then(unwrap) }, - /** - * Send user message (and optional farm_context, images, conversation_id). Returns message with sections. - */ - chat(payload: ChatPayload): Promise { + createChatTask(payload: ChatPayload): Promise { + return apiClient.post>(`${PREFIX}/chat/task/`, payload).then(unwrap) + }, + + getChatTaskStatus(taskId: string): Promise { + return apiClient.get>(`${PREFIX}/chat/task/${taskId}/status/`).then(unwrap) + }, + + getConversations(): Promise { + return apiClient.get>(`${PREFIX}/chats/`).then(unwrap) + }, + + createConversation(payload?: CreateConversationPayload): Promise { + return apiClient.post>(`${PREFIX}/chats/`, payload).then(unwrap) + }, + + deleteConversation(conversationId: string): Promise<{ conversation_id: string }> { + return apiClient.delete>(`${PREFIX}/chats/${conversationId}/`).then(unwrap) + }, + + getConversationMessages(conversationId: string): Promise { return apiClient - .post>(`${PREFIX}/chat/`, payload) + .get>(`${PREFIX}/chats/${conversationId}/messages/`) .then(unwrap) } } diff --git a/src/libs/api/services/fertilizationRecommendationService.ts b/src/libs/api/services/fertilizationRecommendationService.ts index 6889297..2151380 100644 --- a/src/libs/api/services/fertilizationRecommendationService.ts +++ b/src/libs/api/services/fertilizationRecommendationService.ts @@ -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; + soilType?: string; + organicMatter?: string; + waterEC?: string; } +export interface FertilizationRecommendationResult { + plan: FertilizationPlan; + status?: string; +} + +export type FertilizationRecommendResponse = + | FertilizationRecommendationResult + | RecommendationTaskInitResponse; + interface ApiResponse { - status: string - data: T + status: string; + data: T; } async function unwrap(promise: Promise>): Promise { - 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 { - return unwrap(apiClient.get>(`${PREFIX}/config/`)) + return unwrap( + apiClient.get>( + `${PREFIX}/config/`, + ), + ); }, - recommend(payload?: FertilizationRecommendPayload): Promise<{ plan: FertilizationPlan }> { - return unwrap(apiClient.post>(`${PREFIX}/recommend/`, payload ?? {})) + recommend( + payload?: FertilizationRecommendPayload, + ): Promise { + return unwrap( + apiClient.post>( + `${PREFIX}/recommend/`, + payload ?? {}, + ), + ).then((response) => + "task_id" in response + ? normalizeTaskInitResponse(response) + : normalizeRecommendationResult(response), + ); }, -} + + getRecommendStatus( + taskId: string, + ): Promise< + RecommendationTaskStatusResponse + > { + return unwrap( + apiClient.get< + ApiResponse< + RecommendationTaskStatusResponse + > + >(`${PREFIX}/recommend/status/${taskId}/`), + ).then((response) => ({ + ...response, + status: normalizeRecommendationTaskStatus(response.status), + result: response.result + ? normalizeRecommendationResult(response.result) + : undefined, + })); + }, +}; diff --git a/src/libs/api/services/irrigationRecommendationService.ts b/src/libs/api/services/irrigationRecommendationService.ts index cb182dd..2727aff 100644 --- a/src/libs/api/services/irrigationRecommendationService.ts +++ b/src/libs/api/services/irrigationRecommendationService.ts @@ -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; + 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 { - status: string - data: T + status: string; + data: T; } async function unwrap(promise: Promise>): Promise { - 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 { - return unwrap(apiClient.get>(`${PREFIX}/config/`)) + return unwrap( + apiClient.get>(`${PREFIX}/config/`), + ); }, - recommend(payload?: IrrigationRecommendPayload): Promise<{ plan: IrrigationPlan }> { - return unwrap(apiClient.post>(`${PREFIX}/recommend/`, payload ?? {})) + recommend( + payload?: IrrigationRecommendPayload, + ): Promise { + return unwrap( + apiClient.post>( + `${PREFIX}/recommend/`, + payload ?? {}, + ), + ).then((response) => + "task_id" in response + ? normalizeTaskInitResponse(response) + : normalizeRecommendationResult(response), + ); }, -} + + getRecommendStatus( + taskId: string, + ): Promise> { + return unwrap( + apiClient.get< + ApiResponse< + RecommendationTaskStatusResponse + > + >(`${PREFIX}/recommend/status/${taskId}/`), + ).then((response) => ({ + ...response, + status: normalizeRecommendationTaskStatus(response.status), + result: response.result + ? normalizeRecommendationResult(response.result) + : undefined, + })); + }, +}; diff --git a/src/libs/api/services/recommendationTask.ts b/src/libs/api/services/recommendationTask.ts new file mode 100644 index 0000000..9023fff --- /dev/null +++ b/src/libs/api/services/recommendationTask.ts @@ -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 { + 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"; diff --git a/src/views/dashboards/farm/cropZoning/API_INTEGRATION.md b/src/views/dashboards/farm/cropZoning/API_INTEGRATION.md new file mode 100644 index 0000000..6be742a --- /dev/null +++ b/src/views/dashboards/farm/cropZoning/API_INTEGRATION.md @@ -0,0 +1,134 @@ +# Crop Zoning API Integration + +## نحوه ارتباط با API + +### 1. دریافت اطلاعات Area و Zones + +```typescript +GET /api/crop-zoning/area/?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 && ( + + {progress.message} + + {progress.percent}% + +)} +``` + +### 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 شده) diff --git a/src/views/dashboards/farm/cropZoning/CropZoningMap.tsx b/src/views/dashboards/farm/cropZoning/CropZoningMap.tsx index 19657fb..48bb88c 100644 --- a/src/views/dashboards/farm/cropZoning/CropZoningMap.tsx +++ b/src/views/dashboards/farm/cropZoning/CropZoningMap.tsx @@ -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).eachLayer((layer) => drawnItems.addLayer(layer)) emitAreaChange() + const bounds = drawnItems.getBounds() + if (bounds.isValid()) { + map.fitBounds(bounds, { padding: [24, 24] }) + } } const onCreated = (e: L.LeafletEvent) => { diff --git a/src/views/dashboards/farm/cropZoning/CropZoningWrapper.tsx b/src/views/dashboards/farm/cropZoning/CropZoningWrapper.tsx index f6b5491..b082d73 100644 --- a/src/views/dashboards/farm/cropZoning/CropZoningWrapper.tsx +++ b/src/views/dashboards/farm/cropZoning/CropZoningWrapper.tsx @@ -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: () => ( - + - ) -}) + ), +}); -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(null) - const [areaLoading, setAreaLoading] = useState(true) - const [zonesData, setZonesData] = useState(null) - const [zonesWaterNeed, setZonesWaterNeed] = useState(null) - const [zonesSoilQuality, setZonesSoilQuality] = useState(null) - const [zonesCultivationRisk, setZonesCultivationRisk] = useState(null) - const [products, setProducts] = useState([]) - const [productsLoading, setProductsLoading] = useState(true) - const [zonesLoading, setZonesLoading] = useState(false) - const [layerDataLoading, setLayerDataLoading] = useState(false) - const [activeLayer, setActiveLayer] = useState('crops') - const [selectedZone, setSelectedZone] = useState(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(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(null); + const [zonesData, setZonesData] = useState(null); + const [products, setProducts] = useState([]); + const [activeLayer, setActiveLayer] = useState("crops"); + const [selectedZone, setSelectedZone] = useState(null); + const [panelOpen, setPanelOpen] = useState(false); useEffect(() => { - cropZoningService - .getProducts() + setIsClientReady(true); + }, []); + + useEffect(() => { + cropZoningService.getProducts() .then(res => setProducts(res.products)) - .catch(() => setProducts([])) - .finally(() => setProductsLoading(false)) - }, []) + .catch(() => setProducts([])); + }, []); useEffect(() => { - setAreaLoading(true) - cropZoningService - .getArea() - .then(res => setAreaGeoJson(res.area as unknown as MapDrawGeoJSON)) - .catch(() => setAreaGeoJson(null)) - .finally(() => setAreaLoading(false)) - }, []) + let cancelled = false; - const fetchZones = useCallback((geojson: MapDrawGeoJSON) => { - if (!isPolygon(geojson)) { - setZonesData(null) - setZonesWaterNeed(null) - setZonesSoilQuality(null) - setZonesCultivationRisk(null) - return - } - setZonesWaterNeed(null) - setZonesSoilQuality(null) - setZonesCultivationRisk(null) - setZonesLoading(true) - const grid = createGridFromPolygon(geojson as unknown as import('geojson').Feature) - cropZoningService - .getZonesInitial({ zones: grid }) - .then(res => setZonesData(res.zones)) - .catch(() => setZonesData(null)) - .finally(() => setZonesLoading(false)) - }, []) + const loadArea = async () => { + if (!isClientReady) return; - useEffect(() => { - if (isPolygon(areaGeoJson)) { - fetchZones(areaGeoJson) - } else { - setZonesData(null) - setZonesWaterNeed(null) - setZonesSoilQuality(null) - setZonesCultivationRisk(null) - } - }, [areaGeoJson, optimizationKey, fetchZones]) + if (!sensorHub?.id) { + setError(t("errors.noSensor")); + setLoading(false); + return; + } - const gridForLayers = isPolygon(areaGeoJson) - ? createGridFromPolygon(areaGeoJson as unknown as import('geojson').Feature) - : null + setLoading(true); + setError(null); + setProgress({ message: t("loadingArea"), percent: 0 }); - useEffect(() => { - if (!gridForLayers || zonesLoading) return - if (activeLayer === 'waterNeed' && zonesWaterNeed === null) { - setLayerDataLoading(true) - cropZoningService - .getZonesWaterNeed({ zones: gridForLayers }) - .then(res => setZonesWaterNeed(res.zones)) - .catch(() => setZonesWaterNeed(null)) - .finally(() => setLayerDataLoading(false)) - } else if (activeLayer === 'soilQuality' && zonesSoilQuality === null) { - setLayerDataLoading(true) - cropZoningService - .getZonesSoilQuality({ zones: gridForLayers }) - .then(res => setZonesSoilQuality(res.zones)) - .catch(() => setZonesSoilQuality(null)) - .finally(() => setLayerDataLoading(false)) - } else if (activeLayer === 'cultivationRisk' && zonesCultivationRisk === null) { - setLayerDataLoading(true) - cropZoningService - .getZonesCultivationRisk({ zones: gridForLayers }) - .then(res => setZonesCultivationRisk(res.zones)) - .catch(() => setZonesCultivationRisk(null)) - .finally(() => setLayerDataLoading(false)) - } - }, [activeLayer, gridForLayers, zonesLoading, zonesWaterNeed, zonesSoilQuality, zonesCultivationRisk]) + try { + let polls = 0; + + while (!cancelled && polls < MAX_POLLS) { + const res = await cropZoningService.getArea(sensorHub.id); - 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 - ? `
-
${cropLabel}
-
درصد تطابق: ${z.matchPercent ?? '-'}%
-
نیاز آب: ${z.waterNeed ?? '-'}
-
سود تخمینی: ${z.estimatedProfit ?? '-'}
-
` - : `
-
غیر قابل کشت
-
این بخش برای کشت مناسب تشخیص داده نشده است.
-
` - const color = cultivable ? (CROP_COLORS[z.crop as CropType] ?? '#94a3b8') : '#94a3b8' - return { - zoneId: z.zoneId, - geometry: z.geometry, - color, - tooltipContent, - cultivable, - zoneInitialData: z + if (!("area" in res)) break; + + const task = res.task; + const taskStatus = getNormalizedTaskStatus(task?.status); + + if (task) { + setProgress({ + message: task.message || task.stage_label || t("loadingArea"), + percent: task.progress_percent || 0, + }); + } + + if (taskStatus === "completed" || taskStatus === "success") { + setAreaGeoJson(res.area as unknown as MapDrawGeoJSON); + if (res.zones) setZonesData(res.zones as ZoneInitialData[]); + break; + } + + if ((!task && res.area) || (res.area && taskStatus !== "pending" && taskStatus !== "processing")) { + setAreaGeoJson(res.area as unknown as MapDrawGeoJSON); + if (res.zones) setZonesData(res.zones as ZoneInitialData[]); + break; + } + + if (taskStatus === "failed" || taskStatus === "failure") { + throw new Error(task.message || t("errors.areaLoadFailed")); + } + + if (taskStatus === "pending" || taskStatus === "processing") { + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL)); + polls++; + } else { + break; + } } - }) - } - if (activeLayer === 'waterNeed' && zonesWaterNeed) { - const levelLabels = { low: 'کم', medium: 'متوسط', high: 'زیاد' } - return zonesWaterNeed.map(z => ({ - zoneId: z.zoneId, - geometry: z.geometry, - color: z.color, - tooltipContent: `
-
نیاز آبی: ${levelLabels[z.level]}
-
${z.value ?? '-'}
-
`, - 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: `
-
کیفیت خاک: ${levelLabels[z.level]}
-
امتیاز: ${z.score ?? '-'}
-
`, - 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: `
-
ریسک کشت: ${levelLabels[z.level]}
-
`, - cultivable: true, - zoneInitialData: { zoneId: z.zoneId, geometry: z.geometry } as ZoneInitialData - })) - } - return null - }, [activeLayer, zonesData, zonesWaterNeed, zonesSoilQuality, zonesCultivationRisk, products]) + + 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); + } + } + }; - const handleAreaChange = useCallback((geojson: MapDrawGeoJSON) => { - setAreaGeoJson(geojson) - }, []) + loadArea(); + return () => { cancelled = true; }; + }, [isClientReady, sensorHub, t]); + + const mapZonesData = useMemo(() => { + if (activeLayer === "crops" && zonesData) { + return zonesData.map(z => ({ + zoneId: z.zoneId, + geometry: z.geometry, + color: z.crop ? CROP_COLORS[z.crop as CropType] || "#94a3b8" : "#94a3b8", + tooltipContent: `
${z.crop || "نامشخص"}
`, + cultivable: !!z.crop, + zoneInitialData: z, + })); + } + return null; + }, [activeLayer, zonesData]); const handleZoneClick = useCallback((zone: ZoneInitialData) => { - setZoneDetailLoading(true) - setPanelOpen(true) - setSelectedZone(null) - cropZoningService - .getZoneDetails(zone.zoneId) - .then(details => setSelectedZone(details)) - .catch(() => setSelectedZone(null)) - .finally(() => setZoneDetailLoading(false)) - }, []) - - const handleOptimize = useCallback(() => { - setOptimizationKey(k => k + 1) - }, []) + setPanelOpen(true); + setSelectedZone(null); + cropZoningService.getZoneDetails(zone.zoneId) + .then(setSelectedZone) + .catch(() => setSelectedZone(null)); + }, []); return ( - - - + + + {areaGeoJson ? ( [p.id, p.label]))} readOnly /> ) : ( - + )} - {(areaLoading || zonesLoading || (activeLayer !== 'crops' && layerDataLoading)) && ( - + {loading && ( + + {progress && ( + + + {progress.message} + + + + {progress.percent}% + + + )} + + )} + + {error && !loading && ( + + {error} )} - - - - {areaGeoJson && ( - - - - )} + - setPanelOpen(false)} - zone={selectedZone} - products={products} - loading={zoneDetailLoading} - /> - + setPanelOpen(false)} zone={selectedZone} products={products} loading={false} /> - ) + ); } diff --git a/src/views/dashboards/farm/farmAiAssistant/ChatSidebar.tsx b/src/views/dashboards/farm/farmAiAssistant/ChatSidebar.tsx new file mode 100644 index 0000000..4d5d810 --- /dev/null +++ b/src/views/dashboards/farm/farmAiAssistant/ChatSidebar.tsx @@ -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 ( + <> + + + + + + {t('sidebar.title')} + + + + + + + + {loading ? ( + + + + ) : conversations.length === 0 ? ( + + + + {t('sidebar.empty')} + + + ) : ( + + {conversations.map((conversation, index) => ( + 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) + } + } + }} + > + + + + + + ))} + + )} + + + + ) +} diff --git a/src/views/dashboards/farm/farmAiAssistant/FarmAiAssistantChat.tsx b/src/views/dashboards/farm/farmAiAssistant/FarmAiAssistantChat.tsx index eba2fe4..c92bda2 100644 --- a/src/views/dashboards/farm/farmAiAssistant/FarmAiAssistantChat.tsx +++ b/src/views/dashboards/farm/farmAiAssistant/FarmAiAssistantChat.tsx @@ -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(DEFAULT_FARM_CONTEXT) const [contextLoading, setContextLoading] = useState(true) const [conversationId, setConversationId] = useState(null) + const [conversations, setConversations] = useState([]) + const [conversationLoading, setConversationLoading] = useState(false) + const [sidebarOpen, setSidebarOpen] = useState(false) const scrollRef = useRef(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 */} + setSidebarOpen(false)} + activeConversationId={conversationId} + onSelectConversation={handleSelectConversation} + getConversationLabel={getConversationLabel} + /> + + + setSidebarOpen(prev => !prev)} + sx={{ + borderRadius: '12px', + bgcolor: alpha(primary.main, 0.08), + '&:hover': { bgcolor: alpha(primary.main, 0.16) } + }} + > + + + + 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(DEFAULT_FARM_DATA) - const [growthStages, setGrowthStages] = useState(DEFAULT_GROWTH_STAGES) - const [cropOptions, setCropOptions] = useState(DEFAULT_CROP_OPTIONS) - const [configLoading, setConfigLoading] = useState(true) - const [configError, setConfigError] = useState(null) - const [growthStage, setGrowthStage] = useState(DEFAULT_GROWTH_STAGES[0].id) - const [selectedCrop, setSelectedCrop] = useState(null) - const [plan, setPlan] = useState(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(DEFAULT_FARM_DATA); + const [growthStages, setGrowthStages] = useState( + DEFAULT_GROWTH_STAGES, + ); + const [cropOptions, setCropOptions] = + useState(DEFAULT_CROP_OPTIONS); + const [configLoading, setConfigLoading] = useState(true); + const [configError, setConfigError] = useState(null); + const [growthStage, setGrowthStage] = useState( + DEFAULT_GROWTH_STAGES[0].id, + ); + const [selectedCrop, setSelectedCrop] = useState(null); + const [plan, setPlan] = useState(null); + const [loading, setLoading] = useState(false); + const [requestError, setRequestError] = useState(null); + const [statusMessage, setStatusMessage] = useState(null); + const [reasoningExpanded, setReasoningExpanded] = useState(false); useEffect(() => { fertilizationRecommendationService .getConfig() .then(({ farmData: farm, growthStages: stages, cropOptions: crops }) => { - if (farm) setFarmData(farm) + if (farm) setFarmData(farm); if (stages?.length) { - setGrowthStages(stages) - setGrowthStage(stages[0].id) + setGrowthStages(stages); + setGrowthStage(stages[0].id); } - if (crops?.length) setCropOptions(crops) + if (crops?.length) setCropOptions(crops); }) .catch((err: { message?: string }) => { - setConfigError(err?.message ?? 'Failed to load config') + setConfigError(err?.message ?? "Failed to load config"); }) - .finally(() => setConfigLoading(false)) - }, []) + .finally(() => setConfigLoading(false)); + }, []); const handleGenerate = async () => { - if (!selectedCrop) return - setLoading(true) - setPlan(null) - setReasoningExpanded(false) + if (!selectedCrop) return; + setLoading(true); + setPlan(null); + setRequestError(null); + setStatusMessage(t("generating")); + setReasoningExpanded(false); try { - const { plan: nextPlan } = await fertilizationRecommendationService.recommend({ - crop_id: selectedCrop, - growth_stage: growthStage, - soilType: farmData.soilType, - organicMatter: farmData.organicMatter, - waterEC: farmData.waterEC, - }) - setPlan(nextPlan) - } catch { - setPlan(null) - } finally { - setLoading(false) - } - } + const recommendation = await fertilizationRecommendationService.recommend( + { + crop_id: selectedCrop, + growth_stage: growthStage, + farm_data: { + soilType: farmData.soilType, + organicMatter: farmData.organicMatter, + waterEC: farmData.waterEC, + }, + soilType: farmData.soilType, + organicMatter: farmData.organicMatter, + waterEC: farmData.waterEC, + }, + ); - const stageIndex = growthStages.findIndex(s => s.id === growthStage) + if ("task_id" in recommendation) { + let attempts = 0; + let taskStatus = + await fertilizationRecommendationService.getRecommendStatus( + recommendation.task_id, + ); + + while (isRecommendationTaskRunning(taskStatus.status)) { + attempts += 1; + setStatusMessage(taskStatus.progress?.message ?? t("generating")); + + if (attempts >= 20) { + throw new Error(t("errors.timeout")); + } + + await sleep(1500); + taskStatus = + await fertilizationRecommendationService.getRecommendStatus( + recommendation.task_id, + ); + } + + if (taskStatus.status === "failed" || !taskStatus.result?.plan) { + throw new Error(taskStatus.error ?? t("errors.generateFailed")); + } + + setPlan(taskStatus.result.plan); + + return; + } + + if (!recommendation.plan) { + throw new Error(t("errors.generateFailed")); + } + + setPlan(recommendation.plan); + } catch (error) { + setPlan(null); + setRequestError(getErrorMessage(error, t("errors.generateFailed"))); + } finally { + setLoading(false); + setStatusMessage(null); + } + }; + + const stageIndex = growthStages.findIndex((s) => s.id === growthStage); return ( `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", }} > - + {/* 1) Header */} - + - {t('title')} + {t("title")} - {t('subtitle')} + {t("subtitle")} {/* 2) Farm Data Card */} - - - - {t('farmData.title')} + + + + {t("farmData.title")} `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)}`, }} > - - {t('verifiedBadge')} + + {t("verifiedBadge")} - - + + - + {/* 3) Growth Stage Selector */} - - {t('growthStage.title')} + + {t("growthStage.title")} - + {growthStages.map((stage, idx) => { - const isSelected = growthStage === stage.id - const isPast = idx < stageIndex + const isSelected = growthStage === stage.id; + const isPast = idx < stageIndex; return ( 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)}`, + }, }} > {t(`growthStage.${stage.id}`)} - ) + ); })} {/* 4) Plant Selection */} - - {t('plantSelection.title')} + + {t("plantSelection.title")} {configLoading ? ( - - + + ) : configError ? ( - + {configError} ) : ( - - {cropOptions.map(crop => ( - - setSelectedCrop(prev => (prev === crop.id ? null : crop.id)) - } - /> - ))} - + + {cropOptions.map((crop) => ( + + setSelectedCrop((prev) => (prev === crop.id ? null : crop.id)) + } + /> + ))} + )} {/* 5) Primary CTA Button - End of form */} - + + {requestError && !loading && ( + + {requestError} + + )} + {/* 6) Result Section - Prescription style */} {plan && ( - + - - - - - {t('result.title')} + + + + + {t("result.title")} - + {/* Expandable "Why this recommendation?" */} 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) } }} > - - - - {t('result.whyRecommendation')} + + + + {t("result.whyRecommendation")} - + {plan.reasoning} @@ -383,32 +491,35 @@ export default function SmartFertilizationRecommendation() { {loading && ( - + - + - - {t('generating')} + + {statusMessage ?? t("generating")} )} - ) + ); } // ─── 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 ( - + {label} - + {value} - ) + ); } 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 ( {label} {selected && ( - + )} - ) + ); } 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 ( - - - + + + {label} - + {value} - ) + ); } diff --git a/src/views/dashboards/farm/smartIrrigation/SmartIrrigationRecommendation.tsx b/src/views/dashboards/farm/smartIrrigation/SmartIrrigationRecommendation.tsx index c626adf..5678a7a 100644 --- a/src/views/dashboards/farm/smartIrrigation/SmartIrrigationRecommendation.tsx +++ b/src/views/dashboards/farm/smartIrrigation/SmartIrrigationRecommendation.tsx @@ -1,286 +1,480 @@ -'use client' +"use client"; -import { useState, useEffect } from 'react' -import { useTranslations } from 'next-intl' -import Box from '@mui/material/Box' -import Card from '@mui/material/Card' -import CardContent from '@mui/material/CardContent' -import Typography from '@mui/material/Typography' -import Button from '@mui/material/Button' -import IconButton from '@mui/material/IconButton' -import CircularProgress from '@mui/material/CircularProgress' -import { useTheme, alpha } from '@mui/material/styles' -import type { FarmInfo, CropOption, IrrigationPlan } from '@/libs/api/services/irrigationRecommendationService' -import { irrigationRecommendationService } from '@/libs/api/services/irrigationRecommendationService' +import { useState, useEffect } from "react"; +import { useTranslations } from "next-intl"; +import Box from "@mui/material/Box"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Typography from "@mui/material/Typography"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import CircularProgress from "@mui/material/CircularProgress"; +import { useTheme, alpha } from "@mui/material/styles"; +import type { + FarmInfo, + CropOption, + IrrigationPlan, + WaterBalance, +} from "@/libs/api/services/irrigationRecommendationService"; +import { irrigationRecommendationService } from "@/libs/api/services/irrigationRecommendationService"; +import { isRecommendationTaskRunning } from "@/libs/api/services/recommendationTask"; const DEFAULT_FARM_INFO: FarmInfo = { - soilType: 'Loamy', - waterQuality: 'Medium EC', - climateZone: 'Temperate' -} + soilType: "Loamy", + waterQuality: "Medium EC", + climateZone: "Temperate", +}; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const getErrorMessage = (error: unknown, fallback: string) => + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ? error.message + : fallback; export default function SmartIrrigationRecommendation() { - const t = useTranslations('irrigation') - const theme = useTheme() - const [farmInfo, setFarmInfo] = useState(DEFAULT_FARM_INFO) - const [cropOptions, setCropOptions] = useState([]) - const [configLoading, setConfigLoading] = useState(true) - const [configError, setConfigError] = useState(null) - const [selectedCrop, setSelectedCrop] = useState(null) - const [plan, setPlan] = useState(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(DEFAULT_FARM_INFO); + const [cropOptions, setCropOptions] = useState([]); + const [configLoading, setConfigLoading] = useState(true); + const [configError, setConfigError] = useState(null); + const [selectedCrop, setSelectedCrop] = useState(null); + const [plan, setPlan] = useState(null); + const [waterBalance, setWaterBalance] = useState(null); + const [loading, setLoading] = useState(false); + const [requestError, setRequestError] = useState(null); + const [statusMessage, setStatusMessage] = useState(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 ( `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", }} > - + {/* 1) Dynamic Header */} - + - {t('title')} + {t("title")} - - {t('subtitle')} + + {t("subtitle")} {/* 2) Farm Info Card */} - - - - {t('farmInfo.title')} + + + + {t("farmInfo.title")} - + `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, }} > - - {t('verifiedBadge')} + + {t("verifiedBadge")} - - + + - - - - + + + + {/* 3) Plant Selection Section */} - - {t('plantSelection.title')} + + {t("plantSelection.title")} {configLoading ? ( - - + + ) : configError ? ( - + {configError} ) : ( - - {(cropOptions.length > 0 ? cropOptions : []).map(crop => ( - setSelectedCrop(prev => (prev === crop.id ? null : crop.id))} - /> - ))} - + + {(cropOptions.length > 0 ? cropOptions : []).map((crop) => ( + + setSelectedCrop((prev) => (prev === crop.id ? null : crop.id)) + } + /> + ))} + )} {/* 4) Primary CTA Button - End of form */} - + + {requestError && !loading && ( + + {requestError} + + )} + {/* 5) Result Card (after click) */} {plan && ( - + - + {/* Circular moisture indicator */} - - - - - - - - - - - - + + {hasNumericMoistureLevel ? ( + + + + + + + + + + + + + + + {moistureLevelValue}% + + + {t("result.moistureLevel")} + + + + ) : ( - - - {plan.moistureLevel}% + + + {String(plan.moistureLevel)} - - {t('result.moistureLevel')} + + {t("result.moistureLevel")} - + )} - + + {waterBalance && ( + + + {t("result.waterBalance")} + + {nextWaterBalanceDay && ( + <> + + + + + )} + {typeof waterBalance.active_kc === "number" && ( + + )} + + )} + {plan.warning && ( - - + + - - {t('result.smartWarning')} + + {t("result.smartWarning")} - + {plan.warning} @@ -296,25 +490,25 @@ export default function SmartIrrigationRecommendation() { {loading && ( - - - - {t('generating')} + + + + {statusMessage ?? t("generating")} )} - ) + ); } // ─── 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 ( - + {label} - + {value} - ) + ); } 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 ( - + - + {label} {selected && ( - + )} - ) + ); } 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 ( - - - - + + + + {label} - + {value} - ) + ); }