This commit is contained in:
2026-04-30 02:09:56 +03:30
parent 04d678fda4
commit 9946f01cca
7 changed files with 2238 additions and 301 deletions
+40
View File
@@ -0,0 +1,40 @@
# Yield & Harvest AI Integration
این سند خلاصه قرارداد فرانت برای ماژول `yield-harvest` است.
## اصل اصلی
برای تمام endpointهای farm-based در این ماژول، فرانت فقط باید `farm_uuid` را ارسال کند.
فرانت **نباید** این فیلدها را بفرستد:
- `plant_name`
- `crop_name`
- هر context دیگری که backend از مزرعه استخراج می کند
`plant_name` فقط در backend از روی مزرعه استخراج می شود و سپس backend آن را برای AI ارسال می کند.
## endpointهای متصل شده در فرانت
- `POST /api/yield-harvest/current-farm-chart/`
- `POST /api/yield-harvest/growth/`
- `GET /api/yield-harvest/growth/{task_id}/status/`
- `POST /api/yield-harvest/harvest-prediction/`
- `POST /api/yield-harvest/yield-prediction/`
- `GET /api/yield-harvest/yield-harvest-summary/`
## وضعیت فعلی فرانت
فایل `src/libs/api/services/yieldHarvestService.ts` فقط با `farm_uuid` به endpointهای farm-based درخواست می زند.
نمونه payload ارسالی از فرانت:
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111"
}
```
## نکته مهم
حتی اگر responseهای backend شامل `plant_name` باشند، این فیلد فقط برای نمایش یا logging است و از سمت فرانت در request ارسال نمی شود.
@@ -0,0 +1,709 @@
# Yield & Harvest Backend Requirements
این سند مشخص می‌کند صفحه `yield-harvest` برای کامل شدن تمام کارت‌ها چه داده‌ای از بک‌اند نیاز دارد، هر فیلد دقیقاً کجا مصرف می‌شود، و چه بخش‌هایی هنوز در فرانت mock هستند.
## هدف
در حال حاضر صفحه `yield-harvest` از این فایل‌ها ساخته می‌شود:
- `src/views/dashboards/farm/PlantProductionPage.tsx`
- `src/views/dashboards/farm/YieldHarvestPageWrapper.tsx`
- `src/views/dashboards/farm/YieldSeasonHighlightsCard.tsx`
- `src/views/dashboards/farm/FarmOverviewKPIs.tsx`
- `src/views/dashboards/farm/HarvestPredictionCard.tsx`
- `src/views/dashboards/farm/HarvestReadinessZonesCard.tsx`
- `src/views/dashboards/farm/YieldQualityBandsCard.tsx`
- `src/views/dashboards/farm/HarvestOperationsCard.tsx`
- `src/views/dashboards/farm/YieldPredictionChart.tsx`
هدف این سند این است که تیم بک‌اند دقیقاً بداند:
- هر کارت چه payloadی می‌خواهد
- کدام فیلدها اجباری هستند
- کدام مقادیر باید `number` باشند و کدام‌ها `string`
- چه endpointی بهتر است این اطلاعات را برگرداند
---
## وضعیت فعلی فرانت
### داده‌هایی که همین الان از API خوانده می‌شوند
فرانت الان از `src/libs/api/services/yieldHarvestService.ts` این endpoint را صدا می‌زند:
`GET /api/yield-harvest/summary/?farm_uuid=<uuid>`
و فقط این سه بخش را map می‌کند:
- `yield_prediction`
- `harvest_prediction_card`
- `yield_prediction_chart`
### داده‌هایی که هنوز mock هستند
این کارت‌ها هنوز با داده mock رندر می‌شوند و برای production باید از بک‌اند بیایند:
- `YieldSeasonHighlightsCard`
- `HarvestReadinessZonesCard`
- `YieldQualityBandsCard`
- `HarvestOperationsCard`
### نکته مهم درباره Plant Simulator
کامپوننت `src/views/dashboards/farm/plantSimulator/PlantSimulator.tsx` فعلاً self-contained است و برای نمایش فعلی به API نیاز ندارد.
پس اگر هدف فعلی فقط کامل شدن کارت‌های اطلاعاتی صفحه است، برای simulator نیازی به endpoint جدید نیست.
---
## پیشنهاد اصلی
بهترین راه این است که همان endpoint فعلی صفحه توسعه پیدا کند و تمام data blockهای موردنیاز را یکجا برگرداند:
`GET /api/yield-harvest/summary/`
### query params
- `farm_uuid` - اجباری
### query params پیشنهادی برای آینده
- `season_year` - برای انتخاب فصل یا سال زراعی
- `crop_name` - اگر یک مزرعه چند محصول داشته باشد
- `lang` - اگر بخواهید backend متن‌های آماده UI را چندزبانه برگرداند
---
## payload کامل پیشنهادی
نمونه response کامل که تمام کارت‌های فعلی صفحه را پوشش می‌دهد:
```json
{
"code": 200,
"msg": "success",
"data": {
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"season_highlights_card": {
"title": "اتاق فرمان برداشت این فصل",
"subtitle": "خلاصه سریع وضعیت عملکرد، کیفیت و بهترین پنجره فروش برای مزرعه انتخاب شده.",
"seasonLabel": "فصل 1404",
"badges": ["کیفیت ممتاز", "آماده بسته بندی", "ریسک پایین"],
"spotlight": {
"title": "پنجره طلایی فروش",
"value": "3 روز اول بعد از برداشت",
"caption": "در این بازه برآورد قیمت فروش حدود 8٪ بهتر از میانگین هفتگی است."
},
"metrics": [
{
"label": "سطح قابل برداشت",
"value": "18.6 هکتار",
"caption": "4 قطعه در اولویت نخست قرار دارند.",
"avatarIcon": "tabler-map-2",
"avatarColor": "success"
},
{
"label": "گرید ممتاز",
"value": "46%",
"caption": "بالاترین سهم کیفیت مربوط به قطعه A2 است.",
"avatarIcon": "tabler-rosette-discount-check",
"avatarColor": "warning"
},
{
"label": "درآمد هدف",
"value": "1.84 میلیارد",
"caption": "با فرض فروش در بازه پیشنهادی مدل.",
"avatarIcon": "tabler-cash-banknote",
"avatarColor": "primary"
}
]
},
"yield_prediction": {
"kpis": [
{
"id": "predicted-yield",
"title": "عملکرد پیش بینی شده",
"subtitle": "فصل جاری",
"stats": "42.8 تن",
"avatarColor": "primary",
"avatarIcon": "tabler-chart-arcs",
"chipText": "+12%",
"chipColor": "success"
},
{
"id": "harvest-readiness",
"title": "آمادگی برداشت",
"subtitle": "میانگین مزرعه",
"stats": "84%",
"avatarColor": "success",
"avatarIcon": "tabler-plant-2",
"chipText": "روی برنامه",
"chipColor": "success"
},
{
"id": "quality-score",
"title": "امتیاز کیفیت",
"subtitle": "برآورد هوش مصنوعی",
"stats": "91/100",
"avatarColor": "info",
"avatarIcon": "tabler-stars",
"chipText": "+4 واحد",
"chipColor": "success"
},
{
"id": "loss-risk",
"title": "ریسک افت محصول",
"subtitle": "آب و هوا و آفات",
"stats": "6.5%",
"avatarColor": "warning",
"avatarIcon": "tabler-alert-triangle",
"chipText": "پایین",
"chipColor": "warning"
}
]
},
"harvest_prediction_card": {
"dateFormatted": "28 شهریور",
"daysUntil": 18,
"description": "با توجه به روند رشد، الگوی آبیاری و وضعیت دمایی اخیر، این مزرعه در هفته آخر شهریور به نقطه ایده آل برداشت می رسد."
},
"harvest_readiness_zones": {
"averageReadiness": "84%",
"blocks": [
{
"name": "قطعه A1",
"cultivar": "گندم سیروان",
"readiness": 92,
"harvestDate": "26 شهریور",
"expectedYield": "12.4 تن",
"moisture": "11.8%"
},
{
"name": "قطعه A2",
"cultivar": "گندم پیشگام",
"readiness": 87,
"harvestDate": "27 شهریور",
"expectedYield": "10.1 تن",
"moisture": "12.3%"
}
]
},
"yield_quality_bands": {
"bands": [
{
"label": "گرید ممتاز",
"share": 46,
"volume": "19.7 تن",
"premium": "+18% قیمت",
"color": "#2e7d32"
},
{
"label": "گرید درجه یک",
"share": 34,
"volume": "14.5 تن",
"premium": "+9% قیمت",
"color": "#0288d1"
},
{
"label": "گرید فرآوری",
"share": 20,
"volume": "8.6 تن",
"premium": "فروش پایه",
"color": "#ed6c02"
}
],
"stats": [
{ "label": "میانگین بریکس", "value": "14.8" },
{ "label": "یکنواختی دانه", "value": "89%" },
{ "label": "ضایعات قابل انتظار", "value": "2.1%" },
{ "label": "پتانسیل صادرات", "value": "بالا" }
]
},
"harvest_operations_card": {
"summary": "اگر برداشت از قطعات A1 و A2 در دو شیفت اول انجام شود، کیفیت ممتاز حفظ می شود و فشار روی مرحله سورتینگ متعادل می ماند.",
"steps": [
{
"title": "برداشت قطعات اولویت دار",
"note": "تمرکز ابتدا روی A1 و سپس A2 باشد تا گرید ممتاز در دمای پایین صبح جمع آوری شود.",
"status": "امروز",
"statusColor": "success"
},
{
"title": "سورت و تفکیک بر اساس کیفیت",
"note": "محصول ممتاز از جریان فرآوری جدا شود تا فروش با قیمت پریمیوم حفظ شود.",
"status": "بعد از برداشت",
"statusColor": "primary"
},
{
"title": "انتقال سریع به انبار خنک",
"note": "انتقال نهایی حداکثر تا 6 ساعت پس از برداشت انجام شود.",
"status": "ضروری",
"statusColor": "warning"
}
],
"outputs": [
{ "label": "شیفت پیشنهادی", "value": "2 شیفت" },
{ "label": "ظرفیت سورتینگ", "value": "15 تن/روز" },
{ "label": "نیروی مورد نیاز", "value": "12 نفر" },
{ "label": "مدت تا ارسال", "value": "6 ساعت" }
]
},
"yield_prediction_chart": {
"categories": ["فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور", "مهر", "آبان"],
"series": [
{
"name": "سال قبل",
"data": [9, 11, 13, 16, 19, 24, 28, 31]
},
{
"name": "سال جاری",
"data": [10, 12, 15, 18, 23, 29, 34, 39]
}
],
"summary": [
{
"title": "بیشترین خروجی پیش بینی شده",
"subtitle": "بهترین ماه برداشت",
"amount": "39 تن",
"avatarColor": "success",
"avatarIcon": "tabler-trending-up"
},
{
"title": "رشد این فصل",
"subtitle": "نسبت به سال قبل",
"amount": "+11.2 تن",
"avatarColor": "primary",
"avatarIcon": "tabler-chart-line"
}
]
}
}
}
```
---
## توضیح کامل هر کارت
## 1) Season Highlights Card
کامپوننت: `src/views/dashboards/farm/YieldSeasonHighlightsCard.tsx`
### حداقل داده لازم برای رندر
- `title`
- `subtitle`
- `spotlight`
- `metrics[]`
اگر `title` خالی باشد یا `spotlight` وجود نداشته باشد یا `metrics` خالی باشد، کارت رندر نمی‌شود.
### فیلدها
- `title`: تیتر اصلی کارت
- `subtitle`: توضیح متنی زیر تیتر
- `seasonLabel`: چیپ فصل، مثل `فصل 1404`
- `badges[]`: لیبل‌های کوتاه وضعیت
- `spotlight.title`: عنوان بخش برجسته
- `spotlight.value`: مقدار اصلی بخش برجسته
- `spotlight.caption`: توضیح تکمیلی
- `metrics[]`: آرایه کارت‌های کوچک پایین
### ساختار `metrics[]`
هر آیتم این فیلدها را نیاز دارد:
- `label`: عنوان metric
- `value`: مقدار اصلی metric
- `caption`: توضیح کوتاه زیر metric
- `avatarIcon`: نام icon از Tabler
- `avatarColor`: یکی از این مقادیر:
- `primary`
- `success`
- `info`
- `warning`
### نکته بک‌اند
این کارت فعلاً به API وصل نیست و باید یا:
- به همین summary endpoint اضافه شود
- یا یک endpoint جدا برای page header/hero داشته باشد
پیشنهاد بهتر: در همان `summary` برگردد.
---
## 2) KPI Row
کامپوننت: `src/views/dashboards/farm/FarmOverviewKPIs.tsx`
### ساختار موردنیاز
```json
{
"yield_prediction": {
"kpis": [
{
"id": "predicted-yield",
"title": "عملکرد پیش بینی شده",
"subtitle": "فصل جاری",
"stats": "42.8 تن",
"avatarColor": "primary",
"avatarIcon": "tabler-chart-arcs",
"chipText": "+12%",
"chipColor": "success"
}
]
}
}
```
### فیلدهای هر KPI
- `id` - اجباری و یکتا
- `title` - اجباری
- `subtitle` - اجباری
- `stats` - اجباری
- `avatarColor` - اختیاری
- `avatarIcon` - اختیاری
- `chipText` - اختیاری
- `chipColor` - اختیاری
### مقدارهای مجاز
- `avatarColor` بهتر است یکی از این‌ها باشد:
- `primary`
- `secondary`
- `success`
- `info`
- `warning`
- `chipColor` بهتر است یکی از این‌ها باشد:
- `success`
- `warning`
### نکته مهم
فرانت برای نمایش کامل این ردیف، `yield_prediction.kpis[]` می‌خواهد.
اگر بک‌اند فقط یک object تکی برگرداند، فقط یک کارت KPI نمایش داده می‌شود.
---
## 3) Harvest Prediction Card
کامپوننت: `src/views/dashboards/farm/HarvestPredictionCard.tsx`
### ساختار موردنیاز
```json
{
"harvest_prediction_card": {
"dateFormatted": "28 شهریور",
"daysUntil": 18,
"description": "..."
}
}
```
### فیلدها
- `dateFormatted` - تاریخ برداشت به صورت آماده نمایش
- `daysUntil` - عدد با type `number`
- `description` - توضیح متنی
### رفتار UI
- اگر `daysUntil > 0` باشد، چیپ تعداد روز باقی‌مانده نمایش داده می‌شود
- اگر `daysUntil` صفر یا منفی باشد، چیپ نمایش داده نمی‌شود
### نکته بک‌اند
- `daysUntil` حتماً `number` باشد، نه string
- `dateFormatted` چون مستقیم در UI چاپ می‌شود بهتر است آماده نمایش باشد
---
## 4) Harvest Readiness Zones Card
کامپوننت: `src/views/dashboards/farm/HarvestReadinessZonesCard.tsx`
### ساختار موردنیاز
```json
{
"harvest_readiness_zones": {
"averageReadiness": "84%",
"blocks": [
{
"name": "قطعه A1",
"cultivar": "گندم سیروان",
"readiness": 92,
"harvestDate": "26 شهریور",
"expectedYield": "12.4 تن",
"moisture": "11.8%"
}
]
}
}
```
### فیلدهای سطح کارت
- `averageReadiness` - مقدار آماده نمایش برای چیپ بالای کارت
- `blocks[]` - آرایه قطعات مزرعه
### فیلدهای هر block
- `name` - نام قطعه
- `cultivar` - رقم یا cultivar
- `readiness` - درصد آمادگی با type `number`
- `harvestDate` - تاریخ پیشنهادی برداشت
- `expectedYield` - عملکرد پیش‌بینی‌شده به صورت string
- `moisture` - رطوبت محصول/دانه به صورت string
### نکات مهم
- اگر `blocks.length === 0` باشد کارت اصلاً رندر نمی‌شود
- `readiness` باید بین `0..100` باشد چون در progress bar استفاده می‌شود
- رنگ چیپ تاریخ برداشت از روی `readiness` در فرانت تعیین می‌شود:
- `>= 85` -> `success`
- `>= 70` -> `warning`
- کمتر از `70` -> `info`
---
## 5) Yield Quality Bands Card
کامپوننت: `src/views/dashboards/farm/YieldQualityBandsCard.tsx`
### ساختار موردنیاز
```json
{
"yield_quality_bands": {
"bands": [
{
"label": "گرید ممتاز",
"share": 46,
"volume": "19.7 تن",
"premium": "+18% قیمت",
"color": "#2e7d32"
}
],
"stats": [
{
"label": "میانگین بریکس",
"value": "14.8"
}
]
}
}
```
### فیلدهای `bands[]`
- `label` - نام گرید کیفیت
- `share` - درصد سهم از کل برداشت با type `number`
- `volume` - حجم محصول این گرید
- `premium` - اثر قیمتی یا برچسب تجاری
- `color` - رنگ نوار، ترجیحاً hex
### فیلدهای `stats[]`
- `label`
- `value`
### نکات مهم
- اگر `bands.length === 0` باشد کارت رندر نمی‌شود
- `share` باید `number` باشد و بهتر است در بازه `0..100` باشد
- `color` باید color-valid باشد چون مستقیم برای style استفاده می‌شود
---
## 6) Harvest Operations Card
کامپوننت: `src/views/dashboards/farm/HarvestOperationsCard.tsx`
### ساختار موردنیاز
```json
{
"harvest_operations_card": {
"summary": "....",
"steps": [
{
"title": "برداشت قطعات اولویت دار",
"note": "....",
"status": "امروز",
"statusColor": "success"
}
],
"outputs": [
{
"label": "شیفت پیشنهادی",
"value": "2 شیفت"
}
]
}
}
```
### فیلدها
- `summary` - متن خلاصه بالای کارت
- `steps[]` - لیست مراحل عملیاتی
- `outputs[]` - خروجی‌های خلاصه پایین کارت
### فیلدهای هر step
- `title`
- `note`
- `status`
- `statusColor`
### مقدارهای مجاز `statusColor`
- `primary`
- `success`
- `info`
- `warning`
### فیلدهای هر output
- `label`
- `value`
### نکات مهم
- اگر `steps.length === 0` باشد کارت رندر نمی‌شود
- `summary` اختیاری است ولی بهتر است همیشه مقدار داشته باشد
---
## 7) Yield Prediction Chart
کامپوننت: `src/views/dashboards/farm/YieldPredictionChart.tsx`
### ساختار موردنیاز
```json
{
"yield_prediction_chart": {
"categories": ["فروردین", "اردیبهشت", "خرداد"],
"series": [
{
"name": "سال قبل",
"data": [9, 11, 13]
},
{
"name": "سال جاری",
"data": [10, 12, 15]
}
],
"summary": [
{
"title": "بیشترین خروجی پیش بینی شده",
"subtitle": "بهترین ماه برداشت",
"amount": "39 تن",
"avatarColor": "success",
"avatarIcon": "tabler-trending-up"
}
]
}
}
```
### فیلدهای chart
- `categories[]` - labelهای محور X
- `series[]` - سری‌های نمودار
- `summary[]` - لیست خلاصه پایین نمودار
### فیلدهای هر series
- `name`
- `data[]` - فقط `number`
### فیلدهای هر summary item
- `title`
- `subtitle`
- `amount`
- `avatarColor`
- `avatarIcon`
### نکات مهم
- اگر `series.length === 0` باشد خود chart رندر نمی‌شود
- `categories.length` باید با طول `data[]` هر series سازگار باشد
- مقادیر `data[]` حتماً عددی باشند، نه string
---
## قوانین مشترک برای بک‌اند
برای اینکه این صفحه بدون mapping پیچیده و bug کار کند، این قواعد را رعایت کنید:
- همه درصدها و مقادیری که در chart یا progress bar استفاده می‌شوند باید `number` باشند
- همه متن‌های آماده نمایش مثل `12.4 تن`، `26 شهریور`، `11.8%` می‌توانند `string` باشند
- color fieldها باید مقدار معتبر UI داشته باشند
- icon fieldها باید از icon nameهای معتبر Tabler باشند
- اگر API wrapper دارید، ساختار `code/msg/data` مشکلی ندارد
- همه timestampها در صورت نیاز آینده بهتر است ISO 8601 باشند
- داده‌ها باید بر اساس `farm_uuid` فیلتر شوند
---
## حداقل داده‌ای که برای حذف کامل mockها لازم است
برای اینکه این صفحه دیگر به هیچ mockی وابسته نباشد، بک‌اند باید این 7 بلوک را برگرداند:
- `season_highlights_card`
- `yield_prediction`
- `harvest_prediction_card`
- `harvest_readiness_zones`
- `yield_quality_bands`
- `harvest_operations_card`
- `yield_prediction_chart`
اگر فقط این سه بخش برگردند:
- `yield_prediction`
- `harvest_prediction_card`
- `yield_prediction_chart`
باز هم صفحه ناقص می‌ماند چون سه کارت تحلیلی و کارت hero هنوز از mock استفاده می‌کنند.
---
## نکات اتصال فرانت
بعد از آماده شدن بک‌اند، فرانت باید این mappingها را کامل کند:
- `season_highlights_card` -> `YieldSeasonHighlightsCard`
- `yield_prediction.kpis` -> `FarmOverviewKPIs`
- `harvest_prediction_card` -> `HarvestPredictionCard`
- `harvest_readiness_zones` -> `HarvestReadinessZonesCard`
- `yield_quality_bands` -> `YieldQualityBandsCard`
- `harvest_operations_card` -> `HarvestOperationsCard`
- `yield_prediction_chart` -> `YieldPredictionChart`
### نکته فنی مهم
در `src/libs/api/services/yieldHarvestService.ts` الان `yield_prediction` به شکل یک کارت تکی normalize می‌شود.
اگر بک‌اند `yield_prediction.kpis[]` برگرداند، بهتر است این service به‌روزرسانی شود تا کل آرایه KPIها بدون wrap اضافه عبور کند.
---
## جمع بندی نهایی
اگر بک‌اند یک summary کامل برای صفحه `yield-harvest` برگرداند، فرانت می‌تواند تمام کارت‌های صفحه را بدون mock و بدون محاسبه‌ی اضافه سمت کلاینت رندر کند.
بهترین قرارداد برای این صفحه:
- یک endpoint summary
- یک payload شامل 7 بلوک اصلی
- typeهای عددی برای chart/progress
- stringهای آماده نمایش برای متن‌ها، تاریخ‌ها و واحدها
این ساختار هم برای وضعیت فعلی فرانت کافی است، هم برای توسعه بعدی صفحه.
+940 -14
View File
@@ -2,10 +2,108 @@ import { apiClient } from '../client'
const PREFIX = '/api/yield-harvest' const PREFIX = '/api/yield-harvest'
type GenericRecord = Record<string, unknown>
type StatusColor = 'primary' | 'success' | 'info' | 'warning'
export interface YieldHarvestSummary { export interface YieldHarvestSummary {
yield_prediction?: Record<string, unknown> seasonHighlightsCard?: GenericRecord
yieldPredictionChart?: Record<string, unknown> yield_prediction?: GenericRecord
harvestPredictionCard?: Record<string, unknown> yieldPredictionChart?: GenericRecord
harvestPredictionCard?: GenericRecord
harvestReadinessZones?: GenericRecord
yieldQualityBands?: GenericRecord
harvestOperationsCard?: GenericRecord
raw?: GenericRecord
}
export interface CurrentFarmChartResponse {
farm_uuid?: string | null
plant_name?: string | null
engine?: string | null
model_name?: string | null
scenario_id?: number | null
simulation_warning?: string | null
categories?: string[]
series?: Array<{
name: string
key?: string
data: number[]
}>
summary?: Array<{
title: string
subtitle?: string
amount: number
unit?: string
avatarColor?: string
avatarIcon?: string
}>
current_state?: GenericRecord
metrics?: GenericRecord
daily_output?: Array<GenericRecord>
}
export interface GrowthStartResponse {
task_id: string
status_url?: string
plant_name?: string | null
}
export interface GrowthStatusResponse {
task_id: string
status: 'PENDING' | 'PROGRESS' | 'SUCCESS' | 'FAILURE'
message?: string
progress?: {
current?: number
total?: number
percent?: number
}
result?: GenericRecord
error?: string
}
export interface HarvestPredictionResponse {
date?: string
dateFormatted?: string
daysUntil?: number
description?: string
optimalWindowStart?: string
optimalWindowEnd?: string
gddDetails?: GenericRecord
}
export interface YieldPredictionResponse {
farm_uuid?: string
plant_name?: string | null
predictedYieldTons?: number
predictedYieldRaw?: number
predicted_yield_tons?: number
predicted_yield_raw?: number
unit?: string
sourceUnit?: string
source_unit?: string
simulationEngine?: string | null
simulationModel?: string | null
scenarioId?: number | null
simulation_engine?: string | null
simulation_model?: string | null
scenario_id?: number | null
simulationWarning?: string | null
simulation_warning?: string | null
supportingMetrics?: GenericRecord
supporting_metrics?: GenericRecord
}
export interface YieldHarvestDashboardData {
summary: YieldHarvestSummary
currentFarmChart?: CurrentFarmChartResponse
harvestPrediction?: HarvestPredictionResponse
yieldPrediction?: YieldPredictionResponse
}
interface SummaryOptions {
seasonYear?: number
cropName?: string
includeNarrative?: boolean
} }
interface ApiResponse<T> { interface ApiResponse<T> {
@@ -18,29 +116,857 @@ interface ApiResponse<T> {
function extract<T>(res: ApiResponse<T> | T): T { function extract<T>(res: ApiResponse<T> | T): T {
if (res && typeof res === 'object') { if (res && typeof res === 'object') {
if ('data' in res) return (res as ApiResponse<T>).data if ('data' in res) return (res as ApiResponse<T>).data
if ('result' in res) return (res as ApiResponse<T>).result as T if ('result' in res) return (res as unknown as ApiResponse<T>).result as T
} }
return res as T return res as T
} }
function toKpiCard(card?: Record<string, unknown>): Record<string, unknown> { function asRecord(value: unknown): GenericRecord | undefined {
if (!card || typeof card !== 'object') return {} return value && typeof value === 'object' && !Array.isArray(value)
? (value as GenericRecord)
: undefined
}
return { kpis: [card] } function asArray<T = unknown>(value: unknown): T[] {
return Array.isArray(value) ? (value as T[]) : []
}
function toNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function buildFarmPayload(farmUuid: string) {
return { farm_uuid: farmUuid }
}
function buildSummaryQuery(farmUuid: string, options?: SummaryOptions) {
const params = new URLSearchParams({ farm_uuid: farmUuid })
if (options?.seasonYear !== undefined) {
params.set('season_year', String(options.seasonYear))
}
if (options?.cropName) {
params.set('crop_name', options.cropName)
}
if (options?.includeNarrative !== undefined) {
params.set('include_narrative', String(options.includeNarrative))
}
return params.toString()
}
function formatNumber(value: number, maximumFractionDigits = 1) {
return new Intl.NumberFormat('fa-IR', { maximumFractionDigits }).format(value)
}
function formatPercent(value: unknown, maximumFractionDigits = 0) {
const amount = toNumber(value)
if (amount === null) return '-'
return `${formatNumber(amount, maximumFractionDigits)}%`
}
function formatCurrency(value: unknown) {
const amount = toNumber(value)
if (amount === null) return '-'
return `${formatNumber(amount, 0)} ریال`
}
function formatDaysUntil(value: unknown) {
const amount = toNumber(value)
if (amount === null) return 'نامشخص'
if (amount <= 0) return 'آماده برداشت'
return `${formatNumber(amount, 0)} روز مانده`
}
function getPredictedYieldTons(value: unknown): number | null {
const record = asRecord(value)
if (!record) return toNumber(value)
const direct =
toNumber(record.predictedYieldTons) ??
toNumber(record.predicted_yield_tons) ??
toNumber(record.total_predicted_yield)
if (direct !== null) return direct
const rawValue =
toNumber(record.predictedYieldRaw) ?? toNumber(record.predicted_yield_raw)
const sourceUnit = String(record.sourceUnit ?? record.source_unit ?? '')
const displayUnit = String(record.unit ?? record.yield_unit ?? '')
if (
rawValue !== null &&
sourceUnit.toLowerCase().includes('kg') &&
(displayUnit.includes('تن') || displayUnit.toLowerCase().includes('ton'))
) {
return rawValue / 1000
}
return null
}
function getStatusColor(value: unknown): StatusColor {
const normalized = String(value ?? '').toLowerCase()
if (
normalized.includes('success') ||
normalized.includes('complete') ||
normalized.includes('today') ||
normalized.includes('ready') ||
normalized.includes('امروز') ||
normalized.includes('آماده')
) {
return 'success'
}
if (
normalized.includes('warning') ||
normalized.includes('urgent') ||
normalized.includes('ضروری') ||
normalized.includes('هشدار')
) {
return 'warning'
}
if (
normalized.includes('info') ||
normalized.includes('planned') ||
normalized.includes('برنامه')
) {
return 'info'
}
return 'primary'
}
function toTimestampLabel(timestamp: unknown) {
const numericTimestamp = toNumber(timestamp)
if (numericTimestamp === null) return String(timestamp ?? '')
return new Date(numericTimestamp).toLocaleDateString('fa-IR', {
month: 'short',
day: 'numeric',
})
}
function normalizeSeriesChart(chartData?: GenericRecord) {
if (!chartData) return {}
const directCategories = asArray<string>(chartData.categories)
const directSeries = asArray<GenericRecord>(chartData.series)
if (
directCategories.length > 0 &&
directSeries.every((item) => Array.isArray(item.data))
) {
return {
categories: directCategories,
series: directSeries.map((item) => ({
name: String(item.name ?? 'Series'),
data: asArray<number>(item.data).map((point) => toNumber(point) ?? 0),
})),
}
}
if (directSeries.length === 0) return {}
let derivedCategories: string[] = []
const normalizedSeries = directSeries.map((item) => {
const points = asArray(item.data)
if (points.length > 0 && Array.isArray(points[0])) {
const tuplePoints = points as Array<[unknown, unknown]>
if (derivedCategories.length === 0) {
derivedCategories = tuplePoints.map(([timestamp]) =>
toTimestampLabel(timestamp),
)
}
return {
name: String(item.name ?? 'Series'),
data: tuplePoints.map(([, value]) => toNumber(value) ?? 0),
}
}
return {
name: String(item.name ?? 'Series'),
data: points.map((point) => toNumber(point) ?? 0),
}
})
return {
categories: derivedCategories,
series: normalizedSeries,
}
}
function normalizeSeasonHighlightsCard(
raw: GenericRecord | undefined,
summaryData: GenericRecord,
) {
if (!raw) return undefined
if (Array.isArray(raw.metrics) && asRecord(raw.spotlight)) {
return raw
}
const title = String(raw.title ?? 'اتاق فرمان برداشت این فصل')
const subtitle = String(
raw.subtitle ??
'خلاصه سریع وضعیت عملکرد، کیفیت و بهترین بازه برداشت برای مزرعه انتخاب شده.',
)
const seasonPredictedYield = getPredictedYieldTons(raw)
const summaryPredictedYield = getPredictedYieldTons(
asRecord(summaryData.yield_prediction),
)
const totalPredictedYield =
summaryPredictedYield !== null &&
(seasonPredictedYield === null || seasonPredictedYield === 0)
? summaryPredictedYield
: seasonPredictedYield
const yieldUnit = String(raw.yield_unit ?? 'تن')
const daysUntilHarvest =
toNumber(raw.days_until_harvest) ??
toNumber(asRecord(summaryData.harvest_prediction_card)?.days_until)
const targetHarvestDate = String(
raw.target_harvest_date ??
asRecord(summaryData.harvest_prediction_card)?.harvest_date_formatted ??
'نامشخص',
)
const averageReadiness =
toNumber(raw.average_readiness) ??
toNumber(asRecord(summaryData.harvest_readiness_zones)?.averageReadiness)
const primaryQualityGrade = String(raw.primary_quality_grade ?? '')
const estimatedRevenue = raw.estimated_revenue
const soilType = String(raw.soil_type ?? '')
const badges = [primaryQualityGrade, soilType, averageReadiness !== null ? `آمادگی ${formatPercent(averageReadiness)}` : '']
.filter(Boolean)
return {
title,
subtitle,
seasonLabel: raw.seasonLabel ?? (raw.season_year ? `فصل ${raw.season_year}` : ''),
badges,
spotlight: {
title: 'تاریخ هدف برداشت',
value: targetHarvestDate,
caption:
daysUntilHarvest === null
? 'زمان برداشت از داده های فعلی مزرعه برآورد می شود.'
: `${formatNumber(daysUntilHarvest, 0)} روز تا برداشت باقی مانده است.`,
},
metrics: [
totalPredictedYield !== null
? {
label: 'عملکرد پیش بینی شده',
value: `${formatNumber(totalPredictedYield)} ${yieldUnit}`,
caption: 'بر اساس خلاصه پیش بینی عملکرد این مزرعه.',
avatarIcon: 'tabler-chart-arcs',
avatarColor: 'primary',
}
: null,
averageReadiness !== null
? {
label: 'میانگین آمادگی برداشت',
value: formatPercent(averageReadiness),
caption: 'برآورد میانگین readiness در سطح مزرعه.',
avatarIcon: 'tabler-plant-2',
avatarColor: 'success',
}
: null,
estimatedRevenue !== undefined
? {
label: 'درآمد تخمینی',
value: formatCurrency(estimatedRevenue),
caption: 'برآورد درآمد بر اساس داده های فعلی summary.',
avatarIcon: 'tabler-cash-banknote',
avatarColor: 'warning',
}
: null,
].filter(Boolean),
}
}
function normalizeYieldPrediction(
raw: GenericRecord | undefined,
summaryData: GenericRecord,
) {
if (!raw) return undefined
if (Array.isArray(raw.kpis)) return raw
const kpis: GenericRecord[] = []
const predictedYield = getPredictedYieldTons(raw)
const yieldUnit = String(raw.unit ?? 'تن')
const averageReadiness =
toNumber(asRecord(summaryData.harvest_readiness_zones)?.averageReadiness) ??
toNumber(asRecord(summaryData.season_highlights_card)?.average_readiness)
const qualityScore = toNumber(
asRecord(summaryData.yield_quality_bands)?.quality_score,
)
const daysUntilHarvest =
toNumber(asRecord(summaryData.harvest_prediction_card)?.days_until) ??
toNumber(asRecord(summaryData.season_highlights_card)?.days_until_harvest)
const estimatedRevenue = toNumber(
asRecord(summaryData.season_highlights_card)?.estimated_revenue,
)
if (predictedYield !== null) {
kpis.push({
id: 'predicted-yield',
title: 'عملکرد پیش بینی شده',
subtitle: 'پیش بینی عملکرد این مزرعه',
stats: `${formatNumber(predictedYield)} ${yieldUnit}`,
avatarColor: 'primary',
avatarIcon: 'tabler-chart-arcs',
chipText: raw.simulation_warning ? 'نیازمند بررسی' : 'به روز',
chipColor: raw.simulation_warning ? 'warning' : 'success',
})
}
if (averageReadiness !== null) {
kpis.push({
id: 'harvest-readiness',
title: 'آمادگی برداشت',
subtitle: 'میانگین کل مزرعه',
stats: formatPercent(averageReadiness),
avatarColor: 'success',
avatarIcon: 'tabler-plant-2',
chipText: averageReadiness >= 80 ? 'روی برنامه' : 'در حال پایش',
chipColor: averageReadiness >= 80 ? 'success' : 'warning',
})
}
if (qualityScore !== null) {
kpis.push({
id: 'quality-score',
title: 'امتیاز کیفیت',
subtitle: 'برآورد کیفیت محصول',
stats: formatNumber(qualityScore),
avatarColor: 'info',
avatarIcon: 'tabler-stars',
chipText: qualityScore >= 80 ? 'مطلوب' : 'متوسط',
chipColor: qualityScore >= 80 ? 'success' : 'warning',
})
}
if (daysUntilHarvest !== null) {
kpis.push({
id: 'days-until-harvest',
title: 'روز تا برداشت',
subtitle: 'زمان باقیمانده تا پنجره برداشت',
stats: formatNumber(daysUntilHarvest, 0),
avatarColor: 'warning',
avatarIcon: 'tabler-calendar-event',
chipText: daysUntilHarvest <= 7 ? 'نزدیک' : 'برنامه ریزی',
chipColor: daysUntilHarvest <= 7 ? 'warning' : 'success',
})
} else if (estimatedRevenue !== null) {
kpis.push({
id: 'estimated-revenue',
title: 'درآمد تخمینی',
subtitle: 'بر پایه summary این مزرعه',
stats: formatCurrency(estimatedRevenue),
avatarColor: 'warning',
avatarIcon: 'tabler-cash-banknote',
chipText: 'تخمینی',
chipColor: 'warning',
})
}
return { ...raw, kpis }
}
function normalizeHarvestPredictionCard(raw?: GenericRecord) {
if (!raw) return undefined
return {
...raw,
dateFormatted:
raw.dateFormatted ?? raw.harvest_date_formatted ?? raw.dateFormatted ?? raw.date,
daysUntil: toNumber(raw.daysUntil ?? raw.days_until) ?? 0,
description: raw.description ?? raw.explanation ?? '',
}
}
function normalizeHarvestReadinessZones(raw?: GenericRecord) {
if (!raw) return undefined
if (Array.isArray(raw.blocks)) return raw
const blocks = asArray<GenericRecord>(raw.zones).map((zone, index) => ({
name: String(zone.zoneLabel ?? zone.zoneId ?? `Zone ${index + 1}`),
cultivar: String(zone.status ?? raw.vegetationHealthClass ?? 'وضعیت مزرعه'),
readiness: toNumber(zone.readiness) ?? 0,
harvestDate: formatDaysUntil(zone.daysUntil),
expectedYield:
toNumber(zone.meanNdvi) !== null
? `NDVI ${formatNumber(toNumber(zone.meanNdvi) ?? 0, 2)}`
: String(raw.summary ?? '-'),
moisture:
toNumber(raw.ndviTrend) !== null
? `${toNumber(raw.ndviTrend)! >= 0 ? '+' : ''}${formatNumber(toNumber(raw.ndviTrend) ?? 0, 1)}%`
: String(raw.source ?? '-'),
}))
return {
averageReadiness:
typeof raw.averageReadiness === 'string'
? raw.averageReadiness
: formatPercent(raw.averageReadiness),
blocks,
}
}
function normalizeYieldQualityBands(raw?: GenericRecord) {
if (!raw) return undefined
if (Array.isArray(raw.bands)) return raw
const palette = ['#2e7d32', '#0288d1', '#ed6c02', '#8e24aa']
const bands = asArray<GenericRecord>(raw.grade_distribution).map((band, index) => ({
label: String(band.label ?? band.grade ?? band.name ?? `گرید ${index + 1}`),
share: toNumber(band.share ?? band.percentage ?? band.percent) ?? 0,
volume:
band.volume !== undefined
? String(band.volume)
: band.amount !== undefined
? String(band.amount)
: '-',
premium: String(band.premium ?? band.note ?? raw.primary_quality_grade ?? 'برآورد کیفیت'),
color: String(band.color ?? palette[index % palette.length]),
}))
const proteinContent = asRecord(raw.protein_content)
const moisturePercentage = asRecord(raw.moisture_percentage)
const stats = [
proteinContent
? {
label: 'درصد پروتئین',
value: String(
proteinContent.value ?? proteinContent.current ?? proteinContent.percent ?? '-',
),
}
: null,
moisturePercentage
? {
label: 'درصد رطوبت',
value: String(
moisturePercentage.value ??
moisturePercentage.current ??
moisturePercentage.percent ??
'-',
),
}
: null,
raw.quality_score !== undefined
? {
label: 'امتیاز کیفیت',
value: formatNumber(toNumber(raw.quality_score) ?? 0),
}
: null,
raw.primary_quality_grade
? {
label: 'گرید غالب',
value: String(raw.primary_quality_grade),
}
: null,
].filter(Boolean)
return {
bands,
stats,
}
}
function normalizeHarvestOperationsCard(raw?: GenericRecord) {
if (!raw) return undefined
const steps = asArray<GenericRecord>(raw.steps).map((step) => ({
title: String(step.title ?? step.key ?? 'گام عملیاتی'),
note: String(step.note ?? ''),
status: String(step.status ?? step.phase_name ?? step.stage_label ?? 'برنامه ریزی'),
statusColor: getStatusColor(step.status),
}))
const outputs = [
raw.phase_name
? { label: 'فاز رشد', value: String(raw.phase_name) }
: null,
raw.days_until_harvest !== undefined
? {
label: 'روز تا برداشت',
value: formatNumber(toNumber(raw.days_until_harvest) ?? 0, 0),
}
: null,
raw.current_dvs !== undefined
? {
label: 'DVS فعلی',
value: formatNumber(toNumber(raw.current_dvs) ?? 0, 2),
}
: null,
raw.rules_source
? { label: 'منبع قواعد', value: String(raw.rules_source) }
: null,
].filter(Boolean)
return {
summary: String(raw.summary ?? ''),
steps,
outputs,
}
}
function normalizeYieldPredictionChart(
raw: GenericRecord | undefined,
summaryData: GenericRecord,
) {
if (!raw) return undefined
const chart = normalizeSeriesChart(raw)
const chartMeta = asRecord(raw.meta)
const predictedYield =
toNumber(asRecord(summaryData.yield_prediction)?.predicted_yield_tons) ??
toNumber(asRecord(summaryData.yield_prediction)?.predictedYieldTons)
const unit = String(
asRecord(summaryData.yield_prediction)?.unit ?? chartMeta?.unit ?? 'تن',
)
const engine = String(chartMeta?.simulation_engine ?? '')
return {
...chart,
summary: Array.isArray(raw.summary)
? raw.summary
: [
predictedYield !== null
? {
title: 'عملکرد پیش بینی شده',
subtitle: 'جمع بندی مدل عملکرد',
amount: `${formatNumber(predictedYield)} ${unit}`,
avatarColor: 'success',
avatarIcon: 'tabler-trending-up',
}
: null,
engine
? {
title: 'موتور شبیه سازی',
subtitle: 'منبع تحلیل نمودار',
amount: engine,
avatarColor: 'primary',
avatarIcon: 'tabler-chart-line',
}
: null,
].filter(Boolean),
}
}
function normalizeSummaryData(data: GenericRecord): YieldHarvestSummary {
const seasonHighlightsCard = normalizeSeasonHighlightsCard(
asRecord(data.season_highlights_card ?? data.seasonHighlightsCard),
data,
)
const yieldPrediction = normalizeYieldPrediction(
asRecord(data.yield_prediction ?? data.yieldPrediction),
data,
)
const harvestPredictionCard = normalizeHarvestPredictionCard(
asRecord(data.harvest_prediction_card ?? data.harvestPredictionCard),
)
const harvestReadinessZones = normalizeHarvestReadinessZones(
asRecord(data.harvest_readiness_zones ?? data.harvestReadinessZones),
)
const yieldQualityBands = normalizeYieldQualityBands(
asRecord(data.yield_quality_bands ?? data.yieldQualityBands),
)
const harvestOperationsCard = normalizeHarvestOperationsCard(
asRecord(data.harvest_operations_card ?? data.harvestOperationsCard),
)
const yieldPredictionChart = normalizeYieldPredictionChart(
asRecord(data.yield_prediction_chart ?? data.yieldPredictionChart),
data,
)
return {
seasonHighlightsCard,
yield_prediction: yieldPrediction,
harvestPredictionCard,
harvestReadinessZones,
yieldQualityBands,
harvestOperationsCard,
yieldPredictionChart,
raw: data,
}
}
function enrichSummaryData(
summary: YieldHarvestSummary,
extras: {
currentFarmChart?: CurrentFarmChartResponse
harvestPrediction?: HarvestPredictionResponse
yieldPrediction?: YieldPredictionResponse
},
): YieldHarvestSummary {
const raw = { ...(summary.raw ?? {}) }
const baseSeason = asRecord(
raw.season_highlights_card ?? raw.seasonHighlightsCard,
)
const baseYield = asRecord(raw.yield_prediction ?? raw.yieldPrediction)
const baseHarvest = asRecord(
raw.harvest_prediction_card ?? raw.harvestPredictionCard,
)
const baseChart = asRecord(
raw.yield_prediction_chart ?? raw.yieldPredictionChart,
)
if (extras.yieldPrediction) {
const predictedYieldTons = getPredictedYieldTons(extras.yieldPrediction)
const predictedYieldRaw =
toNumber(extras.yieldPrediction.predictedYieldRaw) ??
toNumber(extras.yieldPrediction.predicted_yield_raw)
const sourceUnit = String(
extras.yieldPrediction.sourceUnit ??
extras.yieldPrediction.source_unit ??
baseYield?.source_unit ??
baseYield?.sourceUnit ??
'',
)
const simulationEngine = String(
extras.yieldPrediction.simulationEngine ??
extras.yieldPrediction.simulation_engine ??
baseYield?.simulation_engine ??
baseYield?.simulationEngine ??
'',
)
const simulationModel = String(
extras.yieldPrediction.simulationModel ??
extras.yieldPrediction.simulation_model ??
baseYield?.simulation_model ??
baseYield?.simulationModel ??
'',
)
const scenarioId =
extras.yieldPrediction.scenarioId ??
extras.yieldPrediction.scenario_id ??
baseYield?.scenario_id ??
baseYield?.scenarioId
const simulationWarning =
extras.yieldPrediction.simulationWarning ??
extras.yieldPrediction.simulation_warning ??
baseYield?.simulation_warning ??
baseYield?.simulationWarning
const supportingMetrics =
extras.yieldPrediction.supportingMetrics ??
extras.yieldPrediction.supporting_metrics ??
baseYield?.supporting_metrics ??
baseYield?.supportingMetrics
raw.yield_prediction = {
...baseYield,
...extras.yieldPrediction,
predicted_yield_tons:
predictedYieldTons ??
baseYield?.predicted_yield_tons ??
baseYield?.predictedYieldTons,
predicted_yield_raw:
predictedYieldRaw ??
baseYield?.predicted_yield_raw ??
baseYield?.predictedYieldRaw,
unit: extras.yieldPrediction.unit ?? baseYield?.unit,
source_unit: sourceUnit,
simulation_engine: simulationEngine,
simulation_model: simulationModel,
scenario_id: scenarioId,
simulation_warning: simulationWarning,
supporting_metrics: supportingMetrics,
}
}
if (extras.harvestPrediction) {
raw.harvest_prediction_card = {
...baseHarvest,
...extras.harvestPrediction,
harvest_date:
extras.harvestPrediction.date ??
baseHarvest?.harvest_date ??
baseHarvest?.date,
harvest_date_formatted:
extras.harvestPrediction.dateFormatted ??
baseHarvest?.harvest_date_formatted ??
baseHarvest?.dateFormatted,
days_until:
extras.harvestPrediction.daysUntil ??
baseHarvest?.days_until ??
baseHarvest?.daysUntil,
optimal_window_start:
extras.harvestPrediction.optimalWindowStart ??
baseHarvest?.optimal_window_start ??
baseHarvest?.optimalWindowStart,
optimal_window_end:
extras.harvestPrediction.optimalWindowEnd ??
baseHarvest?.optimal_window_end ??
baseHarvest?.optimalWindowEnd,
description:
extras.harvestPrediction.description ?? baseHarvest?.description,
gddDetails:
extras.harvestPrediction.gddDetails ?? baseHarvest?.gddDetails,
}
}
if (extras.currentFarmChart && !baseChart?.series) {
raw.yield_prediction_chart = {
categories: extras.currentFarmChart.categories ?? [],
series: extras.currentFarmChart.series ?? [],
summary: extras.currentFarmChart.summary ?? [],
meta: {
unit: 'leaf',
simulation_engine: extras.currentFarmChart.engine,
simulation_model: extras.currentFarmChart.model_name,
scenario_id: extras.currentFarmChart.scenario_id,
simulation_warning: extras.currentFarmChart.simulation_warning,
},
}
}
raw.season_highlights_card = {
...baseSeason,
total_predicted_yield:
getPredictedYieldTons(extras.yieldPrediction) ??
baseSeason?.total_predicted_yield,
yield_unit: extras.yieldPrediction?.unit ?? baseSeason?.yield_unit,
target_harvest_date:
extras.harvestPrediction?.dateFormatted ??
baseSeason?.target_harvest_date,
days_until_harvest:
extras.harvestPrediction?.daysUntil ?? baseSeason?.days_until_harvest,
subtitle:
baseSeason?.subtitle ??
extras.harvestPrediction?.description ??
'خلاصه سریع وضعیت عملکرد، کیفیت و بهترین بازه برداشت برای مزرعه انتخاب شده.',
}
return normalizeSummaryData(raw)
} }
export const yieldHarvestService = { export const yieldHarvestService = {
async getSummary(farmUuid: string): Promise<YieldHarvestSummary> { async getCurrentFarmChart(farmUuid: string): Promise<CurrentFarmChartResponse> {
const res = await apiClient.get<ApiResponse<Record<string, unknown>> | Record<string, unknown>>( const response = await apiClient.post<
`${PREFIX}/summary/?farm_uuid=${encodeURIComponent(farmUuid)}` ApiResponse<CurrentFarmChartResponse> | CurrentFarmChartResponse
>(`${PREFIX}/current-farm-chart/`, buildFarmPayload(farmUuid))
return extract(response)
},
async startGrowth(farmUuid: string): Promise<GrowthStartResponse> {
const response = await apiClient.post<
ApiResponse<GrowthStartResponse> | GrowthStartResponse
>(`${PREFIX}/growth/`, buildFarmPayload(farmUuid))
return extract(response)
},
async getGrowthStatus(
taskId: string,
options?: { page?: number; pageSize?: number },
): Promise<GrowthStatusResponse> {
const params = new URLSearchParams()
if (options?.page !== undefined) {
params.set('page', String(options.page))
}
if (options?.pageSize !== undefined) {
params.set('page_size', String(options.pageSize))
}
const query = params.toString()
const endpoint = `${PREFIX}/growth/${encodeURIComponent(taskId)}/status/${query ? `?${query}` : ''}`
const response = await apiClient.get<
ApiResponse<GrowthStatusResponse> | GrowthStatusResponse
>(endpoint)
return extract(response)
},
async getHarvestPrediction(
farmUuid: string,
): Promise<HarvestPredictionResponse> {
const response = await apiClient.post<
ApiResponse<HarvestPredictionResponse> | HarvestPredictionResponse
>(`${PREFIX}/harvest-prediction/`, buildFarmPayload(farmUuid))
return extract(response)
},
async getYieldPrediction(farmUuid: string): Promise<YieldPredictionResponse> {
const response = await apiClient.post<
ApiResponse<YieldPredictionResponse> | YieldPredictionResponse
>(`${PREFIX}/yield-prediction/`, buildFarmPayload(farmUuid))
return extract(response)
},
async getSummary(
farmUuid: string,
options?: SummaryOptions,
): Promise<YieldHarvestSummary> {
const response = await apiClient.get<ApiResponse<GenericRecord> | GenericRecord>(
`${PREFIX}/yield-harvest-summary/?${buildSummaryQuery(farmUuid, options)}`,
) )
const data = extract(res) const data = extract(response)
return normalizeSummaryData(data)
},
async getDashboardData(
farmUuid: string,
options?: SummaryOptions,
): Promise<YieldHarvestDashboardData> {
const [summaryResult, harvestResult, yieldResult, currentChartResult] =
await Promise.allSettled([
this.getSummary(farmUuid, options),
this.getHarvestPrediction(farmUuid),
this.getYieldPrediction(farmUuid),
this.getCurrentFarmChart(farmUuid),
])
const summary =
summaryResult.status === 'fulfilled'
? summaryResult.value
: normalizeSummaryData({})
const harvestPrediction =
harvestResult.status === 'fulfilled' ? harvestResult.value : undefined
const yieldPrediction =
yieldResult.status === 'fulfilled' ? yieldResult.value : undefined
const currentFarmChart =
currentChartResult.status === 'fulfilled'
? currentChartResult.value
: undefined
return { return {
yield_prediction: toKpiCard(data?.yield_prediction ?? data?.yield_prediction_card), summary: enrichSummaryData(summary, {
yieldPredictionChart: (data?.yieldPredictionChart ?? data?.yield_prediction_chart ?? {}) as Record<string, unknown>, harvestPrediction,
harvestPredictionCard: (data?.harvestPredictionCard ?? data?.harvest_prediction_card ?? {}) as Record<string, unknown> yieldPrediction,
currentFarmChart,
}),
harvestPrediction,
yieldPrediction,
currentFarmChart,
} }
}, },
} }
@@ -1,60 +1,94 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react";
import { useFarmHub } from "@/hooks/useFarmHub";
import { yieldHarvestService } from "@/libs/api/services/yieldHarvestService";
import type {
CurrentFarmChartResponse,
YieldHarvestSummary,
} from "@/libs/api/services/yieldHarvestService";
import Grid from "@mui/material/Grid2"; import Grid from "@mui/material/Grid2";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import YieldHarvestPageWrapper from "@views/dashboards/farm/YieldHarvestPageWrapper"; import YieldHarvestPageWrapper from "@views/dashboards/farm/YieldHarvestPageWrapper";
import YieldSeasonHighlightsCard from "@views/dashboards/farm/YieldSeasonHighlightsCard"; import YieldSeasonHighlightsCard from "@views/dashboards/farm/YieldSeasonHighlightsCard";
import PlantSimulator from "@views/dashboards/farm/plantSimulator/PlantSimulator"; import PlantSimulator from "@views/dashboards/farm/plantSimulator/PlantSimulator";
import { mockYieldHarvestSummary } from "@views/dashboards/farm/yieldHarvestMockData";
const mockSeasonHighlightsData = {
title: "اتاق فرمان برداشت این فصل",
subtitle:
"این بخش برای نمایش سریع وضعیت عملکرد، کیفیت و بهترین پنجره فروش طراحی شده است. داده ها فعلا ماک هستند تا ظاهر نهایی کارت ها و ریتم بصری صفحه بهتر دیده شود.",
seasonLabel: "فصل ۱۴۰۴",
badges: ["کیفیت ممتاز", "آماده بسته بندی", "ریسک پایین"],
spotlight: {
title: "پنجره طلایی فروش",
value: "۳ روز اول بعد از برداشت",
caption: "در این بازه، برآورد قیمت فروش حدود ۸٪ بهتر از میانگین هفتگی است.",
},
metrics: [
{
label: "سطح قابل برداشت",
value: "18.6 هکتار",
caption: "۴ قطعه در اولویت نخست قرار دارند.",
avatarIcon: "tabler-map-2",
avatarColor: "success",
},
{
label: "گرید ممتاز",
value: "46%",
caption: "بالاترین سهم کیفیت مربوط به قطعه A2 است.",
avatarIcon: "tabler-rosette-discount-check",
avatarColor: "warning",
},
{
label: "درآمد هدف",
value: "1.84 میلیارد",
caption: "با فرض فروش در بازه پیشنهادی مدل.",
avatarIcon: "tabler-cash-banknote",
avatarColor: "primary",
},
],
};
const PlantProductionPage = () => { const PlantProductionPage = () => {
const { farmHub } = useFarmHub();
const farmUuid = farmHub?.farm_uuid;
const [summary, setSummary] =
useState<YieldHarvestSummary>(mockYieldHarvestSummary);
const [currentFarmChart, setCurrentFarmChart] =
useState<CurrentFarmChartResponse | null>(null);
const [loading, setLoading] = useState(true);
const loadDashboard = useCallback(() => {
if (!farmUuid) {
setSummary(mockYieldHarvestSummary);
setCurrentFarmChart(null);
setLoading(false);
return Promise.resolve();
}
setLoading(true);
return yieldHarvestService
.getDashboardData(farmUuid)
.then((data) => {
setSummary(data.summary);
setCurrentFarmChart(data.currentFarmChart ?? null);
})
.catch(() => {
setSummary(mockYieldHarvestSummary);
setCurrentFarmChart(null);
})
.finally(() => setLoading(false));
}, [farmUuid]);
useEffect(() => {
loadDashboard();
}, [loadDashboard]);
if (loading) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight={280}
>
<CircularProgress />
</Box>
);
}
return ( return (
<Grid container spacing={6} alignItems="stretch"> <Grid container spacing={6} alignItems="stretch">
<Grid size={{ xs: 12 }} sx={{ display: "flex", width: "100%" }}> <Grid size={{ xs: 12 }} sx={{ display: "flex", width: "100%" }}>
<YieldSeasonHighlightsCard data={mockSeasonHighlightsData} /> <YieldSeasonHighlightsCard
data={
(summary.seasonHighlightsCard as Record<string, unknown>) ??
(mockYieldHarvestSummary.seasonHighlightsCard as Record<
string,
unknown
>)
}
/>
</Grid> </Grid>
<Grid size={{ xs: 12 }} sx={{ display: "flex", width: "100%" }}> <Grid size={{ xs: 12 }} sx={{ display: "flex", width: "100%" }}>
<PlantSimulator /> <PlantSimulator
farmUuid={farmUuid}
currentFarmChart={currentFarmChart ?? undefined}
onRefreshDashboard={loadDashboard}
/>
</Grid> </Grid>
<Grid size={{ xs: 12 }} sx={{ display: "flex", width: "100%" }}> <Grid size={{ xs: 12 }} sx={{ display: "flex", width: "100%" }}>
<YieldHarvestPageWrapper /> <YieldHarvestPageWrapper data={summary} />
</Grid> </Grid>
</Grid> </Grid>
); );
@@ -1,11 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { useFarmHub } from "@/hooks/useFarmHub";
import Grid from "@mui/material/Grid2"; import Grid from "@mui/material/Grid2";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import HarvestPredictionCard from "@views/dashboards/farm/HarvestPredictionCard"; import HarvestPredictionCard from "@views/dashboards/farm/HarvestPredictionCard";
import YieldPredictionChart from "@views/dashboards/farm/YieldPredictionChart"; import YieldPredictionChart from "@views/dashboards/farm/YieldPredictionChart";
@@ -14,8 +10,8 @@ import HarvestReadinessZonesCard from "@views/dashboards/farm/HarvestReadinessZo
import YieldQualityBandsCard from "@views/dashboards/farm/YieldQualityBandsCard"; import YieldQualityBandsCard from "@views/dashboards/farm/YieldQualityBandsCard";
import HarvestOperationsCard from "@views/dashboards/farm/HarvestOperationsCard"; import HarvestOperationsCard from "@views/dashboards/farm/HarvestOperationsCard";
import { yieldHarvestService } from "@/libs/api/services/yieldHarvestService";
import type { YieldHarvestSummary } from "@/libs/api/services/yieldHarvestService"; import type { YieldHarvestSummary } from "@/libs/api/services/yieldHarvestService";
import { mockYieldHarvestSummary } from "@views/dashboards/farm/yieldHarvestMockData";
const cardSlotSx = { const cardSlotSx = {
display: "flex", display: "flex",
@@ -40,246 +36,32 @@ const chartCardSx = {
minHeight: { xs: 360, lg: 440 }, minHeight: { xs: 360, lg: 440 },
}; };
const mockYieldHarvestData: YieldHarvestSummary = { const hasYieldKpis = (summary?: YieldHarvestSummary) => {
yield_prediction: { const kpis = (summary?.yield_prediction as Record<string, unknown> | undefined)
kpis: [ ?.kpis;
{
id: "predicted-yield", return Array.isArray(kpis) && kpis.length > 0;
title: "عملکرد پیش بینی شده",
subtitle: "فصل جاری",
stats: "42.8 تن",
avatarColor: "primary",
avatarIcon: "tabler-chart-arcs",
chipText: "+12%",
chipColor: "success",
},
{
id: "harvest-readiness",
title: "آمادگی برداشت",
subtitle: "میانگین مزرعه",
stats: "84%",
avatarColor: "success",
avatarIcon: "tabler-plant-2",
chipText: "روی برنامه",
chipColor: "success",
},
{
id: "quality-score",
title: "امتیاز کیفیت",
subtitle: "برآورد هوش مصنوعی",
stats: "91/100",
avatarColor: "info",
avatarIcon: "tabler-stars",
chipText: "+4 واحد",
chipColor: "success",
},
{
id: "loss-risk",
title: "ریسک افت محصول",
subtitle: "آب وهوا و آفات",
stats: "6.5%",
avatarColor: "warning",
avatarIcon: "tabler-alert-triangle",
chipText: "پایین",
chipColor: "success",
},
],
},
harvestPredictionCard: {
dateFormatted: "۲۸ شهریور",
daysUntil: 18,
description:
"با توجه به روند رشد بوته، الگوی آبیاری و وضعیت دمایی اخیر، این مزرعه در هفته آخر شهریور به نقطه ایده آل برداشت می رسد.",
},
yieldPredictionChart: {
categories: [
"فروردین",
"اردیبهشت",
"خرداد",
"تیر",
"مرداد",
"شهریور",
"مهر",
"آبان",
],
series: [
{
name: "سال قبل",
data: [9, 11, 13, 16, 19, 24, 28, 31],
},
{
name: "سال جاری",
data: [10, 12, 15, 18, 23, 29, 34, 39],
},
],
summary: [
{
title: "بیشترین خروجی پیش بینی شده",
subtitle: "بهترین ماه برداشت",
amount: "39 تن",
avatarColor: "success",
avatarIcon: "tabler-trending-up",
},
{
title: "رشد این فصل",
subtitle: "نسبت به سال قبل",
amount: "+11.2 تن",
avatarColor: "primary",
avatarIcon: "tabler-chart-line",
},
],
},
}; };
const mockHarvestReadinessData = { const hasYieldChart = (summary?: YieldHarvestSummary) => {
averageReadiness: "84%", const series = (
blocks: [ summary?.yieldPredictionChart as Record<string, unknown> | undefined
{ )?.series;
name: "قطعه A1",
cultivar: "گندم سیروان", return Array.isArray(series) && series.length > 0;
readiness: 92,
harvestDate: "۲۶ شهریور",
expectedYield: "12.4 تن",
moisture: "11.8%",
},
{
name: "قطعه A2",
cultivar: "گندم پیشگام",
readiness: 87,
harvestDate: "۲۷ شهریور",
expectedYield: "10.1 تن",
moisture: "12.3%",
},
{
name: "قطعه B1",
cultivar: "گندم مهرگان",
readiness: 73,
harvestDate: "۳۰ شهریور",
expectedYield: "8.6 تن",
moisture: "13.7%",
},
],
}; };
const mockYieldQualityBandsData = { interface YieldHarvestPageWrapperProps {
bands: [ data?: YieldHarvestSummary;
{
label: "گرید ممتاز",
share: 46,
volume: "19.7 تن",
premium: "+18% قیمت",
color: "#2e7d32",
},
{
label: "گرید درجه یک",
share: 34,
volume: "14.5 تن",
premium: "+9% قیمت",
color: "#0288d1",
},
{
label: "گرید فرآوری",
share: 20,
volume: "8.6 تن",
premium: "فروش پایه",
color: "#ed6c02",
},
],
stats: [
{ label: "میانگین بریکس", value: "14.8" },
{ label: "یکنواختی دانه", value: "89%" },
{ label: "ضایعات قابل انتظار", value: "2.1%" },
{ label: "پتانسیل صادرات", value: "بالا" },
],
};
const mockHarvestOperationsData = {
summary:
"اگر برداشت از قطعات A1 و A2 در دو شیفت اول انجام شود، کیفیت ممتاز حفظ می شود و فشار روی مرحله سورتینگ نیز متعادل می ماند.",
steps: [
{
title: "برداشت قطعات اولویت دار",
note: "تمرکز ابتدا روی A1 و سپس A2 باشد تا گرید ممتاز در دمای پایین صبح جمع آوری شود.",
status: "امروز",
statusColor: "success",
},
{
title: "سورت و تفکیک بر اساس کیفیت",
note: "محصول ممتاز از جریان فرآوری جدا شود تا فروش با قیمت پریمیوم قابل حفظ باشد.",
status: "بعد از برداشت",
statusColor: "primary",
},
{
title: "انتقال سریع به انبار خنک",
note: "برای جلوگیری از افت رطوبت و رنگ، انتقال نهایی حداکثر تا ۶ ساعت پس از برداشت انجام شود.",
status: "ضروری",
statusColor: "warning",
},
],
outputs: [
{ label: "شیفت پیشنهادی", value: "۲ شیفت" },
{ label: "ظرفیت سورتینگ", value: "15 تن/روز" },
{ label: "نیروی مورد نیاز", value: "12 نفر" },
{ label: "مدت تا ارسال", value: "6 ساعت" },
],
};
const hasRenderableData = (summary?: YieldHarvestSummary) =>
Boolean(
summary?.yield_prediction ||
summary?.harvestPredictionCard ||
((summary?.yieldPredictionChart?.series as unknown[])?.length ?? 0) > 0,
);
const hasYieldKpis = (summary?: YieldHarvestSummary) =>
((summary?.yield_prediction as Record<string, unknown> | undefined)?.kpis as
| unknown[]
| undefined)?.length > 0;
const hasYieldChart = (summary?: YieldHarvestSummary) =>
((summary?.yieldPredictionChart as Record<string, unknown> | undefined)
?.series as unknown[] | undefined)?.length > 0;
const YieldHarvestPageWrapper = () => {
const { farmHub } = useFarmHub();
const farmUuid = farmHub?.farm_uuid;
const [data, setData] = useState<YieldHarvestSummary>(mockYieldHarvestData);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!farmUuid) {
setData(mockYieldHarvestData);
setLoading(false);
return;
} }
setLoading(true); const YieldHarvestPageWrapper = ({ data }: YieldHarvestPageWrapperProps) => {
yieldHarvestService const summary = data ?? mockYieldHarvestSummary;
.getSummary(farmUuid)
.then((summary) =>
setData(hasRenderableData(summary) ? summary : mockYieldHarvestData),
)
.catch(() => setData(mockYieldHarvestData))
.finally(() => setLoading(false));
}, [farmUuid]);
if (loading) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight={200}
>
<CircularProgress />
</Box>
);
}
return ( return (
<Box position="relative" sx={{ width: "100%" }}> <Box position="relative" sx={{ width: "100%" }}>
<Grid container spacing={6} alignItems="stretch"> <Grid container spacing={6} alignItems="stretch">
{hasYieldKpis(data) && ( {hasYieldKpis(summary) && (
<Grid <Grid
size={12} size={12}
container container
@@ -288,31 +70,63 @@ const YieldHarvestPageWrapper = () => {
sx={{ width: "100%" }} sx={{ width: "100%" }}
> >
<FarmOverviewKPIs <FarmOverviewKPIs
data={data.yield_prediction as Record<string, unknown>} data={summary.yield_prediction as Record<string, unknown>}
/> />
</Grid> </Grid>
)} )}
<Grid size={12} container spacing={6} sx={sectionGridSx}> <Grid size={12} container spacing={6} sx={sectionGridSx}>
<Grid size={{ xs: 12, md: 6, lg: 6 }} sx={compactCardSx}> <Grid size={{ xs: 12, md: 6, lg: 6 }} sx={compactCardSx}>
<HarvestPredictionCard <HarvestPredictionCard
data={data.harvestPredictionCard as Record<string, unknown>} data={
(summary.harvestPredictionCard as Record<string, unknown>) ??
(mockYieldHarvestSummary.harvestPredictionCard as Record<
string,
unknown
>)
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6, lg: 6 }} sx={compactCardSx}> <Grid size={{ xs: 12, md: 6, lg: 6 }} sx={compactCardSx}>
<HarvestReadinessZonesCard data={mockHarvestReadinessData} /> <HarvestReadinessZonesCard
data={
(summary.harvestReadinessZones as Record<string, unknown>) ??
(mockYieldHarvestSummary.harvestReadinessZones as Record<
string,
unknown
>)
}
/>
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6, lg: 6 }} sx={compactCardSx}> <Grid size={{ xs: 12, md: 6, lg: 6 }} sx={compactCardSx}>
<YieldQualityBandsCard data={mockYieldQualityBandsData} /> <YieldQualityBandsCard
data={
(summary.yieldQualityBands as Record<string, unknown>) ??
(mockYieldHarvestSummary.yieldQualityBands as Record<
string,
unknown
>)
}
/>
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6, lg: 6 }} sx={compactCardSx}> <Grid size={{ xs: 12, md: 6, lg: 6 }} sx={compactCardSx}>
<HarvestOperationsCard data={mockHarvestOperationsData} /> <HarvestOperationsCard
data={
(summary.harvestOperationsCard as Record<string, unknown>) ??
(mockYieldHarvestSummary.harvestOperationsCard as Record<
string,
unknown
>)
}
/>
</Grid> </Grid>
</Grid> </Grid>
{hasYieldChart(data) && (
{hasYieldChart(summary) && (
<Grid size={12} container spacing={6} sx={sectionGridSx}> <Grid size={12} container spacing={6} sx={sectionGridSx}>
<Grid size={{ xs: 12 }} sx={chartCardSx}> <Grid size={{ xs: 12 }} sx={chartCardSx}>
<YieldPredictionChart <YieldPredictionChart
data={data.yieldPredictionChart as Record<string, unknown>} data={summary.yieldPredictionChart as Record<string, unknown>}
/> />
</Grid> </Grid>
</Grid> </Grid>
@@ -20,6 +20,13 @@ import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid2"; import Grid from "@mui/material/Grid2";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Chip from "@mui/material/Chip";
import {
yieldHarvestService,
type CurrentFarmChartResponse,
type GrowthStatusResponse,
} from "@/libs/api/services/yieldHarvestService";
ChartJS.register( ChartJS.register(
LineElement, LineElement,
@@ -81,6 +88,12 @@ interface EnvironmentSettings {
water: number; water: number;
} }
interface PlantSimulatorProps {
farmUuid?: string;
currentFarmChart?: CurrentFarmChartResponse;
onRefreshDashboard?: () => Promise<void> | void;
}
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
const MAX_HEIGHT = 280; const MAX_HEIGHT = 280;
@@ -721,6 +734,19 @@ const GrowthChart = memo(function GrowthChart({
); );
}); });
function getGrowthTaskColor(status?: GrowthStatusResponse["status"]) {
switch (status) {
case "SUCCESS":
return "success";
case "FAILURE":
return "error";
case "PROGRESS":
return "warning";
default:
return "default";
}
}
// ─── Main Component ─────────────────────────────────────────────────────────── // ─── Main Component ───────────────────────────────────────────────────────────
const INIT_PLANT: PlantState = { const INIT_PLANT: PlantState = {
@@ -733,7 +759,11 @@ const INIT_PLANT: PlantState = {
yieldRate: 0, yieldRate: 0,
}; };
export default function PlantSimulator() { export default function PlantSimulator({
farmUuid,
currentFarmChart,
onRefreshDashboard,
}: PlantSimulatorProps) {
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [speed, setSpeed] = useState(1.5); const [speed, setSpeed] = useState(1.5);
const [env, setEnv] = useState<EnvironmentSettings>({ light: 75, water: 65 }); const [env, setEnv] = useState<EnvironmentSettings>({ light: 75, water: 65 });
@@ -749,6 +779,10 @@ export default function PlantSimulator() {
const historyIntervalRef = useRef<ReturnType<typeof setInterval> | null>( const historyIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
null, null,
); );
const [growthTaskId, setGrowthTaskId] = useState<string | null>(null);
const [growthTaskStatus, setGrowthTaskStatus] =
useState<GrowthStatusResponse | null>(null);
const [growthTaskLoading, setGrowthTaskLoading] = useState(false);
const plantRef = useRef<PlantState>(plant); const plantRef = useRef<PlantState>(plant);
plantRef.current = plant; plantRef.current = plant;
@@ -935,8 +969,52 @@ export default function PlantSimulator() {
}; };
}, [running]); }, [running]);
useEffect(() => {
if (!growthTaskId) return;
let isMounted = true;
const pollStatus = async () => {
try {
const status = await yieldHarvestService.getGrowthStatus(growthTaskId);
if (!isMounted) return;
setGrowthTaskStatus(status);
if (status.status === "SUCCESS" || status.status === "FAILURE") {
setGrowthTaskLoading(false);
setGrowthTaskId(null);
if (status.status === "SUCCESS") {
await onRefreshDashboard?.();
}
}
} catch {
if (!isMounted) return;
setGrowthTaskLoading(false);
setGrowthTaskStatus({
task_id: growthTaskId,
status: "FAILURE",
error: "خطا در دریافت وضعیت شبیه سازی",
});
setGrowthTaskId(null);
}
};
pollStatus();
const pollInterval = setInterval(pollStatus, 4000);
return () => {
isMounted = false;
clearInterval(pollInterval);
};
}, [growthTaskId, onRefreshDashboard]);
const t = useTranslations("plantSimulator"); const t = useTranslations("plantSimulator");
const isFinished = plant.height >= MAX_HEIGHT; const isFinished = plant.height >= MAX_HEIGHT;
const backendSummaryItems = currentFarmChart?.summary ?? [];
const backendCurrentState = currentFarmChart?.current_state ?? {};
const backendTaskPercent = Math.round(
growthTaskStatus?.progress?.percent ?? 0,
);
const statItems: { const statItems: {
value: string | number; value: string | number;
@@ -964,7 +1042,7 @@ export default function PlantSimulator() {
<Box className="flex flex-col gap-6 min-is-0 is-full"> <Box className="flex flex-col gap-6 min-is-0 is-full">
<Grid container spacing={6} className="min-is-0 is-full"> <Grid container spacing={6} className="min-is-0 is-full">
{/* ── Plant visualization ── */} {/* ── Plant visualization ── */}
<Grid size={{ xs: 12, lg: 6 }} className="flex"> <Grid size={{ xs: 12, lg: 5 }} className="flex">
<Card <Card
className="is-full flex flex-col items-center p-6" className="is-full flex flex-col items-center p-6"
sx={primaryPanelSx} sx={primaryPanelSx}
@@ -977,7 +1055,7 @@ export default function PlantSimulator() {
<Grid container spacing={2} className="is-full"> <Grid container spacing={2} className="is-full">
{statItems.map((item, idx) => ( {statItems.map((item, idx) => (
<Grid key={idx} size={{ xs: 4 }}> <Grid key={idx} size={{ xs: 6, sm: 3 }}>
<Card variant="outlined" className="text-center p-2.5"> <Card variant="outlined" className="text-center p-2.5">
<Typography variant="h6" color={item.color}> <Typography variant="h6" color={item.color}>
{item.value} {item.value}
@@ -1004,7 +1082,7 @@ export default function PlantSimulator() {
</Grid> </Grid>
{/* ── Chart ── */} {/* ── Chart ── */}
<Grid size={{ xs: 12, lg: 6 }} className="flex"> <Grid size={{ xs: 12, lg: 7 }} className="flex">
<Card className="p-6 flex flex-col" sx={primaryPanelSx}> <Card className="p-6 flex flex-col" sx={primaryPanelSx}>
<CardContent sx={{ flex: 1 }}> <CardContent sx={{ flex: 1 }}>
<GrowthChart <GrowthChart
@@ -1161,7 +1239,39 @@ export default function PlantSimulator() {
<Button <Button
variant="contained" variant="contained"
color={running ? "error" : "success"} color={running ? "error" : "success"}
onClick={() => setRunning((r) => !r)} onClick={async () => {
if (running) {
setRunning(false);
return;
}
setRunning(true);
if (!farmUuid || growthTaskLoading || growthTaskId) {
return;
}
try {
setGrowthTaskLoading(true);
const task = await yieldHarvestService.startGrowth(
farmUuid,
);
setGrowthTaskStatus({
task_id: task.task_id,
status: "PENDING",
message: "تسک شبیه سازی در صف قرار گرفت.",
});
setGrowthTaskId(task.task_id);
} catch {
setGrowthTaskLoading(false);
setGrowthTaskStatus({
task_id: "unavailable",
status: "FAILURE",
error: "شروع شبیه سازی در بک اند انجام نشد.",
});
}
}}
disabled={isFinished} disabled={isFinished}
fullWidth fullWidth
> >
@@ -1262,6 +1372,98 @@ export default function PlantSimulator() {
</Typography> </Typography>
</Typography> </Typography>
</Card> </Card>
{farmUuid && (
<Card variant="outlined" className="p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<Typography variant="body2" className="font-medium">
وضعیت اتصال AI
</Typography>
<Chip
label={
growthTaskStatus?.status
? growthTaskStatus.status
: "SYNCED"
}
color={getGrowthTaskColor(growthTaskStatus?.status)}
size="small"
variant="tonal"
/>
</div>
<Typography variant="caption" color="text.secondary">
گیاه فعال: {currentFarmChart?.plant_name ?? "نامشخص"}
</Typography>
<Typography variant="caption" color="text.secondary">
موتور: {currentFarmChart?.engine ?? "-"} / مدل:{" "}
{currentFarmChart?.model_name ?? "-"}
</Typography>
{growthTaskLoading && (
<Typography variant="caption" color="warning.main">
تسک شبیه سازی در حال ارسال به بک اند است...
</Typography>
)}
{growthTaskStatus?.progress && (
<Typography variant="caption" color="text.secondary">
پیشرفت شبیه سازی: {backendTaskPercent}٪
</Typography>
)}
{growthTaskStatus?.message && (
<Typography variant="caption" color="text.secondary">
{growthTaskStatus.message}
</Typography>
)}
{growthTaskStatus?.error && (
<Typography variant="caption" color="error.main">
{growthTaskStatus.error}
</Typography>
)}
{backendSummaryItems.length > 0 && (
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 2,
}}
>
{backendSummaryItems.slice(0, 2).map((item, index) => (
<Card key={index} variant="outlined" className="p-3">
<Typography variant="caption" color="text.secondary">
{item.title}
</Typography>
<Typography variant="body2" className="font-medium">
{item.amount}
{item.unit ? ` ${item.unit}` : ""}
</Typography>
{item.subtitle && (
<Typography variant="caption" color="text.secondary">
{item.subtitle}
</Typography>
)}
</Card>
))}
</Box>
)}
{Object.keys(backendCurrentState).length > 0 && (
<Typography variant="caption" color="text.secondary">
آخرین وضعیت مزرعه: برگ {String(
backendCurrentState.leaf_count_estimate ?? "-",
)}، بیوماس {String(
backendCurrentState.biomass_weight ?? "-",
)}، رطوبت خاک {String(
backendCurrentState.soil_moisture_percent ?? "-",
)}
</Typography>
)}
</Card>
)}
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
@@ -0,0 +1,212 @@
import type { YieldHarvestSummary } from '@/libs/api/services/yieldHarvestService'
export const mockYieldHarvestSummary: YieldHarvestSummary = {
seasonHighlightsCard: {
title: 'اتاق فرمان برداشت این فصل',
subtitle:
'این بخش برای نمایش سریع وضعیت عملکرد، کیفیت و بهترین پنجره فروش طراحی شده است. داده ها فعلا ماک هستند تا ظاهر نهایی کارت ها و ریتم بصری صفحه بهتر دیده شود.',
seasonLabel: 'فصل ۱۴۰۴',
badges: ['کیفیت ممتاز', 'آماده بسته بندی', 'ریسک پایین'],
spotlight: {
title: 'پنجره طلایی فروش',
value: '۳ روز اول بعد از برداشت',
caption:
'در این بازه، برآورد قیمت فروش حدود ۸٪ بهتر از میانگین هفتگی است.',
},
metrics: [
{
label: 'سطح قابل برداشت',
value: '18.6 هکتار',
caption: '۴ قطعه در اولویت نخست قرار دارند.',
avatarIcon: 'tabler-map-2',
avatarColor: 'success',
},
{
label: 'گرید ممتاز',
value: '46%',
caption: 'بالاترین سهم کیفیت مربوط به قطعه A2 است.',
avatarIcon: 'tabler-rosette-discount-check',
avatarColor: 'warning',
},
{
label: 'درآمد هدف',
value: '1.84 میلیارد',
caption: 'با فرض فروش در بازه پیشنهادی مدل.',
avatarIcon: 'tabler-cash-banknote',
avatarColor: 'primary',
},
],
},
yield_prediction: {
kpis: [
{
id: 'predicted-yield',
title: 'عملکرد پیش بینی شده',
subtitle: 'فصل جاری',
stats: '42.8 تن',
avatarColor: 'primary',
avatarIcon: 'tabler-chart-arcs',
chipText: '+12%',
chipColor: 'success',
},
{
id: 'harvest-readiness',
title: 'آمادگی برداشت',
subtitle: 'میانگین مزرعه',
stats: '84%',
avatarColor: 'success',
avatarIcon: 'tabler-plant-2',
chipText: 'روی برنامه',
chipColor: 'success',
},
{
id: 'quality-score',
title: 'امتیاز کیفیت',
subtitle: 'برآورد هوش مصنوعی',
stats: '91/100',
avatarColor: 'info',
avatarIcon: 'tabler-stars',
chipText: '+4 واحد',
chipColor: 'success',
},
{
id: 'loss-risk',
title: 'ریسک افت محصول',
subtitle: 'آب و هوا و آفات',
stats: '6.5%',
avatarColor: 'warning',
avatarIcon: 'tabler-alert-triangle',
chipText: 'پایین',
chipColor: 'success',
},
],
},
harvestPredictionCard: {
dateFormatted: '۲۸ شهریور',
daysUntil: 18,
description:
'با توجه به روند رشد بوته، الگوی آبیاری و وضعیت دمایی اخیر، این مزرعه در هفته آخر شهریور به نقطه ایده آل برداشت می رسد.',
},
harvestReadinessZones: {
averageReadiness: '84%',
blocks: [
{
name: 'قطعه A1',
cultivar: 'گندم سیروان',
readiness: 92,
harvestDate: '۲۶ شهریور',
expectedYield: '12.4 تن',
moisture: '11.8%',
},
{
name: 'قطعه A2',
cultivar: 'گندم پیشگام',
readiness: 87,
harvestDate: '۲۷ شهریور',
expectedYield: '10.1 تن',
moisture: '12.3%',
},
{
name: 'قطعه B1',
cultivar: 'گندم مهرگان',
readiness: 73,
harvestDate: '۳۰ شهریور',
expectedYield: '8.6 تن',
moisture: '13.7%',
},
],
},
yieldQualityBands: {
bands: [
{
label: 'گرید ممتاز',
share: 46,
volume: '19.7 تن',
premium: '+18% قیمت',
color: '#2e7d32',
},
{
label: 'گرید درجه یک',
share: 34,
volume: '14.5 تن',
premium: '+9% قیمت',
color: '#0288d1',
},
{
label: 'گرید فرآوری',
share: 20,
volume: '8.6 تن',
premium: 'فروش پایه',
color: '#ed6c02',
},
],
stats: [
{ label: 'میانگین بریکس', value: '14.8' },
{ label: 'یکنواختی دانه', value: '89%' },
{ label: 'ضایعات قابل انتظار', value: '2.1%' },
{ label: 'پتانسیل صادرات', value: 'بالا' },
],
},
harvestOperationsCard: {
summary:
'اگر برداشت از قطعات A1 و A2 در دو شیفت اول انجام شود، کیفیت ممتاز حفظ می شود و فشار روی مرحله سورتینگ نیز متعادل می ماند.',
steps: [
{
title: 'برداشت قطعات اولویت دار',
note:
'تمرکز ابتدا روی A1 و سپس A2 باشد تا گرید ممتاز در دمای پایین صبح جمع آوری شود.',
status: 'امروز',
statusColor: 'success',
},
{
title: 'سورت و تفکیک بر اساس کیفیت',
note:
'محصول ممتاز از جریان فرآوری جدا شود تا فروش با قیمت پریمیوم قابل حفظ باشد.',
status: 'بعد از برداشت',
statusColor: 'primary',
},
{
title: 'انتقال سریع به انبار خنک',
note:
'برای جلوگیری از افت رطوبت و رنگ، انتقال نهایی حداکثر تا ۶ ساعت پس از برداشت انجام شود.',
status: 'ضروری',
statusColor: 'warning',
},
],
outputs: [
{ label: 'شیفت پیشنهادی', value: '۲ شیفت' },
{ label: 'ظرفیت سورتینگ', value: '15 تن/روز' },
{ label: 'نیروی مورد نیاز', value: '12 نفر' },
{ label: 'مدت تا ارسال', value: '6 ساعت' },
],
},
yieldPredictionChart: {
categories: ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان'],
series: [
{
name: 'سال قبل',
data: [9, 11, 13, 16, 19, 24, 28, 31],
},
{
name: 'سال جاری',
data: [10, 12, 15, 18, 23, 29, 34, 39],
},
],
summary: [
{
title: 'بیشترین خروجی پیش بینی شده',
subtitle: 'بهترین ماه برداشت',
amount: '39 تن',
avatarColor: 'success',
avatarIcon: 'tabler-trending-up',
},
{
title: 'رشد این فصل',
subtitle: 'نسبت به سال قبل',
amount: '+11.2 تن',
avatarColor: 'primary',
avatarIcon: 'tabler-chart-line',
},
],
},
}