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',
|
||||
href: '/pest-risk'
|
||||
},
|
||||
{
|
||||
label: 'economicOverview',
|
||||
icon: 'tabler-cash-banknote',
|
||||
href: '/economic-overview'
|
||||
},
|
||||
{
|
||||
label: 'farmCalendar',
|
||||
icon: 'tabler-calendar-event',
|
||||
@@ -121,6 +126,11 @@ const horizontalMenuData = (): HorizontalMenuDataType[] => [
|
||||
icon: 'tabler-bug',
|
||||
href: '/pest-risk'
|
||||
},
|
||||
{
|
||||
label: 'economicOverview',
|
||||
icon: 'tabler-cash-banknote',
|
||||
href: '/economic-overview'
|
||||
},
|
||||
{
|
||||
label: 'farmCalendar',
|
||||
icon: 'tabler-calendar-event',
|
||||
|
||||
@@ -74,6 +74,11 @@ const verticalMenuData = (): VerticalMenuDataType[] => [
|
||||
icon: 'tabler-bug',
|
||||
href: '/pest-risk'
|
||||
},
|
||||
{
|
||||
label: 'economicOverview',
|
||||
icon: 'tabler-cash-banknote',
|
||||
href: '/economic-overview'
|
||||
},
|
||||
{
|
||||
label: 'farmCalendar',
|
||||
icon: 'tabler-calendar-event',
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
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 {
|
||||
disease_risk?: Record<string, unknown>
|
||||
pest_risk?: Record<string, unknown>
|
||||
diseaseRisk?: Record<string, unknown>
|
||||
pestRisk?: Record<string, unknown>
|
||||
drivers?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
@@ -14,6 +29,12 @@ interface ApiResponse<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 {
|
||||
if (res && typeof res === 'object') {
|
||||
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
|
||||
}
|
||||
|
||||
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 ('title' in card || 'stats' in 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 = {
|
||||
async getRiskSummary(farmUuid: string): Promise<PestRiskSummary> {
|
||||
const res = await apiClient.get<ApiResponse<PestRiskSummary> | PestRiskSummary>(
|
||||
`${PREFIX}/risk-summary/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
||||
let res: ApiResponse<Record<string, unknown>> | Record<string, unknown>
|
||||
|
||||
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 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 {
|
||||
disease_risk: toKpiCard(data?.disease_risk),
|
||||
pest_risk: toKpiCard(data?.pest_risk)
|
||||
diseaseRisk: toKpiCard(diseaseRisk, { title: 'ریسک بیماری', icon: 'tabler-biohazard' }),
|
||||
pestRisk: toKpiCard(pestRisk, { title: 'ریسک آفات', icon: 'tabler-bug' }),
|
||||
drivers
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ import { apiClient } from '../client'
|
||||
const PREFIX = '/api/soil'
|
||||
|
||||
export interface SoilSummary {
|
||||
summaryKpis?: Record<string, unknown>
|
||||
avg_soil_moisture?: Record<string, unknown>
|
||||
sensorRadarChart?: Record<string, unknown>
|
||||
sensorComparisonChart?: Record<string, unknown>
|
||||
anomalyDetectionCard?: Record<string, unknown>
|
||||
soilMoistureHeatmap?: Record<string, unknown>
|
||||
}
|
||||
@@ -16,37 +15,112 @@ interface ApiResponse<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)
|
||||
}
|
||||
|
||||
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 = {
|
||||
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)}`
|
||||
)
|
||||
return extract(res)
|
||||
const data = extract(res)
|
||||
|
||||
return {
|
||||
summaryKpis: { kpis: [buildHealthScoreKpi(data)] }
|
||||
}
|
||||
},
|
||||
|
||||
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)}`
|
||||
)
|
||||
return extract(res)
|
||||
},
|
||||
const data = extract(res)
|
||||
|
||||
async getSensorRadarChart(farmUuid: string): Promise<Record<string, unknown>> {
|
||||
const res = await apiClient.get<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
|
||||
`${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)
|
||||
return {
|
||||
kpis: [data]
|
||||
}
|
||||
},
|
||||
|
||||
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>>(
|
||||
`${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'
|
||||
|
||||
const PREFIX = '/api/water'
|
||||
const WATER_PREFIX = '/api/water'
|
||||
const WEATHER_PREFIX = '/api/weather'
|
||||
|
||||
export interface WaterSummary {
|
||||
farmWeatherCard?: Record<string, unknown>
|
||||
waterNeedPrediction?: Record<string, unknown>
|
||||
water_stress_index?: Record<string, unknown>
|
||||
waterStressIndex?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
@@ -14,36 +15,66 @@ interface ApiResponse<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)
|
||||
}
|
||||
|
||||
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 = {
|
||||
async getSummary(farmUuid: string): Promise<WaterSummary> {
|
||||
const res = await apiClient.get<ApiResponse<WaterSummary> | WaterSummary>(
|
||||
`${PREFIX}/summary/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
||||
async getSummary(farmUuid?: string): Promise<WaterSummary> {
|
||||
const res = await apiClient.get<StatusResponse<Record<string, unknown>> | Record<string, unknown>>(
|
||||
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)
|
||||
},
|
||||
|
||||
async getCard(farmUuid: string): Promise<Record<string, unknown>> {
|
||||
const res = await apiClient.get<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
|
||||
`${PREFIX}/card/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
||||
async getNeedPrediction(farmUuid?: string): Promise<Record<string, unknown>> {
|
||||
const res = await apiClient.get<StatusResponse<Record<string, unknown>> | Record<string, unknown>>(
|
||||
withFarmUuid(`${WATER_PREFIX}/need-prediction/`, farmUuid)
|
||||
)
|
||||
return extract(res)
|
||||
},
|
||||
|
||||
async getNeedPrediction(farmUuid: string): Promise<Record<string, unknown>> {
|
||||
const res = await apiClient.get<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
|
||||
`${PREFIX}/need-prediction/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
||||
async getWeatherFarmCard(farmUuid: string): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
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)
|
||||
} catch {
|
||||
return this.getCard(farmUuid)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,17 +8,14 @@ import { useTranslations } from 'next-intl'
|
||||
import Card from '@mui/material/Card'
|
||||
import CardHeader from '@mui/material/CardHeader'
|
||||
import CardContent from '@mui/material/CardContent'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Grid from '@mui/material/Grid2'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
|
||||
// Third-party Imports
|
||||
import classnames from 'classnames'
|
||||
import type { ApexOptions } from 'apexcharts'
|
||||
|
||||
// Component Imports
|
||||
import OptionMenu from '@core/components/option-menu'
|
||||
import CustomAvatar from '@core/components/mui/Avatar'
|
||||
import CardStatsVertical from '@/components/card-statistics/Vertical'
|
||||
|
||||
// Styled Component Imports
|
||||
const AppReactApexCharts = dynamic(() => import('@/libs/styles/AppReactApexCharts'))
|
||||
@@ -87,20 +84,18 @@ const EconomicOverview = ({ data }: EconomicOverviewProps) => {
|
||||
<Grid container spacing={4}>
|
||||
{economicData.map((item, index) => (
|
||||
<Grid size={{ xs: 12, sm: 6 }} key={index}>
|
||||
<div className='flex items-center gap-4'>
|
||||
<CustomAvatar skin='light' variant='rounded' color={item.avatarColor} size={40}>
|
||||
<i className={classnames(item.avatarIcon, 'text-[22px]')} />
|
||||
</CustomAvatar>
|
||||
<div>
|
||||
<Typography variant='h6'>{item.value}</Typography>
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
{item.title}
|
||||
</Typography>
|
||||
<Typography variant='caption' color='text.disabled'>
|
||||
{item.subtitle}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<CardStatsVertical
|
||||
title={item.title}
|
||||
subtitle={item.subtitle}
|
||||
stats={item.value}
|
||||
avatarIcon={item.avatarIcon}
|
||||
avatarColor={item.avatarColor}
|
||||
avatarSkin='light'
|
||||
avatarSize={40}
|
||||
chipText=''
|
||||
chipColor='primary'
|
||||
chipVariant='tonal'
|
||||
/>
|
||||
</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
|
||||
import Grid from '@mui/material/Grid2'
|
||||
import Grid from "@mui/material/Grid2";
|
||||
|
||||
// Component Imports
|
||||
import CardStatsVertical from '@components/card-statistics/Vertical'
|
||||
import CardStatsVertical from "@components/card-statistics/Vertical";
|
||||
|
||||
type KpiItem = {
|
||||
id: string
|
||||
title: string
|
||||
subtitle: string
|
||||
stats: string
|
||||
avatarColor?: string
|
||||
avatarIcon?: string
|
||||
chipText?: string
|
||||
chipColor?: string
|
||||
}
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
stats: string;
|
||||
avatarColor?: string;
|
||||
avatarIcon?: string;
|
||||
chipText?: string;
|
||||
chipColor?: string;
|
||||
};
|
||||
|
||||
interface FarmOverviewKPIsProps {
|
||||
data?: Record<string, unknown>
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const FarmOverviewKPIs = ({ data }: FarmOverviewKPIsProps) => {
|
||||
const kpis = (data?.kpis as KpiItem[] | undefined) ?? []
|
||||
if (kpis.length === 0) return null
|
||||
const kpis = (data?.kpis as KpiItem[] | undefined) ?? [];
|
||||
if (kpis.length === 0) return null;
|
||||
|
||||
const getGridSize = (count) => {
|
||||
const getGridSize = (count: number) => {
|
||||
if (count === 1) return { xs: 12 };
|
||||
if (count === 2) return { xs: 12, md: 6 };
|
||||
if (count === 3) return { xs: 12, sm: 6, md: 4 };
|
||||
if (count === 4) return { xs: 12, sm: 6, md: 3 };
|
||||
|
||||
return { xs: 12, sm: 6, md: 4, lg: 2 };
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{kpis.map((kpi) => (
|
||||
<Grid
|
||||
key={kpi.id}
|
||||
size={getGridSize(kpis.length)}
|
||||
sx={{ display: "flex" }}
|
||||
> <CardStatsVertical
|
||||
sx={{ display: "flex", width: "100%" }}
|
||||
>
|
||||
<CardStatsVertical
|
||||
title={kpi.title}
|
||||
subtitle={kpi.subtitle}
|
||||
stats={kpi.stats}
|
||||
avatarColor={(kpi.avatarColor as 'success' | 'info' | 'primary' | 'secondary' | 'warning') ?? 'primary'}
|
||||
avatarIcon={kpi.avatarIcon ?? 'tabler-chart-bar'}
|
||||
avatarSkin='light'
|
||||
avatarColor={
|
||||
(kpi.avatarColor as
|
||||
| "success"
|
||||
| "info"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "warning") ?? "primary"
|
||||
}
|
||||
avatarIcon={kpi.avatarIcon ?? "tabler-chart-bar"}
|
||||
avatarSkin="light"
|
||||
avatarSize={44}
|
||||
chipText={kpi.chipText ?? ''}
|
||||
chipColor={(kpi.chipColor as 'success' | 'warning') ?? 'success'}
|
||||
chipVariant='tonal'
|
||||
chipText={kpi.chipText ?? ""}
|
||||
chipColor={(kpi.chipColor as "success" | "warning") ?? "success"}
|
||||
chipVariant="tonal"
|
||||
/>
|
||||
</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 Box from '@mui/material/Box'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import FarmOverviewKPIs from '@views/dashboards/farm/FarmOverviewKPIs'
|
||||
|
||||
@@ -52,18 +51,18 @@ const PestRiskPageWrapper = () => {
|
||||
return (
|
||||
<Box position='relative' sx={{ width: '100%' }}>
|
||||
<Grid container spacing={6} alignItems='stretch'>
|
||||
{data.disease_risk && (
|
||||
{data.diseaseRisk && (
|
||||
<Grid size={{ xs: 12, md: 6 }} sx={sectionSx}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{data.pest_risk && (
|
||||
{data.pestRisk && (
|
||||
<Grid size={{ xs: 12, md: 6 }} sx={sectionSx}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -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 PlantSimulator from '@views/dashboards/farm/plantSimulator/PlantSimulator'
|
||||
import YieldHarvestPageWrapper from "@views/dashboards/farm/YieldHarvestPageWrapper";
|
||||
import YieldSeasonHighlightsCard from "@views/dashboards/farm/YieldSeasonHighlightsCard";
|
||||
import PlantSimulator from "@views/dashboards/farm/plantSimulator/PlantSimulator";
|
||||
|
||||
const mockSeasonHighlightsData = {
|
||||
title: "اتاق فرمان برداشت این فصل",
|
||||
subtitle:
|
||||
"این بخش برای نمایش سریع وضعیت عملکرد، کیفیت و بهترین پنجره فروش طراحی شده است. داده ها فعلا ماک هستند تا ظاهر نهایی کارت ها و ریتم بصری صفحه بهتر دیده شود.",
|
||||
seasonLabel: "فصل ۱۴۰۴",
|
||||
badges: ["کیفیت ممتاز", "آماده بسته بندی", "ریسک پایین"],
|
||||
spotlight: {
|
||||
title: "پنجره طلایی فروش",
|
||||
value: "۳ روز اول بعد از برداشت",
|
||||
caption: "در این بازه، برآورد قیمت فروش حدود ۸٪ بهتر از میانگین هفتگی است.",
|
||||
},
|
||||
metrics: [
|
||||
{
|
||||
label: "سطح قابل برداشت",
|
||||
value: "18.6 هکتار",
|
||||
caption: "۴ قطعه در اولویت نخست قرار دارند.",
|
||||
avatarIcon: "tabler-map-2",
|
||||
avatarColor: "success",
|
||||
},
|
||||
{
|
||||
label: "گرید ممتاز",
|
||||
value: "46%",
|
||||
caption: "بالاترین سهم کیفیت مربوط به قطعه A2 است.",
|
||||
avatarIcon: "tabler-rosette-discount-check",
|
||||
avatarColor: "warning",
|
||||
},
|
||||
{
|
||||
label: "درآمد هدف",
|
||||
value: "1.84 میلیارد",
|
||||
caption: "با فرض فروش در بازه پیشنهادی مدل.",
|
||||
avatarIcon: "tabler-cash-banknote",
|
||||
avatarColor: "primary",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const PlantProductionPage = () => {
|
||||
return (
|
||||
<Grid container spacing={6} alignItems='stretch'>
|
||||
<Grid size={{ xs: 12, xl: 12 }} sx={{ display: 'flex' }}>
|
||||
<Grid container spacing={6} alignItems="stretch">
|
||||
<Grid size={{ xs: 12 }} sx={{ display: "flex", width: "100%" }}>
|
||||
<YieldSeasonHighlightsCard data={mockSeasonHighlightsData} />
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12 }} sx={{ display: "flex", width: "100%" }}>
|
||||
<PlantSimulator />
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, xl: 12 }} sx={{ display: 'flex' }}>
|
||||
<Grid size={{ xs: 12 }} sx={{ display: "flex", width: "100%" }}>
|
||||
<YieldHarvestPageWrapper />
|
||||
</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 CircularProgress from '@mui/material/CircularProgress'
|
||||
|
||||
import FarmOverviewKPIs from '@views/dashboards/farm/FarmOverviewKPIs'
|
||||
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 { soilService } from '@/libs/api/services/soilService'
|
||||
@@ -36,9 +35,24 @@ const SoilDataDashboardWrapper = () => {
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
soilService
|
||||
.getSummary(farmUuid)
|
||||
.then(summary => setData(summary ?? {}))
|
||||
Promise.all([
|
||||
soilService.getSummary(farmUuid),
|
||||
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({}))
|
||||
.finally(() => setLoading(false))
|
||||
}, [farmUuid])
|
||||
@@ -54,19 +68,16 @@ const SoilDataDashboardWrapper = () => {
|
||||
return (
|
||||
<Box position='relative'>
|
||||
<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}>
|
||||
<SoilMoistureHeatmap data={data.soilMoistureHeatmap as Record<string, unknown>} />
|
||||
</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}>
|
||||
<SensorComparisonChart data={data.sensorComparisonChart as Record<string, unknown>} />
|
||||
<AnomalyDetectionCard data={data.anomalyDetectionCard as Record<string, unknown>} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</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 WaterNeedPrediction from '@views/dashboards/farm/WaterNeedPrediction'
|
||||
import FarmOverviewKPIs from '@views/dashboards/farm/FarmOverviewKPIs'
|
||||
import WaterIrrigationInsightCard from '@views/dashboards/farm/WaterIrrigationInsightCard'
|
||||
import WaterCropProfileCard from '@views/dashboards/farm/WaterCropProfileCard'
|
||||
|
||||
// Service
|
||||
import { waterService } from '@/libs/api/services/waterService'
|
||||
@@ -31,16 +33,23 @@ const WaterDataDashboardWrapper = () => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!farmUuid) {
|
||||
setData({})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
waterService
|
||||
.getSummary(farmUuid)
|
||||
.then(summary => setData((summary as Record<string, unknown>) ?? {}))
|
||||
Promise.all([
|
||||
waterService.getSummary(farmUuid),
|
||||
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({}))
|
||||
.finally(() => setLoading(false))
|
||||
}, [farmUuid])
|
||||
@@ -56,9 +65,9 @@ const WaterDataDashboardWrapper = () => {
|
||||
return (
|
||||
<Box position='relative'>
|
||||
<Grid container spacing={6}>
|
||||
{data.water_stress_index != null && (
|
||||
{data.waterStressIndex != null && (
|
||||
<Grid size={12} container spacing={6}>
|
||||
<FarmOverviewKPIs data={data.water_stress_index as Record<string, unknown>} />
|
||||
<FarmOverviewKPIs data={{ kpis: [data.waterStressIndex] }} />
|
||||
</Grid>
|
||||
)}
|
||||
<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>} />
|
||||
</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>
|
||||
</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 { useFarmHub } from '@/hooks/useFarmHub'
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFarmHub } from "@/hooks/useFarmHub";
|
||||
|
||||
import Grid from '@mui/material/Grid2'
|
||||
import Box from '@mui/material/Box'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import Grid from "@mui/material/Grid2";
|
||||
import Box from "@mui/material/Box";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
|
||||
import HarvestPredictionCard from '@views/dashboards/farm/HarvestPredictionCard'
|
||||
import YieldPredictionChart from '@views/dashboards/farm/YieldPredictionChart'
|
||||
import FarmOverviewKPIs from '@views/dashboards/farm/FarmOverviewKPIs'
|
||||
import HarvestPredictionCard from "@views/dashboards/farm/HarvestPredictionCard";
|
||||
import YieldPredictionChart from "@views/dashboards/farm/YieldPredictionChart";
|
||||
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 type { YieldHarvestSummary } from '@/libs/api/services/yieldHarvestService'
|
||||
import { yieldHarvestService } from "@/libs/api/services/yieldHarvestService";
|
||||
import type { YieldHarvestSummary } from "@/libs/api/services/yieldHarvestService";
|
||||
|
||||
const cardRowSx = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 380,
|
||||
'& > *': { flex: 1, minHeight: 0 }
|
||||
}
|
||||
const cardSlotSx = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
"& > *": { 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 { farmHub } = useFarmHub()
|
||||
const farmUuid = farmHub?.farm_uuid
|
||||
const [data, setData] = useState<YieldHarvestSummary>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { farmHub } = useFarmHub();
|
||||
const farmUuid = farmHub?.farm_uuid;
|
||||
const [data, setData] = useState<YieldHarvestSummary>(mockYieldHarvestData);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!farmUuid) {
|
||||
setData({})
|
||||
setLoading(false)
|
||||
return
|
||||
setData(mockYieldHarvestData);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
yieldHarvestService
|
||||
.getSummary(farmUuid)
|
||||
.then(summary => setData(summary ?? {}))
|
||||
.catch(() => setData({}))
|
||||
.finally(() => setLoading(false))
|
||||
}, [farmUuid])
|
||||
.then((summary) =>
|
||||
setData(hasRenderableData(summary) ? summary : mockYieldHarvestData),
|
||||
)
|
||||
.catch(() => setData(mockYieldHarvestData))
|
||||
.finally(() => setLoading(false));
|
||||
}, [farmUuid]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display='flex' justifyContent='center' alignItems='center' minHeight={200}>
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
minHeight={200}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box position='relative' sx={{ width: '100%' }}>
|
||||
<Grid container spacing={6} alignItems='stretch'>
|
||||
{data.yield_prediction && (
|
||||
<Grid size={12} container spacing={6} alignItems='stretch'>
|
||||
<FarmOverviewKPIs data={data.yield_prediction as Record<string, unknown>} />
|
||||
<Box position="relative" sx={{ width: "100%" }}>
|
||||
<Grid container spacing={6} alignItems="stretch">
|
||||
{hasYieldKpis(data) && (
|
||||
<Grid
|
||||
size={12}
|
||||
container
|
||||
spacing={6}
|
||||
alignItems="stretch"
|
||||
sx={{ width: "100%" }}
|
||||
>
|
||||
<FarmOverviewKPIs
|
||||
data={data.yield_prediction as Record<string, unknown>}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid size={12} container spacing={6} sx={{ display: 'flex', alignItems: 'stretch' }}>
|
||||
<Grid size={{ xs: 12, md: 4 }} sx={cardRowSx}>
|
||||
<HarvestPredictionCard data={data.harvestPredictionCard as Record<string, unknown>} />
|
||||
<Grid size={12} container spacing={6} sx={sectionGridSx}>
|
||||
<Grid size={{ xs: 12, md: 6, lg: 6 }} sx={compactCardSx}>
|
||||
<HarvestPredictionCard
|
||||
data={data.harvestPredictionCard as Record<string, unknown>}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 8 }} sx={cardRowSx}>
|
||||
<YieldPredictionChart data={data.yieldPredictionChart as Record<string, unknown>} />
|
||||
<Grid size={{ xs: 12, md: 6, lg: 6 }} sx={compactCardSx}>
|
||||
<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>
|
||||
{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>
|
||||
</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