UPDATE
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
import EconomicOverviewPageWrapper from '@views/dashboards/farm/EconomicOverviewPageWrapper'
|
||||||
|
|
||||||
|
const EconomicOverviewPage = () => {
|
||||||
|
return <EconomicOverviewPageWrapper />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EconomicOverviewPage
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
# Soil Data Backend Requirements
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
صفحه `soil-data` در فرانت از فایل `src/views/dashboards/farm/SoilDataDashboardWrapper.tsx` رندر میشود و در حال حاضر این 4 بلاک را نمایش میدهد:
|
||||||
|
|
||||||
|
1. `SoilMoistureHeatmap`
|
||||||
|
2. `SensorRadarChart`
|
||||||
|
3. `AnomalyDetectionCard`
|
||||||
|
4. `SensorComparisonChart`
|
||||||
|
|
||||||
|
اما payload فعلی بکاند فقط این اطلاعات را برمیگرداند:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"healthScore": 42,
|
||||||
|
"profileSource": "خیار",
|
||||||
|
"healthScoreDetails": {
|
||||||
|
"method": "normalized_weighted_average",
|
||||||
|
"profileSource": "خیار",
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"metricType": "moisture",
|
||||||
|
"label": "رطوبت خاک",
|
||||||
|
"unit": "%",
|
||||||
|
"currentValue": 42.3,
|
||||||
|
"idealValue": 65.0,
|
||||||
|
"minRange": 45.0,
|
||||||
|
"maxRange": 75.0,
|
||||||
|
"weight": 0.45,
|
||||||
|
"normalizedValue": 0.0,
|
||||||
|
"weightedContribution": 0.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"healthLanguage": {
|
||||||
|
"short_chip_text": "تنش بالا",
|
||||||
|
"action_hint": "اصلاح فوری رطوبت، تغذیه يا شوری بر اساس اجزای امتیاز انجام شود.",
|
||||||
|
"explanation": "چند شاخص اصلی خارج از بازه قابل قبول گیاه هستند."
|
||||||
|
},
|
||||||
|
"avgSoilMoisture": 42,
|
||||||
|
"avgSoilMoistureRaw": 42.3,
|
||||||
|
"avgSoilMoistureStatus": "نیازمند بررسی"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## مشکل اصلی
|
||||||
|
|
||||||
|
داده فعلی برای نمایش یک کارت خلاصه سلامت خاک مفید است، ولی برای کامپوننتهای فعلی صفحه `soil-data` کافی نیست.
|
||||||
|
|
||||||
|
یعنی در حال حاضر:
|
||||||
|
|
||||||
|
- `healthScore` و `healthScoreDetails` دریافت میشوند، اما هیچکدام مستقیم به ساختار موردنیاز کامپوننتهای صفحه map نشدهاند.
|
||||||
|
- دادهای برای heatmap زمانی/ناحیهای وجود ندارد.
|
||||||
|
- دادهای برای نمودار radar وجود ندارد.
|
||||||
|
- داده anomalyهای قابل نمایش به فرمت UI وجود ندارد.
|
||||||
|
- داده trend هفتگی/مقایسهای برای chart پایینی وجود ندارد.
|
||||||
|
|
||||||
|
## دادههای موردنیاز که الان دریافت نمیشوند
|
||||||
|
|
||||||
|
### 1) Soil Moisture Heatmap
|
||||||
|
|
||||||
|
کامپوننت `SoilMoistureHeatmap.tsx` این ساختار را میخواهد:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
soilMoistureHeatmap: {
|
||||||
|
series: Array<{
|
||||||
|
name: string
|
||||||
|
data: Array<{
|
||||||
|
x: string
|
||||||
|
y: number
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### دادهای که باید بکاند بدهد
|
||||||
|
|
||||||
|
- `series[].name`: نام zone یا بخش مزرعه، مثلا `Zone A`
|
||||||
|
- `series[].data[].x`: بازه زمانی یا label محور افقی، مثلا `08:00` یا `شنبه`
|
||||||
|
- `series[].data[].y`: مقدار رطوبت برای همان zone و همان زمان در بازه `0..100`
|
||||||
|
|
||||||
|
### چیزی که الان نداریم
|
||||||
|
|
||||||
|
- رطوبت خاک به تفکیک zone
|
||||||
|
- رطوبت خاک در طول زمان
|
||||||
|
- داده heatmap-ready برای chart
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2) Sensor Radar Chart
|
||||||
|
|
||||||
|
کامپوننت `SensorRadarChart.tsx` این ساختار را میخواهد:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
sensorRadarChart: {
|
||||||
|
labels: string[]
|
||||||
|
series: Array<{
|
||||||
|
name: string
|
||||||
|
data: number[]
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### دادهای که باید بکاند بدهد
|
||||||
|
|
||||||
|
- `labels`: نام شاخصها، مثلا:
|
||||||
|
- `Moisture`
|
||||||
|
- `pH`
|
||||||
|
- `EC`
|
||||||
|
- `Nitrogen`
|
||||||
|
- `Phosphorus`
|
||||||
|
- `Potassium`
|
||||||
|
- `series[0]`: مقادیر فعلی
|
||||||
|
- `series[1]`: مقادیر ایدهآل یا target range
|
||||||
|
|
||||||
|
### چیزی که الان داریم ولی کافی نیست
|
||||||
|
|
||||||
|
در `healthScoreDetails.components` فقط 3 شاخص داریم:
|
||||||
|
|
||||||
|
- moisture
|
||||||
|
- ph
|
||||||
|
- ec
|
||||||
|
|
||||||
|
این داده برای radar chart کافی نیست مگر اینکه:
|
||||||
|
|
||||||
|
- بکاند آن را به فرمت `labels + series` تبدیل کند
|
||||||
|
- و ترجیحا شاخصهای بیشتری هم بدهد
|
||||||
|
|
||||||
|
### چیزی که الان نداریم
|
||||||
|
|
||||||
|
- ساختار chart-ready برای radar
|
||||||
|
- سری مجزای `current` و `ideal`
|
||||||
|
- شاخصهای کاملتر خاک برای مقایسه تصویری
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3) Anomaly Detection Card
|
||||||
|
|
||||||
|
کامپوننت `AnomalyDetectionCard.tsx` این ساختار را میخواهد:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
anomalyDetectionCard: {
|
||||||
|
anomalies: Array<{
|
||||||
|
sensor: string
|
||||||
|
value: string
|
||||||
|
expected: string
|
||||||
|
deviation: string
|
||||||
|
severity: "warning" | "error"
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### دادهای که باید بکاند بدهد
|
||||||
|
|
||||||
|
برای هر anomaly:
|
||||||
|
|
||||||
|
- نام سنسور یا متریک
|
||||||
|
- مقدار فعلی
|
||||||
|
- بازه یا مقدار مورد انتظار
|
||||||
|
- میزان انحراف
|
||||||
|
- سطح شدت
|
||||||
|
|
||||||
|
مثال:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"anomalies": [
|
||||||
|
{
|
||||||
|
"sensor": "رطوبت خاک",
|
||||||
|
"value": "42.3%",
|
||||||
|
"expected": "45% - 75%",
|
||||||
|
"deviation": "-2.7%",
|
||||||
|
"severity": "error"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### چیزی که الان تا حدی داریم
|
||||||
|
|
||||||
|
از `healthScoreDetails.components` میشود فهمید بعضی متریکها خارج از بازهاند، چون:
|
||||||
|
|
||||||
|
- `currentValue`
|
||||||
|
- `minRange`
|
||||||
|
- `maxRange`
|
||||||
|
- `idealValue`
|
||||||
|
|
||||||
|
وجود دارد.
|
||||||
|
|
||||||
|
ولی هنوز اینها را به anomaly list آمادهی UI تبدیل نکردهایم.
|
||||||
|
|
||||||
|
### چیزی که الان نداریم
|
||||||
|
|
||||||
|
- لیست anomalyهای آماده نمایش
|
||||||
|
- severity استاندارد برای هر anomaly
|
||||||
|
- deviation format شده برای UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4) Sensor Comparison Chart
|
||||||
|
|
||||||
|
کامپوننت `SensorComparisonChart.tsx` این ساختار را میخواهد:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
sensorComparisonChart: {
|
||||||
|
categories: string[]
|
||||||
|
currentValue: number
|
||||||
|
vsLastWeek: string
|
||||||
|
series: Array<{
|
||||||
|
name: string
|
||||||
|
data: number[]
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### دادهای که باید بکاند بدهد
|
||||||
|
|
||||||
|
- `categories`: مثلا روزهای هفته یا timestampها
|
||||||
|
- `currentValue`: مقدار فعلی خلاصهشده، مثلا میانگین رطوبت امروز
|
||||||
|
- `vsLastWeek`: متن مقایسهای مثل `+5% نسبت به هفته قبل`
|
||||||
|
- `series`: حداقل دو سری:
|
||||||
|
- سری فعلی
|
||||||
|
- سری هفته قبل یا baseline
|
||||||
|
|
||||||
|
مثال:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"categories": ["Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri"],
|
||||||
|
"currentValue": 42,
|
||||||
|
"vsLastWeek": "-8% نسبت به هفته قبل",
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "این هفته",
|
||||||
|
"data": [45, 44, 43, 42, 41, 43, 42]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "هفته قبل",
|
||||||
|
"data": [52, 50, 49, 48, 47, 46, 45]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### چیزی که الان نداریم
|
||||||
|
|
||||||
|
- داده trend زمانی
|
||||||
|
- مقایسه با هفته قبل
|
||||||
|
- seriesهای chart-ready
|
||||||
|
|
||||||
|
## جمعبندی دادههای ناقص
|
||||||
|
|
||||||
|
payload فعلی بیشتر برای این use-case مناسب است:
|
||||||
|
|
||||||
|
- نمایش نمره سلامت خاک
|
||||||
|
- توضیح متنی وضعیت
|
||||||
|
- نمایش میانگین فعلی رطوبت
|
||||||
|
|
||||||
|
اما برای UI فعلی صفحه `soil-data` این data objectها هنوز از بکاند نیاز هستند:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
soilMoistureHeatmap: { series: [...] },
|
||||||
|
sensorRadarChart: { labels: [...], series: [...] },
|
||||||
|
anomalyDetectionCard: { anomalies: [...] },
|
||||||
|
sensorComparisonChart: {
|
||||||
|
categories: [...],
|
||||||
|
currentValue: number,
|
||||||
|
vsLastWeek: string,
|
||||||
|
series: [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## پیشنهاد API خروجی
|
||||||
|
|
||||||
|
بهترین حالت این است که endpoint فعلی `/api/soil/summary/` همین ساختار نهایی را برگرداند:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"healthScore": 42,
|
||||||
|
"profileSource": "خیار",
|
||||||
|
"healthScoreDetails": {},
|
||||||
|
"healthLanguage": {},
|
||||||
|
"avgSoilMoisture": 42,
|
||||||
|
"avgSoilMoistureRaw": 42.3,
|
||||||
|
"avgSoilMoistureStatus": "نیازمند بررسی",
|
||||||
|
"soilMoistureHeatmap": {
|
||||||
|
"series": []
|
||||||
|
},
|
||||||
|
"sensorRadarChart": {
|
||||||
|
"labels": [],
|
||||||
|
"series": []
|
||||||
|
},
|
||||||
|
"anomalyDetectionCard": {
|
||||||
|
"anomalies": []
|
||||||
|
},
|
||||||
|
"sensorComparisonChart": {
|
||||||
|
"categories": [],
|
||||||
|
"currentValue": 42,
|
||||||
|
"vsLastWeek": "",
|
||||||
|
"series": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکته مهم
|
||||||
|
|
||||||
|
اگر قرار است فقط همین payload فعلی از بکاند بماند، باید فرانت تغییر کند و بهجای 4 کامپوننت فعلی، یک صفحه جدید بر اساس این دادهها بسازد؛ چون ساختار فعلی UI با داده فعلی backend همخوانی کامل ندارد.
|
||||||
@@ -70,6 +70,11 @@ const horizontalMenuData = (): HorizontalMenuDataType[] => [
|
|||||||
icon: 'tabler-bug',
|
icon: 'tabler-bug',
|
||||||
href: '/pest-risk'
|
href: '/pest-risk'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'economicOverview',
|
||||||
|
icon: 'tabler-cash-banknote',
|
||||||
|
href: '/economic-overview'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'farmCalendar',
|
label: 'farmCalendar',
|
||||||
icon: 'tabler-calendar-event',
|
icon: 'tabler-calendar-event',
|
||||||
@@ -121,6 +126,11 @@ const horizontalMenuData = (): HorizontalMenuDataType[] => [
|
|||||||
icon: 'tabler-bug',
|
icon: 'tabler-bug',
|
||||||
href: '/pest-risk'
|
href: '/pest-risk'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'economicOverview',
|
||||||
|
icon: 'tabler-cash-banknote',
|
||||||
|
href: '/economic-overview'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'farmCalendar',
|
label: 'farmCalendar',
|
||||||
icon: 'tabler-calendar-event',
|
icon: 'tabler-calendar-event',
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ const verticalMenuData = (): VerticalMenuDataType[] => [
|
|||||||
icon: 'tabler-bug',
|
icon: 'tabler-bug',
|
||||||
href: '/pest-risk'
|
href: '/pest-risk'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'economicOverview',
|
||||||
|
icon: 'tabler-cash-banknote',
|
||||||
|
href: '/economic-overview'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'farmCalendar',
|
label: 'farmCalendar',
|
||||||
icon: 'tabler-calendar-event',
|
icon: 'tabler-calendar-event',
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
import { apiClient } from '../client'
|
import { apiClient } from '../client'
|
||||||
|
import type { ApiError } from '../client'
|
||||||
|
|
||||||
const PREFIX = '/api/pest-detection'
|
const DETECTION_PREFIX = '/api/pest-detection'
|
||||||
|
const DISEASE_PREFIX = '/api/pest-disease'
|
||||||
|
|
||||||
|
export interface RiskCard {
|
||||||
|
id?: string
|
||||||
|
title?: string
|
||||||
|
subtitle?: string
|
||||||
|
stats?: string
|
||||||
|
avatarColor?: string
|
||||||
|
avatarIcon?: string
|
||||||
|
chipText?: string
|
||||||
|
chipColor?: string
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
export interface PestRiskSummary {
|
export interface PestRiskSummary {
|
||||||
disease_risk?: Record<string, unknown>
|
diseaseRisk?: Record<string, unknown>
|
||||||
pest_risk?: Record<string, unknown>
|
pestRisk?: Record<string, unknown>
|
||||||
|
drivers?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
@@ -14,6 +29,12 @@ interface ApiResponse<T> {
|
|||||||
result?: T
|
result?: T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRouteMismatchError(error: unknown): boolean {
|
||||||
|
const statusCode = (error as ApiError | undefined)?.code
|
||||||
|
|
||||||
|
return statusCode === 404 || statusCode === 405
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -23,22 +44,87 @@ function extract<T>(res: ApiResponse<T> | T): T {
|
|||||||
return res as T
|
return res as T
|
||||||
}
|
}
|
||||||
|
|
||||||
function toKpiCard(card?: Record<string, unknown>): Record<string, unknown> {
|
function toKpiCard(
|
||||||
|
card: RiskCard | Record<string, unknown> | undefined,
|
||||||
|
fallback: { title: string; icon: string },
|
||||||
|
): Record<string, unknown> {
|
||||||
if (!card || typeof card !== 'object') return {}
|
if (!card || typeof card !== 'object') return {}
|
||||||
|
|
||||||
|
if ('title' in card || 'stats' in card) {
|
||||||
return { kpis: [card] }
|
return { kpis: [card] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const level = String(card.level ?? '').toLowerCase()
|
||||||
|
const score = typeof card.score === 'number' ? card.score : Number(card.score ?? 0)
|
||||||
|
const percentage = Number.isFinite(score) ? Math.round(score <= 1 ? score * 100 : score) : 0
|
||||||
|
|
||||||
|
const levelLabelMap: Record<string, string> = {
|
||||||
|
low: 'پایین',
|
||||||
|
medium: 'متوسط',
|
||||||
|
moderate: 'متوسط',
|
||||||
|
high: 'بالا'
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
low: 'success',
|
||||||
|
medium: 'warning',
|
||||||
|
moderate: 'warning',
|
||||||
|
high: 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = level ? levelLabelMap[level] ?? String(card.level) : percentage ? `${percentage}%` : '-'
|
||||||
|
|
||||||
|
return {
|
||||||
|
kpis: [
|
||||||
|
{
|
||||||
|
id: String(card.id ?? fallback.title),
|
||||||
|
title: fallback.title,
|
||||||
|
subtitle: 'پیش بینی هوشمند',
|
||||||
|
stats,
|
||||||
|
avatarColor: colorMap[level] ?? 'primary',
|
||||||
|
avatarIcon: fallback.icon,
|
||||||
|
chipText: `${percentage}%`,
|
||||||
|
chipColor: colorMap[level] ?? 'warning'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pestDetectionDomainService = {
|
export const pestDetectionDomainService = {
|
||||||
async getRiskSummary(farmUuid: string): Promise<PestRiskSummary> {
|
async getRiskSummary(farmUuid: string): Promise<PestRiskSummary> {
|
||||||
const res = await apiClient.get<ApiResponse<PestRiskSummary> | PestRiskSummary>(
|
let res: ApiResponse<Record<string, unknown>> | Record<string, unknown>
|
||||||
`${PREFIX}/risk-summary/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
|
||||||
|
try {
|
||||||
|
res = await apiClient.post<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
|
||||||
|
`${DETECTION_PREFIX}/risk-summary/`,
|
||||||
|
{ farm_uuid: farmUuid }
|
||||||
)
|
)
|
||||||
|
} catch (error) {
|
||||||
|
if (!isRouteMismatchError(error)) throw error
|
||||||
|
|
||||||
|
try {
|
||||||
|
res = await apiClient.get<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
|
||||||
|
`${DETECTION_PREFIX}/risk-summary/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
||||||
|
)
|
||||||
|
} catch (fallbackError) {
|
||||||
|
if (!isRouteMismatchError(fallbackError)) throw fallbackError
|
||||||
|
|
||||||
|
res = await apiClient.post<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
|
||||||
|
`${DISEASE_PREFIX}/risk-summary/`,
|
||||||
|
{ farm_uuid: farmUuid }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const data = extract(res)
|
const data = extract(res)
|
||||||
|
const diseaseRisk = (data?.diseaseRisk as Record<string, unknown> | undefined) ?? (data?.disease_risk as Record<string, unknown> | undefined)
|
||||||
|
const pestRisk = (data?.pestRisk as Record<string, unknown> | undefined) ?? (data?.pest_risk as Record<string, unknown> | undefined)
|
||||||
|
const drivers = (data?.drivers as Record<string, unknown> | undefined) ?? {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
disease_risk: toKpiCard(data?.disease_risk),
|
diseaseRisk: toKpiCard(diseaseRisk, { title: 'ریسک بیماری', icon: 'tabler-biohazard' }),
|
||||||
pest_risk: toKpiCard(data?.pest_risk)
|
pestRisk: toKpiCard(pestRisk, { title: 'ریسک آفات', icon: 'tabler-bug' }),
|
||||||
|
drivers
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { apiClient } from '../client'
|
|||||||
const PREFIX = '/api/soil'
|
const PREFIX = '/api/soil'
|
||||||
|
|
||||||
export interface SoilSummary {
|
export interface SoilSummary {
|
||||||
|
summaryKpis?: Record<string, unknown>
|
||||||
avg_soil_moisture?: Record<string, unknown>
|
avg_soil_moisture?: Record<string, unknown>
|
||||||
sensorRadarChart?: Record<string, unknown>
|
|
||||||
sensorComparisonChart?: Record<string, unknown>
|
|
||||||
anomalyDetectionCard?: Record<string, unknown>
|
anomalyDetectionCard?: Record<string, unknown>
|
||||||
soilMoistureHeatmap?: Record<string, unknown>
|
soilMoistureHeatmap?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
@@ -16,37 +15,112 @@ interface ApiResponse<T> {
|
|||||||
data: T
|
data: T
|
||||||
}
|
}
|
||||||
|
|
||||||
function extract<T>(res: ApiResponse<T> | T): T {
|
interface StatusResponse<T> {
|
||||||
|
status: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeatmapPoint = {
|
||||||
|
x: string
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeatmapSeries = {
|
||||||
|
name: string
|
||||||
|
data: HeatmapPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function extract<T>(res: ApiResponse<T> | StatusResponse<T> | T): T {
|
||||||
return res && typeof res === 'object' && 'data' in res ? (res as ApiResponse<T>).data : (res as T)
|
return res && typeof res === 'object' && 'data' in res ? (res as ApiResponse<T>).data : (res as T)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNumericValue(value: unknown): number {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||||
|
|
||||||
|
const parsed = Number(value)
|
||||||
|
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeatmapSeries(data: Record<string, unknown>): HeatmapSeries[] {
|
||||||
|
const legacySeries = Array.isArray(data.series) ? (data.series as HeatmapSeries[]) : []
|
||||||
|
|
||||||
|
if (legacySeries.length > 0) return legacySeries
|
||||||
|
|
||||||
|
const gridCells = Array.isArray(data.grid_cells) ? (data.grid_cells as Array<Record<string, unknown>>) : []
|
||||||
|
|
||||||
|
if (gridCells.length === 0) return []
|
||||||
|
|
||||||
|
const grouped = new Map<string, HeatmapPoint[]>()
|
||||||
|
|
||||||
|
gridCells.forEach((cell, index) => {
|
||||||
|
const rowLabel = String(
|
||||||
|
cell.zone_name ?? cell.zone ?? cell.row_label ?? cell.row ?? cell.y_label ?? `ردیف ${index + 1}`
|
||||||
|
)
|
||||||
|
const columnLabel = String(
|
||||||
|
cell.time_label ?? cell.col_label ?? cell.column_label ?? cell.x_label ?? cell.col ?? cell.x ?? `${index + 1}`
|
||||||
|
)
|
||||||
|
const rawValue = cell.value ?? cell.moisture ?? cell.moisture_percent ?? cell.intensity ?? cell.y
|
||||||
|
const value = getNumericValue(rawValue)
|
||||||
|
const current = grouped.get(rowLabel) ?? []
|
||||||
|
|
||||||
|
current.push({ x: columnLabel, y: value })
|
||||||
|
grouped.set(rowLabel, current)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(grouped.entries()).map(([name, points]) => ({
|
||||||
|
name,
|
||||||
|
data: points
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHealthScoreKpi(summary: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const healthScore = getNumericValue(summary.healthScore)
|
||||||
|
const profileSource = String(summary.profileSource ?? 'مرجع خاک')
|
||||||
|
const healthLanguage =
|
||||||
|
summary.healthLanguage && typeof summary.healthLanguage === 'object'
|
||||||
|
? (summary.healthLanguage as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
const chipText = String(healthLanguage.short_chip_text ?? summary.avgSoilMoistureStatus ?? '-')
|
||||||
|
|
||||||
|
let chipColor: 'success' | 'warning' | 'error' = 'success'
|
||||||
|
|
||||||
|
if (healthScore < 45) chipColor = 'error'
|
||||||
|
else if (healthScore < 70) chipColor = 'warning'
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'soil_health_score',
|
||||||
|
title: 'سلامت خاک',
|
||||||
|
subtitle: profileSource,
|
||||||
|
stats: `${Math.round(healthScore)} / 100`,
|
||||||
|
avatarColor: chipColor === 'error' ? 'error' : chipColor === 'warning' ? 'warning' : 'success',
|
||||||
|
avatarIcon: 'tabler-activity-heartbeat',
|
||||||
|
chipText,
|
||||||
|
chipColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const soilService = {
|
export const soilService = {
|
||||||
async getSummary(farmUuid: string): Promise<SoilSummary> {
|
async getSummary(farmUuid: string): Promise<SoilSummary> {
|
||||||
const res = await apiClient.get<ApiResponse<SoilSummary> | SoilSummary>(
|
const res = await apiClient.get<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
|
||||||
`${PREFIX}/summary/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
`${PREFIX}/summary/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
||||||
)
|
)
|
||||||
return extract(res)
|
const data = extract(res)
|
||||||
|
|
||||||
|
return {
|
||||||
|
summaryKpis: { kpis: [buildHealthScoreKpi(data)] }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAvgMoisture(farmUuid: string): Promise<Record<string, unknown>> {
|
async getAvgMoisture(farmUuid: string): Promise<Record<string, unknown>> {
|
||||||
const res = await apiClient.get<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
|
const res = await apiClient.get<StatusResponse<Record<string, unknown>> | Record<string, unknown>>(
|
||||||
`${PREFIX}/avg-moisture/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
`${PREFIX}/avg-moisture/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
||||||
)
|
)
|
||||||
return extract(res)
|
const data = extract(res)
|
||||||
},
|
|
||||||
|
|
||||||
async getSensorRadarChart(farmUuid: string): Promise<Record<string, unknown>> {
|
return {
|
||||||
const res = await apiClient.get<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
|
kpis: [data]
|
||||||
`${PREFIX}/sensor-radar-chart/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
}
|
||||||
)
|
|
||||||
return extract(res)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getSensorComparisonChart(farmUuid: string): Promise<Record<string, unknown>> {
|
|
||||||
const res = await apiClient.get<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
|
|
||||||
`${PREFIX}/sensor-comparison-chart/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
|
||||||
)
|
|
||||||
return extract(res)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAnomalies(farmUuid: string): Promise<Record<string, unknown>> {
|
async getAnomalies(farmUuid: string): Promise<Record<string, unknown>> {
|
||||||
@@ -60,6 +134,11 @@ export const soilService = {
|
|||||||
const res = await apiClient.get<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
|
const res = await apiClient.get<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
|
||||||
`${PREFIX}/moisture-heatmap/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
`${PREFIX}/moisture-heatmap/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
||||||
)
|
)
|
||||||
return extract(res)
|
const data = extract(res)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
series: normalizeHeatmapSeries(data)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { apiClient } from '../client'
|
import { apiClient } from '../client'
|
||||||
|
|
||||||
const PREFIX = '/api/water'
|
const WATER_PREFIX = '/api/water'
|
||||||
|
const WEATHER_PREFIX = '/api/weather'
|
||||||
|
|
||||||
export interface WaterSummary {
|
export interface WaterSummary {
|
||||||
farmWeatherCard?: Record<string, unknown>
|
farmWeatherCard?: Record<string, unknown>
|
||||||
waterNeedPrediction?: Record<string, unknown>
|
waterNeedPrediction?: Record<string, unknown>
|
||||||
water_stress_index?: Record<string, unknown>
|
waterStressIndex?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
@@ -14,36 +15,66 @@ interface ApiResponse<T> {
|
|||||||
data: T
|
data: T
|
||||||
}
|
}
|
||||||
|
|
||||||
function extract<T>(res: ApiResponse<T> | T): T {
|
interface StatusResponse<T> {
|
||||||
|
status: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
function extract<T>(res: ApiResponse<T> | StatusResponse<T> | T): T {
|
||||||
return res && typeof res === 'object' && 'data' in res ? (res as ApiResponse<T>).data : (res as T)
|
return res && typeof res === 'object' && 'data' in res ? (res as ApiResponse<T>).data : (res as T)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withFarmUuid(endpoint: string, farmUuid?: string): string {
|
||||||
|
if (!farmUuid) return endpoint
|
||||||
|
|
||||||
|
const separator = endpoint.includes('?') ? '&' : '?'
|
||||||
|
|
||||||
|
return `${endpoint}${separator}farm_uuid=${encodeURIComponent(farmUuid)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSummary(data: Record<string, unknown>): WaterSummary {
|
||||||
|
return {
|
||||||
|
farmWeatherCard: (data.farmWeatherCard as Record<string, unknown> | undefined) ?? {},
|
||||||
|
waterNeedPrediction: (data.waterNeedPrediction as Record<string, unknown> | undefined) ?? {},
|
||||||
|
waterStressIndex:
|
||||||
|
(data.waterStressIndex as Record<string, unknown> | undefined) ??
|
||||||
|
(data.water_stress_index as Record<string, unknown> | undefined) ??
|
||||||
|
{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const waterService = {
|
export const waterService = {
|
||||||
async getSummary(farmUuid: string): Promise<WaterSummary> {
|
async getSummary(farmUuid?: string): Promise<WaterSummary> {
|
||||||
const res = await apiClient.get<ApiResponse<WaterSummary> | WaterSummary>(
|
const res = await apiClient.get<StatusResponse<Record<string, unknown>> | Record<string, unknown>>(
|
||||||
`${PREFIX}/summary/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
withFarmUuid(`${WATER_PREFIX}/summary/`, farmUuid)
|
||||||
|
)
|
||||||
|
return normalizeSummary(extract(res))
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCard(farmUuid?: string): Promise<Record<string, unknown>> {
|
||||||
|
const res = await apiClient.get<StatusResponse<Record<string, unknown>> | Record<string, unknown>>(
|
||||||
|
withFarmUuid(`${WATER_PREFIX}/card/`, farmUuid)
|
||||||
)
|
)
|
||||||
return extract(res)
|
return extract(res)
|
||||||
},
|
},
|
||||||
|
|
||||||
async getCard(farmUuid: string): Promise<Record<string, unknown>> {
|
async getNeedPrediction(farmUuid?: string): Promise<Record<string, unknown>> {
|
||||||
const res = await apiClient.get<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
|
const res = await apiClient.get<StatusResponse<Record<string, unknown>> | Record<string, unknown>>(
|
||||||
`${PREFIX}/card/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
withFarmUuid(`${WATER_PREFIX}/need-prediction/`, farmUuid)
|
||||||
)
|
)
|
||||||
return extract(res)
|
return extract(res)
|
||||||
},
|
},
|
||||||
|
|
||||||
async getNeedPrediction(farmUuid: string): Promise<Record<string, unknown>> {
|
async getWeatherFarmCard(farmUuid: string): Promise<Record<string, unknown>> {
|
||||||
const res = await apiClient.get<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
|
try {
|
||||||
`${PREFIX}/need-prediction/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
const res = await apiClient.post<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
|
||||||
|
`${WEATHER_PREFIX}/farm-card/`,
|
||||||
|
{ farm_uuid: farmUuid }
|
||||||
)
|
)
|
||||||
return extract(res)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getStressIndex(farmUuid: string): Promise<Record<string, unknown>> {
|
|
||||||
const res = await apiClient.get<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
|
|
||||||
`${PREFIX}/stress-index/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
|
||||||
)
|
|
||||||
return extract(res)
|
return extract(res)
|
||||||
|
} catch {
|
||||||
|
return this.getCard(farmUuid)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,14 @@ import { useTranslations } from 'next-intl'
|
|||||||
import Card from '@mui/material/Card'
|
import Card from '@mui/material/Card'
|
||||||
import CardHeader from '@mui/material/CardHeader'
|
import CardHeader from '@mui/material/CardHeader'
|
||||||
import CardContent from '@mui/material/CardContent'
|
import CardContent from '@mui/material/CardContent'
|
||||||
import Typography from '@mui/material/Typography'
|
|
||||||
import Grid from '@mui/material/Grid2'
|
import Grid from '@mui/material/Grid2'
|
||||||
import { useTheme } from '@mui/material/styles'
|
import { useTheme } from '@mui/material/styles'
|
||||||
|
|
||||||
// Third-party Imports
|
|
||||||
import classnames from 'classnames'
|
|
||||||
import type { ApexOptions } from 'apexcharts'
|
import type { ApexOptions } from 'apexcharts'
|
||||||
|
|
||||||
// Component Imports
|
// Component Imports
|
||||||
import OptionMenu from '@core/components/option-menu'
|
import OptionMenu from '@core/components/option-menu'
|
||||||
import CustomAvatar from '@core/components/mui/Avatar'
|
import CardStatsVertical from '@/components/card-statistics/Vertical'
|
||||||
|
|
||||||
// Styled Component Imports
|
// Styled Component Imports
|
||||||
const AppReactApexCharts = dynamic(() => import('@/libs/styles/AppReactApexCharts'))
|
const AppReactApexCharts = dynamic(() => import('@/libs/styles/AppReactApexCharts'))
|
||||||
@@ -87,20 +84,18 @@ const EconomicOverview = ({ data }: EconomicOverviewProps) => {
|
|||||||
<Grid container spacing={4}>
|
<Grid container spacing={4}>
|
||||||
{economicData.map((item, index) => (
|
{economicData.map((item, index) => (
|
||||||
<Grid size={{ xs: 12, sm: 6 }} key={index}>
|
<Grid size={{ xs: 12, sm: 6 }} key={index}>
|
||||||
<div className='flex items-center gap-4'>
|
<CardStatsVertical
|
||||||
<CustomAvatar skin='light' variant='rounded' color={item.avatarColor} size={40}>
|
title={item.title}
|
||||||
<i className={classnames(item.avatarIcon, 'text-[22px]')} />
|
subtitle={item.subtitle}
|
||||||
</CustomAvatar>
|
stats={item.value}
|
||||||
<div>
|
avatarIcon={item.avatarIcon}
|
||||||
<Typography variant='h6'>{item.value}</Typography>
|
avatarColor={item.avatarColor}
|
||||||
<Typography variant='body2' color='text.secondary'>
|
avatarSkin='light'
|
||||||
{item.title}
|
avatarSize={40}
|
||||||
</Typography>
|
chipText=''
|
||||||
<Typography variant='caption' color='text.disabled'>
|
chipColor='primary'
|
||||||
{item.subtitle}
|
chipVariant='tonal'
|
||||||
</Typography>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress'
|
||||||
|
import Grid from '@mui/material/Grid2'
|
||||||
|
|
||||||
|
import { useFarmHub } from '@/hooks/useFarmHub'
|
||||||
|
import { economicOverviewService } from '@/libs/api/services/economicOverviewService'
|
||||||
|
import EconomicOverview from '@views/dashboards/farm/EconomicOverview'
|
||||||
|
|
||||||
|
const cardRowSx = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: 380,
|
||||||
|
'& > *': { flex: 1, minHeight: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const EconomicOverviewPageWrapper = () => {
|
||||||
|
const { farmHub } = useFarmHub()
|
||||||
|
const farmUuid = farmHub?.farm_uuid
|
||||||
|
const [data, setData] = useState<Record<string, unknown>>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!farmUuid) {
|
||||||
|
setData({})
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
economicOverviewService
|
||||||
|
.getSummary(farmUuid)
|
||||||
|
.then(summary => setData((summary.economicOverview as Record<string, unknown>) ?? {}))
|
||||||
|
.catch(() => setData({}))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [farmUuid])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box display='flex' justifyContent='center' alignItems='center' minHeight={200}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box position='relative'>
|
||||||
|
<Grid container spacing={6}>
|
||||||
|
<Grid size={12} sx={cardRowSx}>
|
||||||
|
<EconomicOverview data={data} />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EconomicOverviewPageWrapper
|
||||||
@@ -1,60 +1,70 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
// MUI Imports
|
// MUI Imports
|
||||||
import Grid from '@mui/material/Grid2'
|
import Grid from "@mui/material/Grid2";
|
||||||
|
|
||||||
// Component Imports
|
// Component Imports
|
||||||
import CardStatsVertical from '@components/card-statistics/Vertical'
|
import CardStatsVertical from "@components/card-statistics/Vertical";
|
||||||
|
|
||||||
type KpiItem = {
|
type KpiItem = {
|
||||||
id: string
|
id: string;
|
||||||
title: string
|
title: string;
|
||||||
subtitle: string
|
subtitle: string;
|
||||||
stats: string
|
stats: string;
|
||||||
avatarColor?: string
|
avatarColor?: string;
|
||||||
avatarIcon?: string
|
avatarIcon?: string;
|
||||||
chipText?: string
|
chipText?: string;
|
||||||
chipColor?: string
|
chipColor?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
interface FarmOverviewKPIsProps {
|
interface FarmOverviewKPIsProps {
|
||||||
data?: Record<string, unknown>
|
data?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FarmOverviewKPIs = ({ data }: FarmOverviewKPIsProps) => {
|
const FarmOverviewKPIs = ({ data }: FarmOverviewKPIsProps) => {
|
||||||
const kpis = (data?.kpis as KpiItem[] | undefined) ?? []
|
const kpis = (data?.kpis as KpiItem[] | undefined) ?? [];
|
||||||
if (kpis.length === 0) return null
|
if (kpis.length === 0) return null;
|
||||||
|
|
||||||
const getGridSize = (count) => {
|
const getGridSize = (count: number) => {
|
||||||
if (count === 1) return { xs: 12 };
|
if (count === 1) return { xs: 12 };
|
||||||
if (count === 2) return { xs: 12, md: 6 };
|
if (count === 2) return { xs: 12, md: 6 };
|
||||||
if (count === 3) return { xs: 12, sm: 6, md: 4 };
|
if (count === 3) return { xs: 12, sm: 6, md: 4 };
|
||||||
if (count === 4) return { xs: 12, sm: 6, md: 3 };
|
if (count === 4) return { xs: 12, sm: 6, md: 3 };
|
||||||
|
|
||||||
return { xs: 12, sm: 6, md: 4, lg: 2 };
|
return { xs: 12, sm: 6, md: 4, lg: 2 };
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{kpis.map((kpi) => (
|
{kpis.map((kpi) => (
|
||||||
<Grid
|
<Grid
|
||||||
key={kpi.id}
|
key={kpi.id}
|
||||||
size={getGridSize(kpis.length)}
|
size={getGridSize(kpis.length)}
|
||||||
sx={{ display: "flex" }}
|
sx={{ display: "flex", width: "100%" }}
|
||||||
> <CardStatsVertical
|
>
|
||||||
|
<CardStatsVertical
|
||||||
title={kpi.title}
|
title={kpi.title}
|
||||||
subtitle={kpi.subtitle}
|
subtitle={kpi.subtitle}
|
||||||
stats={kpi.stats}
|
stats={kpi.stats}
|
||||||
avatarColor={(kpi.avatarColor as 'success' | 'info' | 'primary' | 'secondary' | 'warning') ?? 'primary'}
|
avatarColor={
|
||||||
avatarIcon={kpi.avatarIcon ?? 'tabler-chart-bar'}
|
(kpi.avatarColor as
|
||||||
avatarSkin='light'
|
| "success"
|
||||||
|
| "info"
|
||||||
|
| "primary"
|
||||||
|
| "secondary"
|
||||||
|
| "warning") ?? "primary"
|
||||||
|
}
|
||||||
|
avatarIcon={kpi.avatarIcon ?? "tabler-chart-bar"}
|
||||||
|
avatarSkin="light"
|
||||||
avatarSize={44}
|
avatarSize={44}
|
||||||
chipText={kpi.chipText ?? ''}
|
chipText={kpi.chipText ?? ""}
|
||||||
chipColor={(kpi.chipColor as 'success' | 'warning') ?? 'success'}
|
chipColor={(kpi.chipColor as "success" | "warning") ?? "success"}
|
||||||
chipVariant='tonal'
|
chipVariant="tonal"
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default FarmOverviewKPIs
|
export default FarmOverviewKPIs;
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Card from "@mui/material/Card";
|
||||||
|
import CardContent from "@mui/material/CardContent";
|
||||||
|
import CardHeader from "@mui/material/CardHeader";
|
||||||
|
import Chip from "@mui/material/Chip";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
|
||||||
|
import OptionMenu from "@core/components/option-menu";
|
||||||
|
|
||||||
|
type HarvestStep = {
|
||||||
|
title: string;
|
||||||
|
note: string;
|
||||||
|
status: string;
|
||||||
|
statusColor: "primary" | "success" | "info" | "warning";
|
||||||
|
};
|
||||||
|
|
||||||
|
type OutputItem = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HarvestOperationsCardProps {
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HarvestOperationsCard = ({ data }: HarvestOperationsCardProps) => {
|
||||||
|
const steps = (data?.steps as HarvestStep[] | undefined) ?? [];
|
||||||
|
const outputs = (data?.outputs as OutputItem[] | undefined) ?? [];
|
||||||
|
const summary = (data?.summary as string | undefined) ?? "";
|
||||||
|
|
||||||
|
if (steps.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ height: "100%" }}>
|
||||||
|
<CardHeader
|
||||||
|
title="برنامه عملیات برداشت"
|
||||||
|
subheader="توالی اقداماتی که بیشترین اثر را روی کیفیت و فروش محصول دارند"
|
||||||
|
action={
|
||||||
|
<OptionMenu options={["تقویم عملیات", "چاپ برنامه", "اشتراک"]} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent className="flex flex-col gap-5">
|
||||||
|
{summary ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 4,
|
||||||
|
p: 4,
|
||||||
|
border:
|
||||||
|
"1px solid rgba(var(--mui-palette-primary-mainChannel) / 0.18)",
|
||||||
|
backgroundColor:
|
||||||
|
"rgba(var(--mui-palette-primary-mainChannel) / 0.08)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
className="mbe-1"
|
||||||
|
>
|
||||||
|
جمع بندی
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ lineHeight: 1.9 }}>{summary}</Typography>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div key={step.title} className="flex items-start gap-4">
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: "var(--mui-palette-action-hover)",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: "1px solid var(--mui-palette-divider)",
|
||||||
|
p: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3 mbe-2">
|
||||||
|
<Typography className="font-medium">{step.title}</Typography>
|
||||||
|
<Chip
|
||||||
|
label={step.status}
|
||||||
|
color={step.statusColor}
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ lineHeight: 1.8 }}
|
||||||
|
>
|
||||||
|
{step.note}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{outputs.length > 0 ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||||
|
gap: 3,
|
||||||
|
mt: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{outputs.map((output) => (
|
||||||
|
<Box
|
||||||
|
key={output.label}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
border: "1px solid var(--mui-palette-divider)",
|
||||||
|
p: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{output.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography className="font-medium mt-1">
|
||||||
|
{output.value}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HarvestOperationsCard;
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Card from "@mui/material/Card";
|
||||||
|
import CardContent from "@mui/material/CardContent";
|
||||||
|
import CardHeader from "@mui/material/CardHeader";
|
||||||
|
import Chip from "@mui/material/Chip";
|
||||||
|
import LinearProgress from "@mui/material/LinearProgress";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
|
||||||
|
import OptionMenu from "@core/components/option-menu";
|
||||||
|
|
||||||
|
type ReadinessBlock = {
|
||||||
|
name: string;
|
||||||
|
cultivar: string;
|
||||||
|
readiness: number;
|
||||||
|
harvestDate: string;
|
||||||
|
expectedYield: string;
|
||||||
|
moisture: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HarvestReadinessZonesCardProps {
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HarvestReadinessZonesCard = ({
|
||||||
|
data,
|
||||||
|
}: HarvestReadinessZonesCardProps) => {
|
||||||
|
const averageReadiness = (data?.averageReadiness as string | undefined) ?? "";
|
||||||
|
const blocks = (data?.blocks as ReadinessBlock[] | undefined) ?? [];
|
||||||
|
|
||||||
|
if (blocks.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ height: "100%" }}>
|
||||||
|
<CardHeader
|
||||||
|
title="آمادگی قطعات برای برداشت"
|
||||||
|
subheader="مقایسه سریع قطعات بر اساس رسیدگی، رطوبت و خروجی پیش بینی شده"
|
||||||
|
action={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{averageReadiness ? (
|
||||||
|
<Chip
|
||||||
|
label={`میانگین ${averageReadiness}`}
|
||||||
|
color="success"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<OptionMenu options={["نمایش نقشه", "مرتب سازی", "دانلود"]} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
{blocks.map((block) => (
|
||||||
|
<Box
|
||||||
|
key={block.name}
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: "1px solid var(--mui-palette-divider)",
|
||||||
|
backgroundColor: "var(--mui-palette-action-hover)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3 mbe-3">
|
||||||
|
<div>
|
||||||
|
<Typography className="font-medium">{block.name}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
رقم {block.cultivar}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<Chip
|
||||||
|
label={block.harvestDate}
|
||||||
|
color={
|
||||||
|
block.readiness >= 85
|
||||||
|
? "success"
|
||||||
|
: block.readiness >= 70
|
||||||
|
? "warning"
|
||||||
|
: "info"
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-4 mbe-2">
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
سطح آمادگی
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" className="font-medium">
|
||||||
|
{block.readiness}%
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={block.readiness}
|
||||||
|
color={
|
||||||
|
block.readiness >= 85
|
||||||
|
? "success"
|
||||||
|
: block.readiness >= 70
|
||||||
|
? "warning"
|
||||||
|
: "info"
|
||||||
|
}
|
||||||
|
sx={{ height: 8, borderRadius: 999 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||||
|
gap: 3,
|
||||||
|
mt: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
border: "1px solid var(--mui-palette-divider)",
|
||||||
|
p: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
عملکرد پیش بینی شده
|
||||||
|
</Typography>
|
||||||
|
<Typography className="font-medium mt-1">
|
||||||
|
{block.expectedYield}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
border: "1px solid var(--mui-palette-divider)",
|
||||||
|
p: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
رطوبت دانه
|
||||||
|
</Typography>
|
||||||
|
<Typography className="font-medium mt-1">
|
||||||
|
{block.moisture}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HarvestReadinessZonesCard;
|
||||||
@@ -6,7 +6,6 @@ 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 CircularProgress from '@mui/material/CircularProgress'
|
||||||
import Typography from '@mui/material/Typography'
|
|
||||||
|
|
||||||
import FarmOverviewKPIs from '@views/dashboards/farm/FarmOverviewKPIs'
|
import FarmOverviewKPIs from '@views/dashboards/farm/FarmOverviewKPIs'
|
||||||
|
|
||||||
@@ -52,18 +51,18 @@ const PestRiskPageWrapper = () => {
|
|||||||
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'>
|
||||||
{data.disease_risk && (
|
{data.diseaseRisk && (
|
||||||
<Grid size={{ xs: 12, md: 6 }} sx={sectionSx}>
|
<Grid size={{ xs: 12, md: 6 }} sx={sectionSx}>
|
||||||
<Grid container spacing={6} alignItems='stretch'>
|
<Grid container spacing={6} alignItems='stretch'>
|
||||||
<FarmOverviewKPIs data={data.disease_risk as Record<string, unknown>} />
|
<FarmOverviewKPIs data={data.diseaseRisk as Record<string, unknown>} />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.pest_risk && (
|
{data.pestRisk && (
|
||||||
<Grid size={{ xs: 12, md: 6 }} sx={sectionSx}>
|
<Grid size={{ xs: 12, md: 6 }} sx={sectionSx}>
|
||||||
<Grid container spacing={6} alignItems='stretch'>
|
<Grid container spacing={6} alignItems='stretch'>
|
||||||
<FarmOverviewKPIs data={data.pest_risk as Record<string, unknown>} />
|
<FarmOverviewKPIs data={data.pestRisk as Record<string, unknown>} />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,22 +1,63 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import Grid from '@mui/material/Grid2'
|
import Grid from "@mui/material/Grid2";
|
||||||
|
|
||||||
import YieldHarvestPageWrapper from '@views/dashboards/farm/YieldHarvestPageWrapper'
|
import YieldHarvestPageWrapper from "@views/dashboards/farm/YieldHarvestPageWrapper";
|
||||||
import PlantSimulator from '@views/dashboards/farm/plantSimulator/PlantSimulator'
|
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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
const PlantProductionPage = () => {
|
const PlantProductionPage = () => {
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={6} alignItems='stretch'>
|
<Grid container spacing={6} alignItems="stretch">
|
||||||
<Grid size={{ xs: 12, xl: 12 }} sx={{ display: 'flex' }}>
|
<Grid size={{ xs: 12 }} sx={{ display: "flex", width: "100%" }}>
|
||||||
|
<YieldSeasonHighlightsCard data={mockSeasonHighlightsData} />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12 }} sx={{ display: "flex", width: "100%" }}>
|
||||||
<PlantSimulator />
|
<PlantSimulator />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid size={{ xs: 12, xl: 12 }} sx={{ display: 'flex' }}>
|
<Grid size={{ xs: 12 }} sx={{ display: "flex", width: "100%" }}>
|
||||||
<YieldHarvestPageWrapper />
|
<YieldHarvestPageWrapper />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default PlantProductionPage
|
export default PlantProductionPage;
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ 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 CircularProgress from '@mui/material/CircularProgress'
|
||||||
|
|
||||||
|
import FarmOverviewKPIs from '@views/dashboards/farm/FarmOverviewKPIs'
|
||||||
import SoilMoistureHeatmap from '@views/dashboards/farm/SoilMoistureHeatmap'
|
import SoilMoistureHeatmap from '@views/dashboards/farm/SoilMoistureHeatmap'
|
||||||
import SensorComparisonChart from '@views/dashboards/farm/SensorComparisonChart'
|
|
||||||
import SensorRadarChart from '@views/dashboards/farm/SensorRadarChart'
|
|
||||||
import AnomalyDetectionCard from '@views/dashboards/farm/AnomalyDetectionCard'
|
import AnomalyDetectionCard from '@views/dashboards/farm/AnomalyDetectionCard'
|
||||||
|
|
||||||
import { soilService } from '@/libs/api/services/soilService'
|
import { soilService } from '@/libs/api/services/soilService'
|
||||||
@@ -36,9 +35,24 @@ const SoilDataDashboardWrapper = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
soilService
|
Promise.all([
|
||||||
.getSummary(farmUuid)
|
soilService.getSummary(farmUuid),
|
||||||
.then(summary => setData(summary ?? {}))
|
soilService.getAvgMoisture(farmUuid),
|
||||||
|
soilService.getAnomalies(farmUuid),
|
||||||
|
soilService.getMoistureHeatmap(farmUuid)
|
||||||
|
])
|
||||||
|
.then(([summary, avgMoisture, anomalies, heatmap]) =>
|
||||||
|
setData({
|
||||||
|
summaryKpis: {
|
||||||
|
kpis: [
|
||||||
|
...(((summary.summaryKpis?.kpis as Record<string, unknown>[]) ?? [])),
|
||||||
|
...(((avgMoisture.kpis as Record<string, unknown>[]) ?? []))
|
||||||
|
]
|
||||||
|
},
|
||||||
|
anomalyDetectionCard: anomalies,
|
||||||
|
soilMoistureHeatmap: heatmap
|
||||||
|
})
|
||||||
|
)
|
||||||
.catch(() => setData({}))
|
.catch(() => setData({}))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [farmUuid])
|
}, [farmUuid])
|
||||||
@@ -54,19 +68,16 @@ const SoilDataDashboardWrapper = () => {
|
|||||||
return (
|
return (
|
||||||
<Box position='relative'>
|
<Box position='relative'>
|
||||||
<Grid container spacing={6}>
|
<Grid container spacing={6}>
|
||||||
|
<Grid size={12}>
|
||||||
|
<Grid container spacing={6}>
|
||||||
|
<FarmOverviewKPIs data={data.summaryKpis as Record<string, unknown>} />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
<Grid size={12} sx={cardRowSx}>
|
<Grid size={12} sx={cardRowSx}>
|
||||||
<SoilMoistureHeatmap data={data.soilMoistureHeatmap as Record<string, unknown>} />
|
<SoilMoistureHeatmap data={data.soilMoistureHeatmap as Record<string, unknown>} />
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={12} container spacing={6} sx={{ display: 'flex', alignItems: 'stretch' }}>
|
|
||||||
<Grid size={{ xs: 12, lg: 7 }} sx={cardRowSx}>
|
|
||||||
<SensorRadarChart data={data.sensorRadarChart as Record<string, unknown>} />
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, lg: 5 }} sx={cardRowSx}>
|
|
||||||
<AnomalyDetectionCard data={data.anomalyDetectionCard as Record<string, unknown>} />
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={12} sx={cardRowSx}>
|
<Grid size={12} sx={cardRowSx}>
|
||||||
<SensorComparisonChart data={data.sensorComparisonChart as Record<string, unknown>} />
|
<AnomalyDetectionCard data={data.anomalyDetectionCard as Record<string, unknown>} />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Card from '@mui/material/Card'
|
||||||
|
import CardContent from '@mui/material/CardContent'
|
||||||
|
import CardHeader from '@mui/material/CardHeader'
|
||||||
|
import Chip from '@mui/material/Chip'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
|
||||||
|
type CropProfilePayload = {
|
||||||
|
kc_initial?: number
|
||||||
|
kc_mid?: number
|
||||||
|
kc_end?: number
|
||||||
|
current_stage?: string
|
||||||
|
growth_stage_duration?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WaterCropProfileCardProps {
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatValue = (value: unknown) => {
|
||||||
|
if (typeof value === 'number') return value.toFixed(2)
|
||||||
|
if (value == null || value === '') return '-'
|
||||||
|
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const WaterCropProfileCard = ({ data }: WaterCropProfileCardProps) => {
|
||||||
|
const cropProfile =
|
||||||
|
data?.cropProfile && typeof data.cropProfile === 'object' ? (data.cropProfile as CropProfilePayload) : null
|
||||||
|
|
||||||
|
if (!cropProfile) return null
|
||||||
|
|
||||||
|
const duration =
|
||||||
|
cropProfile.growth_stage_duration && typeof cropProfile.growth_stage_duration === 'object'
|
||||||
|
? (cropProfile.growth_stage_duration as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const stages = [
|
||||||
|
{ label: 'ابتدایی', value: duration.initial, tone: 'primary' as const },
|
||||||
|
{ label: 'میانی', value: duration.mid, tone: 'success' as const },
|
||||||
|
{ label: 'پایانی', value: duration.late, tone: 'warning' as const }
|
||||||
|
]
|
||||||
|
|
||||||
|
const kcItems = [
|
||||||
|
{ label: 'Kc شروع', value: cropProfile.kc_initial },
|
||||||
|
{ label: 'Kc میانی', value: cropProfile.kc_mid },
|
||||||
|
{ label: 'Kc پایان', value: cropProfile.kc_end }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ height: '100%' }}>
|
||||||
|
<CardHeader
|
||||||
|
title='پروفایل گیاه'
|
||||||
|
subheader='پارامترهای مورد استفاده در برآورد نیاز آبی'
|
||||||
|
action={
|
||||||
|
cropProfile.current_stage ? (
|
||||||
|
<Chip size='small' color='primary' variant='tonal' label={`مرحله ${cropProfile.current_stage}`} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent className='flex flex-col gap-4'>
|
||||||
|
<div className='grid grid-cols-1 gap-3 sm:grid-cols-3'>
|
||||||
|
{kcItems.map(item => (
|
||||||
|
<div key={item.label} className='rounded-lg bg-[var(--mui-palette-action-hover)] p-4'>
|
||||||
|
<Typography variant='body2' color='text.secondary'>
|
||||||
|
{item.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant='h5'>{formatValue(item.value)}</Typography>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
{stages.map(stage => (
|
||||||
|
<Chip
|
||||||
|
key={stage.label}
|
||||||
|
color={stage.tone}
|
||||||
|
variant='tonal'
|
||||||
|
label={`${stage.label}: ${formatValue(stage.value)} روز`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WaterCropProfileCard
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Card from '@mui/material/Card'
|
||||||
|
import CardContent from '@mui/material/CardContent'
|
||||||
|
import CardHeader from '@mui/material/CardHeader'
|
||||||
|
import Chip from '@mui/material/Chip'
|
||||||
|
import Divider from '@mui/material/Divider'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
|
||||||
|
type DailyBreakdownItem = {
|
||||||
|
forecast_date?: string
|
||||||
|
net_irrigation_mm?: number
|
||||||
|
gross_irrigation_mm?: number
|
||||||
|
effective_rainfall_mm?: number
|
||||||
|
irrigation_timing?: string
|
||||||
|
kc?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WaterDailyBreakdownCardProps {
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatAmount = (value: unknown) => {
|
||||||
|
if (typeof value === 'number') return value.toFixed(2)
|
||||||
|
|
||||||
|
const parsed = Number(value)
|
||||||
|
|
||||||
|
return Number.isFinite(parsed) ? parsed.toFixed(2) : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const WaterDailyBreakdownCard = ({ data }: WaterDailyBreakdownCardProps) => {
|
||||||
|
const dailyBreakdown = Array.isArray(data?.dailyBreakdown) ? (data?.dailyBreakdown as DailyBreakdownItem[]) : []
|
||||||
|
|
||||||
|
if (dailyBreakdown.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader title='برنامه 3 روزه آبیاری' subheader='جزییات روزانه برای اجرای عملیات' />
|
||||||
|
<CardContent className='flex flex-col gap-4'>
|
||||||
|
{dailyBreakdown.map((item, index) => {
|
||||||
|
const irrigationAmount = Number(item.gross_irrigation_mm ?? item.net_irrigation_mm ?? 0)
|
||||||
|
const tone = irrigationAmount >= 4 ? 'error' : irrigationAmount >= 2 ? 'warning' : 'success'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${item.forecast_date ?? index}`} className='flex flex-col gap-3'>
|
||||||
|
<div className='flex flex-col gap-3 rounded-xl border border-[var(--mui-palette-divider)] p-4 md:flex-row md:items-start md:justify-between'>
|
||||||
|
<div className='flex flex-col gap-1'>
|
||||||
|
<Typography variant='h6'>{item.forecast_date ?? `روز ${index + 1}`}</Typography>
|
||||||
|
<Typography variant='body2' color='text.secondary'>
|
||||||
|
زمان پیشنهادی: {item.irrigation_timing ?? '-'}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
|
<Chip color={tone} variant='tonal' label={`${formatAmount(irrigationAmount)} mm`} />
|
||||||
|
<Chip color='info' variant='tonal' label={`بارش موثر ${formatAmount(item.effective_rainfall_mm)} mm`} />
|
||||||
|
<Chip color='secondary' variant='outlined' label={`Kc ${formatAmount(item.kc)}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-1 gap-3 sm:grid-cols-3'>
|
||||||
|
<div className='rounded-lg bg-[var(--mui-palette-action-hover)] p-3'>
|
||||||
|
<Typography variant='caption' color='text.secondary'>
|
||||||
|
آبیاری خالص
|
||||||
|
</Typography>
|
||||||
|
<Typography variant='h6'>{formatAmount(item.net_irrigation_mm)} mm</Typography>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-lg bg-[var(--mui-palette-action-hover)] p-3'>
|
||||||
|
<Typography variant='caption' color='text.secondary'>
|
||||||
|
آبیاری ناخالص
|
||||||
|
</Typography>
|
||||||
|
<Typography variant='h6'>{formatAmount(item.gross_irrigation_mm)} mm</Typography>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-lg bg-[var(--mui-palette-action-hover)] p-3'>
|
||||||
|
<Typography variant='caption' color='text.secondary'>
|
||||||
|
بارش موثر
|
||||||
|
</Typography>
|
||||||
|
<Typography variant='h6'>{formatAmount(item.effective_rainfall_mm)} mm</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{index < dailyBreakdown.length - 1 ? <Divider /> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WaterDailyBreakdownCard
|
||||||
@@ -13,6 +13,8 @@ import CircularProgress from '@mui/material/CircularProgress'
|
|||||||
import FarmWeatherCard from '@views/dashboards/farm/FarmWeatherCard'
|
import FarmWeatherCard from '@views/dashboards/farm/FarmWeatherCard'
|
||||||
import WaterNeedPrediction from '@views/dashboards/farm/WaterNeedPrediction'
|
import WaterNeedPrediction from '@views/dashboards/farm/WaterNeedPrediction'
|
||||||
import FarmOverviewKPIs from '@views/dashboards/farm/FarmOverviewKPIs'
|
import FarmOverviewKPIs from '@views/dashboards/farm/FarmOverviewKPIs'
|
||||||
|
import WaterIrrigationInsightCard from '@views/dashboards/farm/WaterIrrigationInsightCard'
|
||||||
|
import WaterCropProfileCard from '@views/dashboards/farm/WaterCropProfileCard'
|
||||||
|
|
||||||
// Service
|
// Service
|
||||||
import { waterService } from '@/libs/api/services/waterService'
|
import { waterService } from '@/libs/api/services/waterService'
|
||||||
@@ -31,16 +33,23 @@ const WaterDataDashboardWrapper = () => {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!farmUuid) {
|
|
||||||
setData({})
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
waterService
|
Promise.all([
|
||||||
.getSummary(farmUuid)
|
waterService.getSummary(farmUuid),
|
||||||
.then(summary => setData((summary as Record<string, unknown>) ?? {}))
|
waterService.getNeedPrediction(farmUuid),
|
||||||
|
farmUuid ? waterService.getWeatherFarmCard(farmUuid) : waterService.getCard()
|
||||||
|
])
|
||||||
|
.then(([summary, waterNeedPrediction, farmWeatherCard]) =>
|
||||||
|
setData({
|
||||||
|
waterStressIndex: summary.waterStressIndex ?? {},
|
||||||
|
waterNeedPrediction:
|
||||||
|
Object.keys(waterNeedPrediction ?? {}).length > 0
|
||||||
|
? waterNeedPrediction
|
||||||
|
: (summary.waterNeedPrediction ?? {}),
|
||||||
|
farmWeatherCard:
|
||||||
|
Object.keys(farmWeatherCard ?? {}).length > 0 ? farmWeatherCard : (summary.farmWeatherCard ?? {})
|
||||||
|
})
|
||||||
|
)
|
||||||
.catch(() => setData({}))
|
.catch(() => setData({}))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [farmUuid])
|
}, [farmUuid])
|
||||||
@@ -56,9 +65,9 @@ const WaterDataDashboardWrapper = () => {
|
|||||||
return (
|
return (
|
||||||
<Box position='relative'>
|
<Box position='relative'>
|
||||||
<Grid container spacing={6}>
|
<Grid container spacing={6}>
|
||||||
{data.water_stress_index != null && (
|
{data.waterStressIndex != null && (
|
||||||
<Grid size={12} container spacing={6}>
|
<Grid size={12} container spacing={6}>
|
||||||
<FarmOverviewKPIs data={data.water_stress_index as Record<string, unknown>} />
|
<FarmOverviewKPIs data={{ kpis: [data.waterStressIndex] }} />
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
<Grid size={12} container spacing={6} sx={{ display: 'flex', alignItems: 'stretch' }}>
|
<Grid size={12} container spacing={6} sx={{ display: 'flex', alignItems: 'stretch' }}>
|
||||||
@@ -69,6 +78,14 @@ const WaterDataDashboardWrapper = () => {
|
|||||||
<WaterNeedPrediction data={data.waterNeedPrediction as Record<string, unknown>} />
|
<WaterNeedPrediction data={data.waterNeedPrediction as Record<string, unknown>} />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Grid size={12} container spacing={6} sx={{ display: 'flex', alignItems: 'stretch' }}>
|
||||||
|
<Grid size={{ xs: 12, lg: 8 }} sx={cardRowSx}>
|
||||||
|
<WaterIrrigationInsightCard data={data.waterNeedPrediction as Record<string, unknown>} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, lg: 4 }} sx={cardRowSx}>
|
||||||
|
<WaterCropProfileCard data={data.waterNeedPrediction as Record<string, unknown>} />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Card from '@mui/material/Card'
|
||||||
|
import CardContent from '@mui/material/CardContent'
|
||||||
|
import CardHeader from '@mui/material/CardHeader'
|
||||||
|
import Chip from '@mui/material/Chip'
|
||||||
|
import LinearProgress from '@mui/material/LinearProgress'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
|
||||||
|
type InsightPayload = {
|
||||||
|
summary?: string
|
||||||
|
irrigation_outlook?: string
|
||||||
|
recommended_action?: string
|
||||||
|
risk_note?: string
|
||||||
|
confidence?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WaterIrrigationInsightCardProps {
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const WaterIrrigationInsightCard = ({ data }: WaterIrrigationInsightCardProps) => {
|
||||||
|
const insight =
|
||||||
|
data?.insight && typeof data.insight === 'object' ? (data.insight as InsightPayload) : null
|
||||||
|
|
||||||
|
if (!insight) return null
|
||||||
|
|
||||||
|
const confidence = typeof insight.confidence === 'number' ? Math.round(insight.confidence * 100) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ height: '100%' }}>
|
||||||
|
<CardHeader
|
||||||
|
title='بینش آبیاری'
|
||||||
|
subheader='تحلیل قابل اقدام بر اساس پیش بینی کوتاه مدت'
|
||||||
|
action={
|
||||||
|
confidence != null ? (
|
||||||
|
<Chip
|
||||||
|
size='small'
|
||||||
|
color={confidence >= 80 ? 'success' : confidence >= 60 ? 'warning' : 'error'}
|
||||||
|
label={`اطمینان ${confidence}%`}
|
||||||
|
variant='tonal'
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent className='flex flex-col gap-4'>
|
||||||
|
{insight.summary ? (
|
||||||
|
<div className='rounded-lg border border-[var(--mui-palette-divider)] p-4'>
|
||||||
|
<Typography variant='body2' color='text.secondary' className='mbe-1'>
|
||||||
|
جمع بندی
|
||||||
|
</Typography>
|
||||||
|
<Typography className='leading-7'>{insight.summary}</Typography>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{insight.irrigation_outlook ? (
|
||||||
|
<div className='rounded-lg bg-[var(--mui-palette-action-hover)] p-4'>
|
||||||
|
<Typography variant='body2' color='text.secondary' className='mbe-1'>
|
||||||
|
چشم انداز آبیاری
|
||||||
|
</Typography>
|
||||||
|
<Typography>{insight.irrigation_outlook}</Typography>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{insight.recommended_action ? (
|
||||||
|
<div className='rounded-lg border border-dashed border-[var(--mui-palette-primary-main)] p-4'>
|
||||||
|
<Typography variant='body2' color='primary.main' className='mbe-1 font-medium'>
|
||||||
|
اقدام پیشنهادی
|
||||||
|
</Typography>
|
||||||
|
<Typography>{insight.recommended_action}</Typography>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{confidence != null ? (
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<Typography variant='body2' color='text.secondary'>
|
||||||
|
اعتماد مدل
|
||||||
|
</Typography>
|
||||||
|
<Typography variant='body2' className='font-medium'>
|
||||||
|
{confidence}%
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<LinearProgress
|
||||||
|
variant='determinate'
|
||||||
|
value={confidence}
|
||||||
|
color={confidence >= 80 ? 'success' : confidence >= 60 ? 'warning' : 'error'}
|
||||||
|
sx={{ height: 8, borderRadius: 999 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{insight.risk_note ? (
|
||||||
|
<Typography variant='caption' color='text.secondary'>
|
||||||
|
نکته ریسک: {insight.risk_note}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WaterIrrigationInsightCard
|
||||||
@@ -1,74 +1,325 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from "react";
|
||||||
import { useFarmHub } from '@/hooks/useFarmHub'
|
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 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";
|
||||||
import FarmOverviewKPIs from '@views/dashboards/farm/FarmOverviewKPIs'
|
import FarmOverviewKPIs from "@views/dashboards/farm/FarmOverviewKPIs";
|
||||||
|
import HarvestReadinessZonesCard from "@views/dashboards/farm/HarvestReadinessZonesCard";
|
||||||
|
import YieldQualityBandsCard from "@views/dashboards/farm/YieldQualityBandsCard";
|
||||||
|
import HarvestOperationsCard from "@views/dashboards/farm/HarvestOperationsCard";
|
||||||
|
|
||||||
import { yieldHarvestService } from '@/libs/api/services/yieldHarvestService'
|
import { yieldHarvestService } from "@/libs/api/services/yieldHarvestService";
|
||||||
import type { YieldHarvestSummary } from '@/libs/api/services/yieldHarvestService'
|
import type { YieldHarvestSummary } from "@/libs/api/services/yieldHarvestService";
|
||||||
|
|
||||||
const cardRowSx = {
|
const cardSlotSx = {
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
minHeight: 380,
|
width: "100%",
|
||||||
'& > *': { flex: 1, minHeight: 0 }
|
"& > *": { flex: 1, minHeight: 0 },
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const sectionGridSx = {
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "stretch",
|
||||||
|
};
|
||||||
|
|
||||||
|
const compactCardSx = {
|
||||||
|
...cardSlotSx,
|
||||||
|
minHeight: { xs: 280, md: 320 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartCardSx = {
|
||||||
|
...cardSlotSx,
|
||||||
|
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 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 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: "بالا" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
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 YieldHarvestPageWrapper = () => {
|
||||||
const { farmHub } = useFarmHub()
|
const { farmHub } = useFarmHub();
|
||||||
const farmUuid = farmHub?.farm_uuid
|
const farmUuid = farmHub?.farm_uuid;
|
||||||
const [data, setData] = useState<YieldHarvestSummary>({})
|
const [data, setData] = useState<YieldHarvestSummary>(mockYieldHarvestData);
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!farmUuid) {
|
if (!farmUuid) {
|
||||||
setData({})
|
setData(mockYieldHarvestData);
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
yieldHarvestService
|
yieldHarvestService
|
||||||
.getSummary(farmUuid)
|
.getSummary(farmUuid)
|
||||||
.then(summary => setData(summary ?? {}))
|
.then((summary) =>
|
||||||
.catch(() => setData({}))
|
setData(hasRenderableData(summary) ? summary : mockYieldHarvestData),
|
||||||
.finally(() => setLoading(false))
|
)
|
||||||
}, [farmUuid])
|
.catch(() => setData(mockYieldHarvestData))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [farmUuid]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Box display='flex' justifyContent='center' alignItems='center' minHeight={200}>
|
<Box
|
||||||
|
display="flex"
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
minHeight={200}
|
||||||
|
>
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
</Box>
|
</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">
|
||||||
{data.yield_prediction && (
|
{hasYieldKpis(data) && (
|
||||||
<Grid size={12} container spacing={6} alignItems='stretch'>
|
<Grid
|
||||||
<FarmOverviewKPIs data={data.yield_prediction as Record<string, unknown>} />
|
size={12}
|
||||||
|
container
|
||||||
|
spacing={6}
|
||||||
|
alignItems="stretch"
|
||||||
|
sx={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<FarmOverviewKPIs
|
||||||
|
data={data.yield_prediction as Record<string, unknown>}
|
||||||
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
<Grid size={12} container spacing={6} sx={{ display: 'flex', alignItems: 'stretch' }}>
|
<Grid size={12} container spacing={6} sx={sectionGridSx}>
|
||||||
<Grid size={{ xs: 12, md: 4 }} sx={cardRowSx}>
|
<Grid size={{ xs: 12, md: 6, lg: 6 }} sx={compactCardSx}>
|
||||||
<HarvestPredictionCard data={data.harvestPredictionCard as Record<string, unknown>} />
|
<HarvestPredictionCard
|
||||||
|
data={data.harvestPredictionCard as Record<string, unknown>}
|
||||||
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, md: 8 }} sx={cardRowSx}>
|
<Grid size={{ xs: 12, md: 6, lg: 6 }} sx={compactCardSx}>
|
||||||
<YieldPredictionChart data={data.yieldPredictionChart as Record<string, unknown>} />
|
<HarvestReadinessZonesCard data={mockHarvestReadinessData} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 6, lg: 6 }} sx={compactCardSx}>
|
||||||
|
<YieldQualityBandsCard data={mockYieldQualityBandsData} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 6, lg: 6 }} sx={compactCardSx}>
|
||||||
|
<HarvestOperationsCard data={mockHarvestOperationsData} />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
{hasYieldChart(data) && (
|
||||||
|
<Grid size={12} container spacing={6} sx={sectionGridSx}>
|
||||||
|
<Grid size={{ xs: 12 }} sx={chartCardSx}>
|
||||||
|
<YieldPredictionChart
|
||||||
|
data={data.yieldPredictionChart as Record<string, unknown>}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default YieldHarvestPageWrapper
|
export default YieldHarvestPageWrapper;
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Card from "@mui/material/Card";
|
||||||
|
import CardContent from "@mui/material/CardContent";
|
||||||
|
import CardHeader from "@mui/material/CardHeader";
|
||||||
|
import Chip from "@mui/material/Chip";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
|
||||||
|
import OptionMenu from "@core/components/option-menu";
|
||||||
|
|
||||||
|
type QualityBand = {
|
||||||
|
label: string;
|
||||||
|
share: number;
|
||||||
|
volume: string;
|
||||||
|
premium: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QualityStat = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface YieldQualityBandsCardProps {
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const YieldQualityBandsCard = ({ data }: YieldQualityBandsCardProps) => {
|
||||||
|
const bands = (data?.bands as QualityBand[] | undefined) ?? [];
|
||||||
|
const stats = (data?.stats as QualityStat[] | undefined) ?? [];
|
||||||
|
|
||||||
|
if (bands.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ height: "100%" }}>
|
||||||
|
<CardHeader
|
||||||
|
title="ترکیب کیفیت محصول"
|
||||||
|
subheader="سهم هر گرید از برداشت و اثر آن بر قیمت نهایی فروش"
|
||||||
|
action={
|
||||||
|
<OptionMenu options={["جزئیات کیفیت", "خروجی Excel", "اشتراک"]} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent className="flex flex-col gap-5">
|
||||||
|
{bands.map((band) => (
|
||||||
|
<Box key={band.label}>
|
||||||
|
<div className="flex items-center justify-between gap-3 mbe-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className="inline-block h-3 w-3 rounded-full"
|
||||||
|
style={{ backgroundColor: band.color }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Typography className="font-medium">{band.label}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{band.volume}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Chip label={band.premium} size="small" variant="tonal" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "var(--mui-palette-action-hover)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: `${band.share}%`,
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: `linear-gradient(90deg, ${band.color} 0%, ${band.color}CC 100%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 mt-2">
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
سهم از کل برداشت
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" className="font-medium">
|
||||||
|
{band.share}%
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{stats.length > 0 ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||||
|
gap: 3,
|
||||||
|
mt: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stats.map((stat) => (
|
||||||
|
<Box
|
||||||
|
key={stat.label}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
border: "1px solid var(--mui-palette-divider)",
|
||||||
|
p: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{stat.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography className="font-medium mt-1">
|
||||||
|
{stat.value}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default YieldQualityBandsCard;
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Card from "@mui/material/Card";
|
||||||
|
import CardContent from "@mui/material/CardContent";
|
||||||
|
import Chip from "@mui/material/Chip";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
|
||||||
|
import classnames from "classnames";
|
||||||
|
|
||||||
|
import CustomAvatar from "@core/components/mui/Avatar";
|
||||||
|
import OptionMenu from "@core/components/option-menu";
|
||||||
|
|
||||||
|
type HighlightMetric = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
caption: string;
|
||||||
|
avatarIcon: string;
|
||||||
|
avatarColor: "primary" | "success" | "info" | "warning";
|
||||||
|
};
|
||||||
|
|
||||||
|
type Spotlight = {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
caption: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface YieldSeasonHighlightsCardProps {
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const YieldSeasonHighlightsCard = ({
|
||||||
|
data,
|
||||||
|
}: YieldSeasonHighlightsCardProps) => {
|
||||||
|
const title = (data?.title as string | undefined) ?? "";
|
||||||
|
const subtitle = (data?.subtitle as string | undefined) ?? "";
|
||||||
|
const seasonLabel = (data?.seasonLabel as string | undefined) ?? "";
|
||||||
|
const badges = (data?.badges as string[] | undefined) ?? [];
|
||||||
|
const spotlight = (data?.spotlight as Spotlight | undefined) ?? null;
|
||||||
|
const metrics = (data?.metrics as HighlightMetric[] | undefined) ?? [];
|
||||||
|
|
||||||
|
if (!title || metrics.length === 0 || !spotlight) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
border: "1px solid var(--mui-palette-divider)",
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, rgba(var(--mui-palette-success-mainChannel) / 0.12) 0%, rgba(var(--mui-palette-primary-mainChannel) / 0.12) 55%, rgba(var(--mui-palette-info-mainChannel) / 0.1) 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle at top right, rgba(var(--mui-palette-success-mainChannel) / 0.18), transparent 28%), radial-gradient(circle at bottom left, rgba(var(--mui-palette-primary-mainChannel) / 0.16), transparent 32%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CardContent sx={{ position: "relative", p: { xs: 5, md: 6 } }}>
|
||||||
|
<Stack spacing={5}>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", lg: "row" }}
|
||||||
|
spacing={5}
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Stack spacing={3} sx={{ flex: 1 }}>
|
||||||
|
<Stack direction="row" spacing={1.5} useFlexGap flexWrap="wrap">
|
||||||
|
{seasonLabel ? (
|
||||||
|
<Chip label={seasonLabel} color="success" size="small" />
|
||||||
|
) : null}
|
||||||
|
{badges.map((badge) => (
|
||||||
|
<Chip
|
||||||
|
key={badge}
|
||||||
|
label={badge}
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Typography variant="h4" className="mbe-2">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ maxWidth: 760, lineHeight: 1.9 }}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minWidth: { xs: "100%", lg: 280 },
|
||||||
|
maxWidth: 360,
|
||||||
|
p: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: "1px solid var(--mui-palette-divider)",
|
||||||
|
backgroundColor: "var(--mui-palette-background-paper)",
|
||||||
|
backdropFilter: "blur(10px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{spotlight.title}
|
||||||
|
</Typography>
|
||||||
|
<OptionMenu
|
||||||
|
options={["جزئیات", "خروجی PDF", "اشتراک گذاری"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Typography variant="h4">{spotlight.value}</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ lineHeight: 1.9 }}
|
||||||
|
>
|
||||||
|
{spotlight.caption}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
xs: "1fr",
|
||||||
|
md: "repeat(3, minmax(0, 1fr))",
|
||||||
|
},
|
||||||
|
gap: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{metrics.map((metric) => (
|
||||||
|
<Box
|
||||||
|
key={metric.label}
|
||||||
|
sx={{
|
||||||
|
p: 3.5,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: "1px solid var(--mui-palette-divider)",
|
||||||
|
backgroundColor: "var(--mui-palette-background-paper)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<CustomAvatar
|
||||||
|
variant="rounded"
|
||||||
|
skin="light"
|
||||||
|
color={metric.avatarColor}
|
||||||
|
size={42}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={classnames(metric.avatarIcon, "text-[24px]")}
|
||||||
|
/>
|
||||||
|
</CustomAvatar>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
className="mbe-1"
|
||||||
|
>
|
||||||
|
{metric.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" className="mbe-1">
|
||||||
|
{metric.value}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{metric.caption}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default YieldSeasonHighlightsCard;
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user