This commit is contained in:
2026-04-29 22:26:53 +03:30
parent 8f74e4f385
commit 04d678fda4
23 changed files with 2860 additions and 711 deletions
@@ -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',
+5
View File
@@ -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
} }
}, },
} }
+100 -21
View File
@@ -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)
}
}, },
} }
+49 -18
View File
@@ -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)
}
}, },
} }
+13 -18
View File
@@ -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
+37 -27
View File
@@ -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