UPDATE
This commit is contained in:
@@ -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های آماده نمایش برای متنها، تاریخها و واحدها
|
||||
|
||||
این ساختار هم برای وضعیت فعلی فرانت کافی است، هم برای توسعه بعدی صفحه.
|
||||
@@ -2,10 +2,108 @@ import { apiClient } from '../client'
|
||||
|
||||
const PREFIX = '/api/yield-harvest'
|
||||
|
||||
type GenericRecord = Record<string, unknown>
|
||||
type StatusColor = 'primary' | 'success' | 'info' | 'warning'
|
||||
|
||||
export interface YieldHarvestSummary {
|
||||
yield_prediction?: Record<string, unknown>
|
||||
yieldPredictionChart?: Record<string, unknown>
|
||||
harvestPredictionCard?: Record<string, unknown>
|
||||
seasonHighlightsCard?: GenericRecord
|
||||
yield_prediction?: GenericRecord
|
||||
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> {
|
||||
@@ -18,29 +116,857 @@ interface ApiResponse<T> {
|
||||
function extract<T>(res: ApiResponse<T> | T): T {
|
||||
if (res && typeof res === 'object') {
|
||||
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
|
||||
}
|
||||
|
||||
function toKpiCard(card?: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!card || typeof card !== 'object') return {}
|
||||
function asRecord(value: unknown): GenericRecord | undefined {
|
||||
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 = {
|
||||
async getSummary(farmUuid: string): Promise<YieldHarvestSummary> {
|
||||
const res = await apiClient.get<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
|
||||
`${PREFIX}/summary/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
||||
async getCurrentFarmChart(farmUuid: string): Promise<CurrentFarmChartResponse> {
|
||||
const response = await apiClient.post<
|
||||
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 {
|
||||
yield_prediction: toKpiCard(data?.yield_prediction ?? data?.yield_prediction_card),
|
||||
yieldPredictionChart: (data?.yieldPredictionChart ?? data?.yield_prediction_chart ?? {}) as Record<string, unknown>,
|
||||
harvestPredictionCard: (data?.harvestPredictionCard ?? data?.harvest_prediction_card ?? {}) as Record<string, unknown>
|
||||
summary: enrichSummaryData(summary, {
|
||||
harvestPrediction,
|
||||
yieldPrediction,
|
||||
currentFarmChart,
|
||||
}),
|
||||
harvestPrediction,
|
||||
yieldPrediction,
|
||||
currentFarmChart,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,60 +1,94 @@
|
||||
"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 Box from "@mui/material/Box";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
|
||||
import YieldHarvestPageWrapper from "@views/dashboards/farm/YieldHarvestPageWrapper";
|
||||
import YieldSeasonHighlightsCard from "@views/dashboards/farm/YieldSeasonHighlightsCard";
|
||||
import PlantSimulator from "@views/dashboards/farm/plantSimulator/PlantSimulator";
|
||||
|
||||
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",
|
||||
},
|
||||
],
|
||||
};
|
||||
import { mockYieldHarvestSummary } from "@views/dashboards/farm/yieldHarvestMockData";
|
||||
|
||||
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 (
|
||||
<Grid container spacing={6} alignItems="stretch">
|
||||
<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 size={{ xs: 12 }} sx={{ display: "flex", width: "100%" }}>
|
||||
<PlantSimulator />
|
||||
<PlantSimulator
|
||||
farmUuid={farmUuid}
|
||||
currentFarmChart={currentFarmChart ?? undefined}
|
||||
onRefreshDashboard={loadDashboard}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12 }} sx={{ display: "flex", width: "100%" }}>
|
||||
<YieldHarvestPageWrapper />
|
||||
<YieldHarvestPageWrapper data={summary} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFarmHub } from "@/hooks/useFarmHub";
|
||||
|
||||
import Grid from "@mui/material/Grid2";
|
||||
import Box from "@mui/material/Box";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
|
||||
import HarvestPredictionCard from "@views/dashboards/farm/HarvestPredictionCard";
|
||||
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 HarvestOperationsCard from "@views/dashboards/farm/HarvestOperationsCard";
|
||||
|
||||
import { yieldHarvestService } from "@/libs/api/services/yieldHarvestService";
|
||||
import type { YieldHarvestSummary } from "@/libs/api/services/yieldHarvestService";
|
||||
import { mockYieldHarvestSummary } from "@views/dashboards/farm/yieldHarvestMockData";
|
||||
|
||||
const cardSlotSx = {
|
||||
display: "flex",
|
||||
@@ -40,246 +36,32 @@ const chartCardSx = {
|
||||
minHeight: { xs: 360, lg: 440 },
|
||||
};
|
||||
|
||||
const mockYieldHarvestData: YieldHarvestSummary = {
|
||||
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:
|
||||
"با توجه به روند رشد بوته، الگوی آبیاری و وضعیت دمایی اخیر، این مزرعه در هفته آخر شهریور به نقطه ایده آل برداشت می رسد.",
|
||||
},
|
||||
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 hasYieldKpis = (summary?: YieldHarvestSummary) => {
|
||||
const kpis = (summary?.yield_prediction as Record<string, unknown> | undefined)
|
||||
?.kpis;
|
||||
|
||||
return Array.isArray(kpis) && kpis.length > 0;
|
||||
};
|
||||
|
||||
const mockHarvestReadinessData = {
|
||||
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%",
|
||||
},
|
||||
],
|
||||
const hasYieldChart = (summary?: YieldHarvestSummary) => {
|
||||
const series = (
|
||||
summary?.yieldPredictionChart as Record<string, unknown> | undefined
|
||||
)?.series;
|
||||
|
||||
return Array.isArray(series) && series.length > 0;
|
||||
};
|
||||
|
||||
const mockYieldQualityBandsData = {
|
||||
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: "بالا" },
|
||||
],
|
||||
};
|
||||
interface YieldHarvestPageWrapperProps {
|
||||
data?: YieldHarvestSummary;
|
||||
}
|
||||
|
||||
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);
|
||||
yieldHarvestService
|
||||
.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>
|
||||
);
|
||||
}
|
||||
const YieldHarvestPageWrapper = ({ data }: YieldHarvestPageWrapperProps) => {
|
||||
const summary = data ?? mockYieldHarvestSummary;
|
||||
|
||||
return (
|
||||
<Box position="relative" sx={{ width: "100%" }}>
|
||||
<Grid container spacing={6} alignItems="stretch">
|
||||
{hasYieldKpis(data) && (
|
||||
{hasYieldKpis(summary) && (
|
||||
<Grid
|
||||
size={12}
|
||||
container
|
||||
@@ -288,31 +70,63 @@ const YieldHarvestPageWrapper = () => {
|
||||
sx={{ width: "100%" }}
|
||||
>
|
||||
<FarmOverviewKPIs
|
||||
data={data.yield_prediction as Record<string, unknown>}
|
||||
data={summary.yield_prediction as Record<string, unknown>}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Grid size={12} container spacing={6} sx={sectionGridSx}>
|
||||
<Grid size={{ xs: 12, md: 6, lg: 6 }} sx={compactCardSx}>
|
||||
<HarvestPredictionCard
|
||||
data={data.harvestPredictionCard as Record<string, unknown>}
|
||||
data={
|
||||
(summary.harvestPredictionCard as Record<string, unknown>) ??
|
||||
(mockYieldHarvestSummary.harvestPredictionCard as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<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 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 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>
|
||||
{hasYieldChart(data) && (
|
||||
|
||||
{hasYieldChart(summary) && (
|
||||
<Grid size={12} container spacing={6} sx={sectionGridSx}>
|
||||
<Grid size={{ xs: 12 }} sx={chartCardSx}>
|
||||
<YieldPredictionChart
|
||||
data={data.yieldPredictionChart as Record<string, unknown>}
|
||||
data={summary.yieldPredictionChart as Record<string, unknown>}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -20,6 +20,13 @@ import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import Grid from "@mui/material/Grid2";
|
||||
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(
|
||||
LineElement,
|
||||
@@ -81,6 +88,12 @@ interface EnvironmentSettings {
|
||||
water: number;
|
||||
}
|
||||
|
||||
interface PlantSimulatorProps {
|
||||
farmUuid?: string;
|
||||
currentFarmChart?: CurrentFarmChartResponse;
|
||||
onRefreshDashboard?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
const INIT_PLANT: PlantState = {
|
||||
@@ -733,7 +759,11 @@ const INIT_PLANT: PlantState = {
|
||||
yieldRate: 0,
|
||||
};
|
||||
|
||||
export default function PlantSimulator() {
|
||||
export default function PlantSimulator({
|
||||
farmUuid,
|
||||
currentFarmChart,
|
||||
onRefreshDashboard,
|
||||
}: PlantSimulatorProps) {
|
||||
const [running, setRunning] = useState(false);
|
||||
const [speed, setSpeed] = useState(1.5);
|
||||
const [env, setEnv] = useState<EnvironmentSettings>({ light: 75, water: 65 });
|
||||
@@ -749,6 +779,10 @@ export default function PlantSimulator() {
|
||||
const historyIntervalRef = useRef<ReturnType<typeof setInterval> | 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);
|
||||
plantRef.current = plant;
|
||||
|
||||
@@ -935,8 +969,52 @@ export default function PlantSimulator() {
|
||||
};
|
||||
}, [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 isFinished = plant.height >= MAX_HEIGHT;
|
||||
const backendSummaryItems = currentFarmChart?.summary ?? [];
|
||||
const backendCurrentState = currentFarmChart?.current_state ?? {};
|
||||
const backendTaskPercent = Math.round(
|
||||
growthTaskStatus?.progress?.percent ?? 0,
|
||||
);
|
||||
|
||||
const statItems: {
|
||||
value: string | number;
|
||||
@@ -964,7 +1042,7 @@ export default function PlantSimulator() {
|
||||
<Box className="flex flex-col gap-6 min-is-0 is-full">
|
||||
<Grid container spacing={6} className="min-is-0 is-full">
|
||||
{/* ── Plant visualization ── */}
|
||||
<Grid size={{ xs: 12, lg: 6 }} className="flex">
|
||||
<Grid size={{ xs: 12, lg: 5 }} className="flex">
|
||||
<Card
|
||||
className="is-full flex flex-col items-center p-6"
|
||||
sx={primaryPanelSx}
|
||||
@@ -977,7 +1055,7 @@ export default function PlantSimulator() {
|
||||
|
||||
<Grid container spacing={2} className="is-full">
|
||||
{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">
|
||||
<Typography variant="h6" color={item.color}>
|
||||
{item.value}
|
||||
@@ -1004,7 +1082,7 @@ export default function PlantSimulator() {
|
||||
</Grid>
|
||||
|
||||
{/* ── 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}>
|
||||
<CardContent sx={{ flex: 1 }}>
|
||||
<GrowthChart
|
||||
@@ -1161,7 +1239,39 @@ export default function PlantSimulator() {
|
||||
<Button
|
||||
variant="contained"
|
||||
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}
|
||||
fullWidth
|
||||
>
|
||||
@@ -1262,6 +1372,98 @@ export default function PlantSimulator() {
|
||||
</Typography>
|
||||
</Typography>
|
||||
</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>
|
||||
</Card>
|
||||
</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',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user