From 04d678fda47e46240147334d2fb40274b1f07601 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Wed, 29 Apr 2026 22:26:53 +0330 Subject: [PATCH] UPDATE --- .../(private)/economic-overview/page.tsx | 7 + .../soil-data/BACKEND_REQUIREMENTS.md | 324 ++++ src/data/navigation/horizontalMenuData.tsx | 10 + src/data/navigation/verticalMenuData.tsx | 5 + .../services/pestDetectionDomainService.ts | 106 +- src/libs/api/services/soilService.ts | 121 +- src/libs/api/services/waterService.ts | 71 +- .../dashboards/farm/EconomicOverview.tsx | 31 +- .../farm/EconomicOverviewPageWrapper.tsx | 60 + .../dashboards/farm/FarmOverviewKPIs.tsx | 70 +- .../dashboards/farm/HarvestOperationsCard.tsx | 147 ++ .../farm/HarvestReadinessZonesCard.tsx | 149 ++ .../dashboards/farm/PestRiskPageWrapper.tsx | 11 +- .../dashboards/farm/PlantProductionPage.tsx | 61 +- .../farm/SoilDataDashboardWrapper.tsx | 39 +- .../dashboards/farm/WaterCropProfileCard.tsx | 89 ++ .../farm/WaterDailyBreakdownCard.tsx | 91 ++ .../farm/WaterDataDashboardWrapper.tsx | 39 +- .../farm/WaterIrrigationInsightCard.tsx | 103 ++ .../farm/YieldHarvestPageWrapper.tsx | 339 +++- .../dashboards/farm/YieldQualityBandsCard.tsx | 126 ++ .../farm/YieldSeasonHighlightsCard.tsx | 188 +++ .../farm/plantSimulator/PlantSimulator.tsx | 1384 ++++++++++------- 23 files changed, 2860 insertions(+), 711 deletions(-) create mode 100644 src/app/(dashboard)/(private)/economic-overview/page.tsx create mode 100644 src/app/(dashboard)/(private)/soil-data/BACKEND_REQUIREMENTS.md create mode 100644 src/views/dashboards/farm/EconomicOverviewPageWrapper.tsx create mode 100644 src/views/dashboards/farm/HarvestOperationsCard.tsx create mode 100644 src/views/dashboards/farm/HarvestReadinessZonesCard.tsx create mode 100644 src/views/dashboards/farm/WaterCropProfileCard.tsx create mode 100644 src/views/dashboards/farm/WaterDailyBreakdownCard.tsx create mode 100644 src/views/dashboards/farm/WaterIrrigationInsightCard.tsx create mode 100644 src/views/dashboards/farm/YieldQualityBandsCard.tsx create mode 100644 src/views/dashboards/farm/YieldSeasonHighlightsCard.tsx diff --git a/src/app/(dashboard)/(private)/economic-overview/page.tsx b/src/app/(dashboard)/(private)/economic-overview/page.tsx new file mode 100644 index 0000000..746e630 --- /dev/null +++ b/src/app/(dashboard)/(private)/economic-overview/page.tsx @@ -0,0 +1,7 @@ +import EconomicOverviewPageWrapper from '@views/dashboards/farm/EconomicOverviewPageWrapper' + +const EconomicOverviewPage = () => { + return +} + +export default EconomicOverviewPage diff --git a/src/app/(dashboard)/(private)/soil-data/BACKEND_REQUIREMENTS.md b/src/app/(dashboard)/(private)/soil-data/BACKEND_REQUIREMENTS.md new file mode 100644 index 0000000..03cbc5e --- /dev/null +++ b/src/app/(dashboard)/(private)/soil-data/BACKEND_REQUIREMENTS.md @@ -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 هم‌خوانی کامل ندارد. diff --git a/src/data/navigation/horizontalMenuData.tsx b/src/data/navigation/horizontalMenuData.tsx index c3302d4..2524e4a 100644 --- a/src/data/navigation/horizontalMenuData.tsx +++ b/src/data/navigation/horizontalMenuData.tsx @@ -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', diff --git a/src/data/navigation/verticalMenuData.tsx b/src/data/navigation/verticalMenuData.tsx index 8d8ee7b..5055ab2 100644 --- a/src/data/navigation/verticalMenuData.tsx +++ b/src/data/navigation/verticalMenuData.tsx @@ -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', diff --git a/src/libs/api/services/pestDetectionDomainService.ts b/src/libs/api/services/pestDetectionDomainService.ts index e2318ee..4357cf3 100644 --- a/src/libs/api/services/pestDetectionDomainService.ts +++ b/src/libs/api/services/pestDetectionDomainService.ts @@ -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 +} export interface PestRiskSummary { - disease_risk?: Record - pest_risk?: Record + diseaseRisk?: Record + pestRisk?: Record + drivers?: Record } interface ApiResponse { @@ -14,6 +29,12 @@ interface ApiResponse { result?: T } +function isRouteMismatchError(error: unknown): boolean { + const statusCode = (error as ApiError | undefined)?.code + + return statusCode === 404 || statusCode === 405 +} + function extract(res: ApiResponse | T): T { if (res && typeof res === 'object') { if ('data' in res) return (res as ApiResponse).data @@ -23,22 +44,87 @@ function extract(res: ApiResponse | T): T { return res as T } -function toKpiCard(card?: Record): Record { +function toKpiCard( + card: RiskCard | Record | undefined, + fallback: { title: string; icon: string }, +): Record { if (!card || typeof card !== 'object') return {} - return { kpis: [card] } + 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 = { + low: 'پایین', + medium: 'متوسط', + moderate: 'متوسط', + high: 'بالا' + } + + const colorMap: Record = { + 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 { - const res = await apiClient.get | PestRiskSummary>( - `${PREFIX}/risk-summary/?farm_uuid=${encodeURIComponent(farmUuid)}` - ) + let res: ApiResponse> | Record + + try { + res = await apiClient.post> | Record>( + `${DETECTION_PREFIX}/risk-summary/`, + { farm_uuid: farmUuid } + ) + } catch (error) { + if (!isRouteMismatchError(error)) throw error + + try { + res = await apiClient.get> | Record>( + `${DETECTION_PREFIX}/risk-summary/?farm_uuid=${encodeURIComponent(farmUuid)}` + ) + } catch (fallbackError) { + if (!isRouteMismatchError(fallbackError)) throw fallbackError + + res = await apiClient.post> | Record>( + `${DISEASE_PREFIX}/risk-summary/`, + { farm_uuid: farmUuid } + ) + } + } + const data = extract(res) + const diseaseRisk = (data?.diseaseRisk as Record | undefined) ?? (data?.disease_risk as Record | undefined) + const pestRisk = (data?.pestRisk as Record | undefined) ?? (data?.pest_risk as Record | undefined) + const drivers = (data?.drivers as Record | 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 } }, } diff --git a/src/libs/api/services/soilService.ts b/src/libs/api/services/soilService.ts index 134e052..9d9a797 100644 --- a/src/libs/api/services/soilService.ts +++ b/src/libs/api/services/soilService.ts @@ -3,9 +3,8 @@ import { apiClient } from '../client' const PREFIX = '/api/soil' export interface SoilSummary { + summaryKpis?: Record avg_soil_moisture?: Record - sensorRadarChart?: Record - sensorComparisonChart?: Record anomalyDetectionCard?: Record soilMoistureHeatmap?: Record } @@ -16,37 +15,112 @@ interface ApiResponse { data: T } -function extract(res: ApiResponse | T): T { +interface StatusResponse { + status: string + data: T +} + +type HeatmapPoint = { + x: string + y: number +} + +type HeatmapSeries = { + name: string + data: HeatmapPoint[] +} + +function extract(res: ApiResponse | StatusResponse | T): T { return res && typeof res === 'object' && 'data' in res ? (res as ApiResponse).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): 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>) : [] + + if (gridCells.length === 0) return [] + + const grouped = new Map() + + 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): Record { + const healthScore = getNumericValue(summary.healthScore) + const profileSource = String(summary.profileSource ?? 'مرجع خاک') + const healthLanguage = + summary.healthLanguage && typeof summary.healthLanguage === 'object' + ? (summary.healthLanguage as Record) + : {} + 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 { - const res = await apiClient.get | SoilSummary>( + const res = await apiClient.get> | Record>( `${PREFIX}/summary/?farm_uuid=${encodeURIComponent(farmUuid)}` ) - return extract(res) + const data = extract(res) + + return { + summaryKpis: { kpis: [buildHealthScoreKpi(data)] } + } }, async getAvgMoisture(farmUuid: string): Promise> { - const res = await apiClient.get> | Record>( + const res = await apiClient.get> | Record>( `${PREFIX}/avg-moisture/?farm_uuid=${encodeURIComponent(farmUuid)}` ) - return extract(res) - }, + const data = extract(res) - async getSensorRadarChart(farmUuid: string): Promise> { - const res = await apiClient.get> | Record>( - `${PREFIX}/sensor-radar-chart/?farm_uuid=${encodeURIComponent(farmUuid)}` - ) - return extract(res) - }, - - async getSensorComparisonChart(farmUuid: string): Promise> { - const res = await apiClient.get> | Record>( - `${PREFIX}/sensor-comparison-chart/?farm_uuid=${encodeURIComponent(farmUuid)}` - ) - return extract(res) + return { + kpis: [data] + } }, async getAnomalies(farmUuid: string): Promise> { @@ -60,6 +134,11 @@ export const soilService = { const res = await apiClient.get> | Record>( `${PREFIX}/moisture-heatmap/?farm_uuid=${encodeURIComponent(farmUuid)}` ) - return extract(res) + const data = extract(res) + + return { + ...data, + series: normalizeHeatmapSeries(data) + } }, } diff --git a/src/libs/api/services/waterService.ts b/src/libs/api/services/waterService.ts index 64610dc..0d2f5fc 100644 --- a/src/libs/api/services/waterService.ts +++ b/src/libs/api/services/waterService.ts @@ -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 waterNeedPrediction?: Record - water_stress_index?: Record + waterStressIndex?: Record } interface ApiResponse { @@ -14,36 +15,66 @@ interface ApiResponse { data: T } -function extract(res: ApiResponse | T): T { +interface StatusResponse { + status: string + data: T +} + +function extract(res: ApiResponse | StatusResponse | T): T { return res && typeof res === 'object' && 'data' in res ? (res as ApiResponse).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): WaterSummary { + return { + farmWeatherCard: (data.farmWeatherCard as Record | undefined) ?? {}, + waterNeedPrediction: (data.waterNeedPrediction as Record | undefined) ?? {}, + waterStressIndex: + (data.waterStressIndex as Record | undefined) ?? + (data.water_stress_index as Record | undefined) ?? + {}, + } +} + export const waterService = { - async getSummary(farmUuid: string): Promise { - const res = await apiClient.get | WaterSummary>( - `${PREFIX}/summary/?farm_uuid=${encodeURIComponent(farmUuid)}` + async getSummary(farmUuid?: string): Promise { + const res = await apiClient.get> | Record>( + withFarmUuid(`${WATER_PREFIX}/summary/`, farmUuid) + ) + return normalizeSummary(extract(res)) + }, + + async getCard(farmUuid?: string): Promise> { + const res = await apiClient.get> | Record>( + withFarmUuid(`${WATER_PREFIX}/card/`, farmUuid) ) return extract(res) }, - async getCard(farmUuid: string): Promise> { - const res = await apiClient.get> | Record>( - `${PREFIX}/card/?farm_uuid=${encodeURIComponent(farmUuid)}` + async getNeedPrediction(farmUuid?: string): Promise> { + const res = await apiClient.get> | Record>( + withFarmUuid(`${WATER_PREFIX}/need-prediction/`, farmUuid) ) return extract(res) }, - async getNeedPrediction(farmUuid: string): Promise> { - const res = await apiClient.get> | Record>( - `${PREFIX}/need-prediction/?farm_uuid=${encodeURIComponent(farmUuid)}` - ) - return extract(res) - }, + async getWeatherFarmCard(farmUuid: string): Promise> { + try { + const res = await apiClient.post> | Record>( + `${WEATHER_PREFIX}/farm-card/`, + { farm_uuid: farmUuid } + ) - async getStressIndex(farmUuid: string): Promise> { - const res = await apiClient.get> | Record>( - `${PREFIX}/stress-index/?farm_uuid=${encodeURIComponent(farmUuid)}` - ) - return extract(res) + return extract(res) + } catch { + return this.getCard(farmUuid) + } }, } diff --git a/src/views/dashboards/farm/EconomicOverview.tsx b/src/views/dashboards/farm/EconomicOverview.tsx index 447037a..82106bc 100644 --- a/src/views/dashboards/farm/EconomicOverview.tsx +++ b/src/views/dashboards/farm/EconomicOverview.tsx @@ -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) => { {economicData.map((item, index) => ( -
- - - -
- {item.value} - - {item.title} - - - {item.subtitle} - -
-
+
))}
diff --git a/src/views/dashboards/farm/EconomicOverviewPageWrapper.tsx b/src/views/dashboards/farm/EconomicOverviewPageWrapper.tsx new file mode 100644 index 0000000..c307922 --- /dev/null +++ b/src/views/dashboards/farm/EconomicOverviewPageWrapper.tsx @@ -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>({}) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!farmUuid) { + setData({}) + setLoading(false) + return + } + + setLoading(true) + economicOverviewService + .getSummary(farmUuid) + .then(summary => setData((summary.economicOverview as Record) ?? {})) + .catch(() => setData({})) + .finally(() => setLoading(false)) + }, [farmUuid]) + + if (loading) { + return ( + + + + ) + } + + return ( + + + + + + + + ) +} + +export default EconomicOverviewPageWrapper diff --git a/src/views/dashboards/farm/FarmOverviewKPIs.tsx b/src/views/dashboards/farm/FarmOverviewKPIs.tsx index 542251e..dd991be 100644 --- a/src/views/dashboards/farm/FarmOverviewKPIs.tsx +++ b/src/views/dashboards/farm/FarmOverviewKPIs.tsx @@ -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 + data?: Record; } 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) => ( - + ))} - ) -} + ); +}; -export default FarmOverviewKPIs +export default FarmOverviewKPIs; diff --git a/src/views/dashboards/farm/HarvestOperationsCard.tsx b/src/views/dashboards/farm/HarvestOperationsCard.tsx new file mode 100644 index 0000000..9f2bfab --- /dev/null +++ b/src/views/dashboards/farm/HarvestOperationsCard.tsx @@ -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; +} + +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 ( + + + } + /> + + {summary ? ( + + + جمع بندی + + {summary} + + ) : null} + +
+ {steps.map((step, index) => ( +
+ + {index + 1} + + +
+ {step.title} + +
+ + {step.note} + +
+
+ ))} +
+ + {outputs.length > 0 ? ( + + {outputs.map((output) => ( + + + {output.label} + + + {output.value} + + + ))} + + ) : null} +
+
+ ); +}; + +export default HarvestOperationsCard; diff --git a/src/views/dashboards/farm/HarvestReadinessZonesCard.tsx b/src/views/dashboards/farm/HarvestReadinessZonesCard.tsx new file mode 100644 index 0000000..24abbcd --- /dev/null +++ b/src/views/dashboards/farm/HarvestReadinessZonesCard.tsx @@ -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; +} + +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 ( + + + {averageReadiness ? ( + + ) : null} + + + } + /> + + {blocks.map((block) => ( + +
+
+ {block.name} + + رقم {block.cultivar} + +
+ = 85 + ? "success" + : block.readiness >= 70 + ? "warning" + : "info" + } + size="small" + variant="tonal" + /> +
+ +
+ + سطح آمادگی + + + {block.readiness}% + +
+ = 85 + ? "success" + : block.readiness >= 70 + ? "warning" + : "info" + } + sx={{ height: 8, borderRadius: 999 }} + /> + + + + + عملکرد پیش بینی شده + + + {block.expectedYield} + + + + + رطوبت دانه + + + {block.moisture} + + + +
+ ))} +
+
+ ); +}; + +export default HarvestReadinessZonesCard; diff --git a/src/views/dashboards/farm/PestRiskPageWrapper.tsx b/src/views/dashboards/farm/PestRiskPageWrapper.tsx index f2c1f71..96650c8 100644 --- a/src/views/dashboards/farm/PestRiskPageWrapper.tsx +++ b/src/views/dashboards/farm/PestRiskPageWrapper.tsx @@ -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' @@ -50,20 +49,20 @@ const PestRiskPageWrapper = () => { } return ( - + - {data.disease_risk && ( + {data.diseaseRisk && ( - } /> + } /> )} - {data.pest_risk && ( + {data.pestRisk && ( - } /> + } /> )} diff --git a/src/views/dashboards/farm/PlantProductionPage.tsx b/src/views/dashboards/farm/PlantProductionPage.tsx index e43b85b..5df74fa 100644 --- a/src/views/dashboards/farm/PlantProductionPage.tsx +++ b/src/views/dashboards/farm/PlantProductionPage.tsx @@ -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 ( - - + + + + + + - + - ) -} + ); +}; -export default PlantProductionPage +export default PlantProductionPage; diff --git a/src/views/dashboards/farm/SoilDataDashboardWrapper.tsx b/src/views/dashboards/farm/SoilDataDashboardWrapper.tsx index b8d7315..3b8e554 100644 --- a/src/views/dashboards/farm/SoilDataDashboardWrapper.tsx +++ b/src/views/dashboards/farm/SoilDataDashboardWrapper.tsx @@ -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[]) ?? [])), + ...(((avgMoisture.kpis as Record[]) ?? [])) + ] + }, + anomalyDetectionCard: anomalies, + soilMoistureHeatmap: heatmap + }) + ) .catch(() => setData({})) .finally(() => setLoading(false)) }, [farmUuid]) @@ -54,19 +68,16 @@ const SoilDataDashboardWrapper = () => { return ( + + + } /> + + } /> - - - } /> - - - } /> - - - } /> + } /> diff --git a/src/views/dashboards/farm/WaterCropProfileCard.tsx b/src/views/dashboards/farm/WaterCropProfileCard.tsx new file mode 100644 index 0000000..ecc5b47 --- /dev/null +++ b/src/views/dashboards/farm/WaterCropProfileCard.tsx @@ -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 +} + +interface WaterCropProfileCardProps { + data?: Record +} + +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) + : {} + + 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 ( + + + ) : undefined + } + /> + +
+ {kcItems.map(item => ( +
+ + {item.label} + + {formatValue(item.value)} +
+ ))} +
+ +
+ {stages.map(stage => ( + + ))} +
+
+
+ ) +} + +export default WaterCropProfileCard diff --git a/src/views/dashboards/farm/WaterDailyBreakdownCard.tsx b/src/views/dashboards/farm/WaterDailyBreakdownCard.tsx new file mode 100644 index 0000000..d9964c3 --- /dev/null +++ b/src/views/dashboards/farm/WaterDailyBreakdownCard.tsx @@ -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 +} + +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 ( + + + + {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 ( +
+
+
+ {item.forecast_date ?? `روز ${index + 1}`} + + زمان پیشنهادی: {item.irrigation_timing ?? '-'} + +
+ +
+ + + +
+
+ +
+
+ + آبیاری خالص + + {formatAmount(item.net_irrigation_mm)} mm +
+
+ + آبیاری ناخالص + + {formatAmount(item.gross_irrigation_mm)} mm +
+
+ + بارش موثر + + {formatAmount(item.effective_rainfall_mm)} mm +
+
+ + {index < dailyBreakdown.length - 1 ? : null} +
+ ) + })} +
+
+ ) +} + +export default WaterDailyBreakdownCard diff --git a/src/views/dashboards/farm/WaterDataDashboardWrapper.tsx b/src/views/dashboards/farm/WaterDataDashboardWrapper.tsx index 443e2b2..07d950f 100644 --- a/src/views/dashboards/farm/WaterDataDashboardWrapper.tsx +++ b/src/views/dashboards/farm/WaterDataDashboardWrapper.tsx @@ -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) ?? {})) + 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 ( - {data.water_stress_index != null && ( + {data.waterStressIndex != null && ( - } /> + )} @@ -69,6 +78,14 @@ const WaterDataDashboardWrapper = () => { } /> + + + } /> + + + } /> + +
) diff --git a/src/views/dashboards/farm/WaterIrrigationInsightCard.tsx b/src/views/dashboards/farm/WaterIrrigationInsightCard.tsx new file mode 100644 index 0000000..209ccde --- /dev/null +++ b/src/views/dashboards/farm/WaterIrrigationInsightCard.tsx @@ -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 +} + +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 ( + + = 80 ? 'success' : confidence >= 60 ? 'warning' : 'error'} + label={`اطمینان ${confidence}%`} + variant='tonal' + /> + ) : undefined + } + /> + + {insight.summary ? ( +
+ + جمع بندی + + {insight.summary} +
+ ) : null} + + {insight.irrigation_outlook ? ( +
+ + چشم انداز آبیاری + + {insight.irrigation_outlook} +
+ ) : null} + + {insight.recommended_action ? ( +
+ + اقدام پیشنهادی + + {insight.recommended_action} +
+ ) : null} + + {confidence != null ? ( +
+
+ + اعتماد مدل + + + {confidence}% + +
+ = 80 ? 'success' : confidence >= 60 ? 'warning' : 'error'} + sx={{ height: 8, borderRadius: 999 }} + /> +
+ ) : null} + + {insight.risk_note ? ( + + نکته ریسک: {insight.risk_note} + + ) : null} +
+
+ ) +} + +export default WaterIrrigationInsightCard diff --git a/src/views/dashboards/farm/YieldHarvestPageWrapper.tsx b/src/views/dashboards/farm/YieldHarvestPageWrapper.tsx index 2440cb0..2a11706 100644 --- a/src/views/dashboards/farm/YieldHarvestPageWrapper.tsx +++ b/src/views/dashboards/farm/YieldHarvestPageWrapper.tsx @@ -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 | undefined)?.kpis as + | unknown[] + | undefined)?.length > 0; + +const hasYieldChart = (summary?: YieldHarvestSummary) => + ((summary?.yieldPredictionChart as Record | undefined) + ?.series as unknown[] | undefined)?.length > 0; const YieldHarvestPageWrapper = () => { - const { farmHub } = useFarmHub() - const farmUuid = farmHub?.farm_uuid - const [data, setData] = useState({}) - const [loading, setLoading] = useState(true) + const { farmHub } = useFarmHub(); + const farmUuid = farmHub?.farm_uuid; + const [data, setData] = useState(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 ( - + - ) + ); } return ( - - - {data.yield_prediction && ( - - } /> + + + {hasYieldKpis(data) && ( + + } + /> )} - - - } /> + + + } + /> - - } /> + + + + + + + + + {hasYieldChart(data) && ( + + + } + /> + + + )} - ) -} + ); +}; -export default YieldHarvestPageWrapper +export default YieldHarvestPageWrapper; diff --git a/src/views/dashboards/farm/YieldQualityBandsCard.tsx b/src/views/dashboards/farm/YieldQualityBandsCard.tsx new file mode 100644 index 0000000..053164d --- /dev/null +++ b/src/views/dashboards/farm/YieldQualityBandsCard.tsx @@ -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; +} + +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 ( + + + } + /> + + {bands.map((band) => ( + +
+
+ +
+ {band.label} + + {band.volume} + +
+
+ +
+ + + + + +
+ + سهم از کل برداشت + + + {band.share}% + +
+
+ ))} + + {stats.length > 0 ? ( + + {stats.map((stat) => ( + + + {stat.label} + + + {stat.value} + + + ))} + + ) : null} +
+
+ ); +}; + +export default YieldQualityBandsCard; diff --git a/src/views/dashboards/farm/YieldSeasonHighlightsCard.tsx b/src/views/dashboards/farm/YieldSeasonHighlightsCard.tsx new file mode 100644 index 0000000..a3d244a --- /dev/null +++ b/src/views/dashboards/farm/YieldSeasonHighlightsCard.tsx @@ -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; +} + +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 ( + + + + + + + + {seasonLabel ? ( + + ) : null} + {badges.map((badge) => ( + + ))} + + +
+ + {title} + + + {subtitle} + +
+
+ + + +
+ + {spotlight.title} + + +
+ {spotlight.value} + + {spotlight.caption} + +
+
+
+ + + {metrics.map((metric) => ( + +
+ + + +
+ + {metric.label} + + + {metric.value} + + + {metric.caption} + +
+
+
+ ))} +
+
+
+
+ ); +}; + +export default YieldSeasonHighlightsCard; diff --git a/src/views/dashboards/farm/plantSimulator/PlantSimulator.tsx b/src/views/dashboards/farm/plantSimulator/PlantSimulator.tsx index e2aee09..35360c3 100644 --- a/src/views/dashboards/farm/plantSimulator/PlantSimulator.tsx +++ b/src/views/dashboards/farm/plantSimulator/PlantSimulator.tsx @@ -1,7 +1,7 @@ -'use client' +"use client"; -import { useEffect, useRef, useState, useCallback, memo } from 'react' -import { useTranslations } from 'next-intl' +import { useEffect, useRef, useState, useCallback, memo } from "react"; +import { useTranslations } from "next-intl"; import { Chart as ChartJS, LineElement, @@ -11,196 +11,281 @@ import { Title, Tooltip, Legend, - Filler -} from 'chart.js' -import { Line } from 'react-chartjs-2' -import Card from '@mui/material/Card' -import CardContent from '@mui/material/CardContent' -import Typography from '@mui/material/Typography' -import Box from '@mui/material/Box' -import Grid from '@mui/material/Grid2' -import Button from '@mui/material/Button' + Filler, +} from "chart.js"; +import { Line } from "react-chartjs-2"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Typography from "@mui/material/Typography"; +import Box from "@mui/material/Box"; +import Grid from "@mui/material/Grid2"; +import Button from "@mui/material/Button"; -ChartJS.register(LineElement, PointElement, LinearScale, CategoryScale, Title, Tooltip, Legend, Filler) +ChartJS.register( + LineElement, + PointElement, + LinearScale, + CategoryScale, + Title, + Tooltip, + Legend, + Filler, +); // ─── Types ─────────────────────────────────────────────────────────────────── interface Leaf { - id: number - side: 'left' | 'right' - heightFraction: number - scale: number - swayOffset: number - length: number - branchId: number | null // null = on main stem, number = on a branch + id: number; + side: "left" | "right"; + heightFraction: number; + scale: number; + swayOffset: number; + length: number; + branchId: number | null; // null = on main stem, number = on a branch } interface Branch { - id: number - side: 'left' | 'right' - heightFraction: number // where on stem it starts (0..1) - length: number // branch length px - angle: number // base angle in degrees - scale: number // 0..1 grow-in - swayOffset: number - thickness: number // px + id: number; + side: "left" | "right"; + heightFraction: number; // where on stem it starts (0..1) + length: number; // branch length px + angle: number; // base angle in degrees + scale: number; // 0..1 grow-in + swayOffset: number; + thickness: number; // px } interface Fruit { - id: number - branchId: number | null - leafId: number - side: 'left' | 'right' - scale: number - swayOffset: number - color: string - size: number // varied fruit radius + id: number; + branchId: number | null; + leafId: number; + side: "left" | "right"; + scale: number; + swayOffset: number; + color: string; + size: number; // varied fruit radius } interface PlantState { - height: number - leaves: Leaf[] - branches: Branch[] - fruits: Fruit[] - tick: number - yield: number - yieldRate: number + height: number; + leaves: Leaf[]; + branches: Branch[]; + fruits: Fruit[]; + tick: number; + yield: number; + yieldRate: number; } interface EnvironmentSettings { - light: number - water: number + light: number; + water: number; } // ─── Constants ─────────────────────────────────────────────────────────────── -const MAX_HEIGHT = 280 -const MAX_YIELD = 500 -const SVG_W = 280 -const SVG_H = 400 -const STEM_X = SVG_W / 2 -const BASE_Y = SVG_H - 24 -const LEAF_INTERVAL_PX = 30 -const MAX_LEAVES = 14 -const MAX_BRANCHES = 6 -const FRUIT_COLORS = ['#ef4444', '#f97316', '#eab308', '#f472b6', '#a855f7', '#22c55e'] +const MAX_HEIGHT = 280; +const MAX_YIELD = 500; +const SVG_W = 280; +const SVG_H = 400; +const STEM_X = SVG_W / 2; +const BASE_Y = SVG_H - 24; +const LEAF_INTERVAL_PX = 30; +const MAX_LEAVES = 14; +const MAX_BRANCHES = 6; +const FRUIT_COLORS = [ + "#ef4444", + "#f97316", + "#eab308", + "#f472b6", + "#a855f7", + "#22c55e", +]; // ─── Helper: compute growth speed multiplier from env settings ──────────────── function growthRate(env: EnvironmentSettings, speed: number): number { - const lightFactor = 0.3 + (env.light / 100) * 0.7 - const waterFactor = 0.3 + (env.water / 100) * 0.7 - return speed * lightFactor * waterFactor + const lightFactor = 0.3 + (env.light / 100) * 0.7; + const waterFactor = 0.3 + (env.water / 100) * 0.7; + return speed * lightFactor * waterFactor; } // Yield rate in g/s: leaves and height progress amplify yield production -function computeYieldRate(env: EnvironmentSettings, leafCount: number, heightProgress: number): number { - const lightFactor = 0.2 + (env.light / 100) * 0.8 - const waterFactor = 0.2 + (env.water / 100) * 0.8 - const leafFactor = leafCount / MAX_LEAVES +function computeYieldRate( + env: EnvironmentSettings, + leafCount: number, + heightProgress: number, +): number { + const lightFactor = 0.2 + (env.light / 100) * 0.8; + const waterFactor = 0.2 + (env.water / 100) * 0.8; + const leafFactor = leafCount / MAX_LEAVES; // yield only starts after 20% growth and accelerates with more leaves - const maturityFactor = Math.max(0, (heightProgress - 0.2) / 0.8) - return parseFloat((MAX_YIELD * 0.012 * lightFactor * waterFactor * leafFactor * maturityFactor).toFixed(3)) + const maturityFactor = Math.max(0, (heightProgress - 0.2) / 0.8); + return parseFloat( + ( + MAX_YIELD * + 0.012 * + lightFactor * + waterFactor * + leafFactor * + maturityFactor + ).toFixed(3), + ); } // ─── Helpers: quadratic bezier point / tangent at t ───────────────────────── function qBez(p0: number, p1: number, p2: number, t: number) { - return (1 - t) * (1 - t) * p0 + 2 * (1 - t) * t * p1 + t * t * p2 + return (1 - t) * (1 - t) * p0 + 2 * (1 - t) * t * p1 + t * t * p2; } function qBezTan(p0: number, p1: number, p2: number, t: number) { - return 2 * (1 - t) * (p1 - p0) + 2 * t * (p2 - p1) + return 2 * (1 - t) * (p1 - p0) + 2 * t * (p2 - p1); } // ─── Plant SVG Component ────────────────────────────────────────────────────── -function PlantSVG({ plant, tick, running }: { plant: PlantState; tick: number; running: boolean }) { - const stemTop = BASE_Y - plant.height - const progress = plant.height / MAX_HEIGHT - const stemW = 6 + Math.min(progress * 7, 7) +function PlantSVG({ + plant, + tick, + running, +}: { + plant: PlantState; + tick: number; + running: boolean; +}) { + const stemTop = BASE_Y - plant.height; + const progress = plant.height / MAX_HEIGHT; + const stemW = 6 + Math.min(progress * 7, 7); - const sway = Math.sin(tick / 55) * 3 + const sway = Math.sin(tick / 55) * 3; // Stem control points — quadratic Bezier - const s0x = STEM_X, s0y = BASE_Y - const s1x = STEM_X + sway * 0.4, s1y = stemTop + plant.height * 0.5 - const s2x = STEM_X + sway, s2y = stemTop + const s0x = STEM_X, + s0y = BASE_Y; + const s1x = STEM_X + sway * 0.4, + s1y = stemTop + plant.height * 0.5; + const s2x = STEM_X + sway, + s2y = stemTop; // Get point on main stem at fraction t (0=base, 1=top) const stemPt = (t: number) => ({ x: qBez(s0x, s1x, s2x, t), - y: qBez(s0y, s1y, s2y, t) - }) + y: qBez(s0y, s1y, s2y, t), + }); // Get angle on main stem at t const stemAngle = (t: number) => { - const dx = qBezTan(s0x, s1x, s2x, t) - const dy = qBezTan(s0y, s1y, s2y, t) - return (Math.atan2(dy, dx) * 180) / Math.PI - } + const dx = qBezTan(s0x, s1x, s2x, t); + const dy = qBezTan(s0y, s1y, s2y, t); + return (Math.atan2(dy, dx) * 180) / Math.PI; + }; // Get branch tip position const branchTip = (b: Branch) => { - const base = stemPt(b.heightFraction) - const dir = b.side === 'left' ? -1 : 1 - const bSway = Math.sin(tick / 30 + b.swayOffset) * 4 * b.scale - const rad = ((b.angle + bSway) * Math.PI) / 180 - const len = b.length * b.scale + const base = stemPt(b.heightFraction); + const dir = b.side === "left" ? -1 : 1; + const bSway = Math.sin(tick / 30 + b.swayOffset) * 4 * b.scale; + const rad = ((b.angle + bSway) * Math.PI) / 180; + const len = b.length * b.scale; return { bx: base.x, by: base.y, tx: base.x + Math.cos(rad) * len * dir, ty: base.y + Math.sin(rad) * len, cx: base.x + Math.cos(rad) * len * 0.5 * dir, - cy: base.y + Math.sin(rad) * len * 0.35 - 8 * b.scale - } - } + cy: base.y + Math.sin(rad) * len * 0.35 - 8 * b.scale, + }; + }; return ( - + - - - - + + + + - - - + + + - - - + + + - - - + + + + + + - - + + {/* ── Floating pollen particles ── */} - {running && progress > 0.35 && [0, 1, 2, 3, 4, 5].map(i => { - const px = STEM_X + Math.sin(tick / 28 + i * 1.1) * 40 - const cycle = (tick * 0.35 + i * 30) % 80 - const py = stemTop - 12 - cycle - return - })} + {running && + progress > 0.35 && + [0, 1, 2, 3, 4, 5].map((i) => { + const px = STEM_X + Math.sin(tick / 28 + i * 1.1) * 40; + const cycle = (tick * 0.35 + i * 30) % 80; + const py = stemTop - 12 - cycle; + return ( + + ); + })} {/* ── Soil mound ── */} - - {[-24, -10, 4, 16, 28].map(dx => ( - + + {[-24, -10, 4, 16, 28].map((dx) => ( + ))} {/* Small grass tufts */} {[-30, -14, 18, 32].map((dx, i) => ( - + stroke="#5a9a5a" + strokeWidth={0.8} + fill="none" + opacity={0.4} + /> ))} {/* ── Roots ── */} @@ -210,11 +295,16 @@ function PlantSVG({ plant, tick, running }: { plant: PlantState; tick: number; r { dx: -22, dy: 22, w: 2.2, cx: -10, cy: 8 }, { dx: 18, dy: 20, w: 1.8, cx: 8, cy: 10 }, { dx: -8, dy: 24, w: 1.2, cx: -4, cy: 12 }, - { dx: 12, dy: 18, w: 1, cx: 6, cy: 14 } + { dx: 12, dy: 18, w: 1, cx: 6, cy: 14 }, ].map((r, i) => ( - + stroke="#3b2a12" + strokeWidth={r.w} + fill="none" + strokeLinecap="round" + /> ))} )} @@ -222,180 +312,287 @@ function PlantSVG({ plant, tick, running }: { plant: PlantState; tick: number; r {/* ── Main stem (quadratic bezier) ── */} {plant.height > 0 && ( <> - + {/* Stem highlight vein */} - + {/* Stem node bumps — small circles at branch attachment points */} - {plant.branches.map(b => { - const pt = stemPt(b.heightFraction) - return + {plant.branches.map((b) => { + const pt = stemPt(b.heightFraction); + return ( + + ); })} )} {/* ── Branches (curved) ── */} - {plant.branches.map(b => { - const { bx, by, tx, ty, cx, cy } = branchTip(b) - const bw = b.thickness * b.scale + {plant.branches.map((b) => { + const { bx, by, tx, ty, cx, cy } = branchTip(b); + const bw = b.thickness * b.scale; return ( - + {/* branch highlight */} - + {/* small thorn/bud at tip */} - + - ) + ); })} {/* ── Leaves ── */} - {plant.leaves.map(leaf => { - let ax: number, ay: number + {plant.leaves.map((leaf) => { + let ax: number, ay: number; if (leaf.branchId != null) { - const branch = plant.branches.find(b => b.id === leaf.branchId) - if (!branch) return null - const tip = branchTip(branch) - ax = tip.tx - ay = tip.ty + const branch = plant.branches.find((b) => b.id === leaf.branchId); + if (!branch) return null; + const tip = branchTip(branch); + ax = tip.tx; + ay = tip.ty; } else { - const pt = stemPt(leaf.heightFraction) - ax = pt.x - ay = pt.y + const pt = stemPt(leaf.heightFraction); + ax = pt.x; + ay = pt.y; } - const swayA = Math.sin(tick / 20 + leaf.swayOffset) * 6 - const dir = leaf.side === 'left' ? -1 : 1 - const rot = dir * 35 + swayA - const lx = leaf.length * leaf.scale - const ly = leaf.length * 0.4 * leaf.scale - const hue = 110 + leaf.id * 3 - const lit = 30 + leaf.id * 1.8 - const c1 = `hsl(${hue},65%,${lit}%)` - const c2 = `hsl(${hue},55%,${lit + 14}%)` + const swayA = Math.sin(tick / 20 + leaf.swayOffset) * 6; + const dir = leaf.side === "left" ? -1 : 1; + const rot = dir * 35 + swayA; + const lx = leaf.length * leaf.scale; + const ly = leaf.length * 0.4 * leaf.scale; + const hue = 110 + leaf.id * 3; + const lit = 30 + leaf.id * 1.8; + const c1 = `hsl(${hue},65%,${lit}%)`; + const c2 = `hsl(${hue},55%,${lit + 14}%)`; return ( - + {/* Leaf shape — custom path for more natural look */} + fill={c1} + /> {/* Midrib */} - + {/* Side veins */} {[0.25, 0.45, 0.65, 0.8].map((t, vi) => { - const vx = dir * lx * t * 0.95 - const vy = -ly * 0.12 * t + const vx = dir * lx * t * 0.95; + const vy = -ly * 0.12 * t; return ( - - ) + + ); })} - ) + ); })} {/* ── Fruits on branches and stem ── */} - {plant.fruits.map(fruit => { - const leaf = plant.leaves.find(l => l.id === fruit.leafId) - if (!leaf) return null + {plant.fruits.map((fruit) => { + const leaf = plant.leaves.find((l) => l.id === fruit.leafId); + if (!leaf) return null; - let ox: number, oy: number + let ox: number, oy: number; if (fruit.branchId != null) { - const branch = plant.branches.find(b => b.id === fruit.branchId) - if (!branch) return null - const tip = branchTip(branch) - ox = tip.tx - oy = tip.ty + const branch = plant.branches.find((b) => b.id === fruit.branchId); + if (!branch) return null; + const tip = branchTip(branch); + ox = tip.tx; + oy = tip.ty; } else { - const pt = stemPt(leaf.heightFraction) - ox = pt.x - oy = pt.y + const pt = stemPt(leaf.heightFraction); + ox = pt.x; + oy = pt.y; } - const fSway = Math.sin(tick / 25 + fruit.swayOffset) * 3 - const dir = fruit.side === 'left' ? -1 : 1 - const fr = fruit.size * fruit.scale - const fx = ox + dir * (12 + fr) + fSway - const fy = oy + 4 * fruit.scale + const fSway = Math.sin(tick / 25 + fruit.swayOffset) * 3; + const dir = fruit.side === "left" ? -1 : 1; + const fr = fruit.size * fruit.scale; + const fx = ox + dir * (12 + fr) + fSway; + const fy = oy + 4 * fruit.scale; return ( - + {/* Hanging stem */} - + {/* Fruit body */} {/* Depth gradient */} - + {/* Shine */} - + {/* Calyx (small leaves on top) */} - - + + - ) + ); })} {/* ── Top bud → flower ── */} - {progress > 0.55 && (() => { - const bs = Math.min((progress - 0.55) / 0.3, 1) - const tx = s2x - const ty = s2y - 4 - const ps = Math.sin(tick / 32) * 5 + {progress > 0.55 && + (() => { + const bs = Math.min((progress - 0.55) / 0.3, 1); + const tx = s2x; + const ty = s2y - 4; + const ps = Math.sin(tick / 32) * 5; - return ( - - {/* Sepals (green base petals) */} - {[0, 72, 144, 216, 288].map(deg => { - const r = ((deg + ps * 0.5) * Math.PI) / 180 - return ( - - ) - })} - {/* Petals — larger, appear after 80% */} - {progress > 0.8 && [0, 45, 90, 135, 180, 225, 270, 315].map(deg => { - const petalScale = Math.min((progress - 0.8) / 0.15, 1) - const r = ((deg + ps) * Math.PI) / 180 - const pd = 10 * petalScale * bs - return ( - - ) - })} - {/* Centre */} - - - {/* Orbiting pollen */} - {progress > 0.88 && [0, 1, 2, 3, 4, 5].map(i => { - const a = (tick / 18 + i * 1.05) % (Math.PI * 2) - const or = 13 * bs - return - })} - - ) - })()} + return ( + + {/* Sepals (green base petals) */} + {[0, 72, 144, 216, 288].map((deg) => { + const r = ((deg + ps * 0.5) * Math.PI) / 180; + return ( + + ); + })} + {/* Petals — larger, appear after 80% */} + {progress > 0.8 && + [0, 45, 90, 135, 180, 225, 270, 315].map((deg) => { + const petalScale = Math.min((progress - 0.8) / 0.15, 1); + const r = ((deg + ps) * Math.PI) / 180; + const pd = 10 * petalScale * bs; + return ( + + ); + })} + {/* Centre */} + + + {/* Orbiting pollen */} + {progress > 0.88 && + [0, 1, 2, 3, 4, 5].map((i) => { + const a = (tick / 18 + i * 1.05) % (Math.PI * 2); + const or = 13 * bs; + return ( + + ); + })} + + ); + })()} - ) + ); } // ─── Growth Chart ───────────────────────────────────────────────────────────── @@ -404,14 +601,14 @@ const GrowthChart = memo(function GrowthChart({ heightHistory, leafHistory, yieldHistory, - yieldRateHistory + yieldRateHistory, }: { - heightHistory: number[] - leafHistory: number[] - yieldHistory: number[] - yieldRateHistory: number[] + heightHistory: number[]; + leafHistory: number[]; + yieldHistory: number[]; + yieldRateHistory: number[]; }) { - const t = useTranslations('plantSimulator') + const t = useTranslations("plantSimulator"); const chartOptions = { responsive: true, @@ -421,205 +618,226 @@ const GrowthChart = memo(function GrowthChart({ legend: { labels: { font: { size: 11 } } }, title: { display: true, - text: t('chartTitle'), - font: { size: 14 } - } + text: t("chartTitle"), + font: { size: 14 }, + }, }, scales: { x: { - ticks: { maxTicksLimit: 8 } + ticks: { maxTicksLimit: 8 }, }, yHeight: { - type: 'linear' as const, - position: 'left' as const, + type: "linear" as const, + position: "left" as const, min: 0, max: MAX_HEIGHT, - title: { display: true, text: t('chartHeight') } + title: { display: true, text: t("chartHeight") }, }, yLeaf: { - type: 'linear' as const, - position: 'right' as const, + type: "linear" as const, + position: "right" as const, min: 0, max: MAX_LEAVES, grid: { display: false }, - title: { display: true, text: t('chartLeaves') } + title: { display: true, text: t("chartLeaves") }, }, yYield: { - type: 'linear' as const, - position: 'left' as const, + type: "linear" as const, + position: "left" as const, min: 0, max: MAX_YIELD, display: false, - grid: { display: false } + grid: { display: false }, }, yYieldRate: { - type: 'linear' as const, - position: 'right' as const, + type: "linear" as const, + position: "right" as const, min: 0, display: false, - grid: { display: false } - } - } - } + grid: { display: false }, + }, + }, + }; - const labels = heightHistory.map((_, i) => `${i}s`) + const labels = heightHistory.map((_, i) => `${i}s`); const data = { labels, datasets: [ { - label: t('chartHeightPx'), + label: t("chartHeightPx"), data: heightHistory, - borderColor: '#4a7c59', - backgroundColor: 'rgba(74,124,89,0.10)', + borderColor: "#4a7c59", + backgroundColor: "rgba(74,124,89,0.10)", fill: true, tension: 0.4, pointRadius: 0, - yAxisID: 'yHeight' + yAxisID: "yHeight", }, { - label: t('chartLeafCount'), + label: t("chartLeafCount"), data: leafHistory, - borderColor: '#f9c74f', - backgroundColor: 'rgba(249,199,79,0.10)', + borderColor: "#f9c74f", + backgroundColor: "rgba(249,199,79,0.10)", fill: true, tension: 0.4, pointRadius: 0, - yAxisID: 'yLeaf' + yAxisID: "yLeaf", }, { - label: t('chartYield'), + label: t("chartYield"), data: yieldHistory, - borderColor: '#f97316', - backgroundColor: 'rgba(249,115,22,0.10)', + borderColor: "#f97316", + backgroundColor: "rgba(249,115,22,0.10)", fill: true, tension: 0.4, pointRadius: 0, - yAxisID: 'yYield' + yAxisID: "yYield", }, { - label: t('chartYieldRate'), + label: t("chartYieldRate"), data: yieldRateHistory, - borderColor: '#a78bfa', - backgroundColor: 'rgba(167,139,250,0.10)', + borderColor: "#a78bfa", + backgroundColor: "rgba(167,139,250,0.10)", fill: true, tension: 0.4, pointRadius: 0, borderDash: [4, 3], - yAxisID: 'yYieldRate' - } - ] - } + yAxisID: "yYieldRate", + }, + ], + }; return ( - ) -}) + ); +}); // ─── Main Component ─────────────────────────────────────────────────────────── -const INIT_PLANT: PlantState = { height: 0, leaves: [], branches: [], fruits: [], tick: 0, yield: 0, yieldRate: 0 } +const INIT_PLANT: PlantState = { + height: 0, + leaves: [], + branches: [], + fruits: [], + tick: 0, + yield: 0, + yieldRate: 0, +}; export default function PlantSimulator() { - const [running, setRunning] = useState(false) - const [speed, setSpeed] = useState(1.5) - const [env, setEnv] = useState({ light: 75, water: 65 }) + const [running, setRunning] = useState(false); + const [speed, setSpeed] = useState(1.5); + const [env, setEnv] = useState({ light: 75, water: 65 }); - const [plant, setPlant] = useState(INIT_PLANT) - const [heightHistory, setHeightHistory] = useState([0]) - const [leafHistory, setLeafHistory] = useState([0]) - const [yieldHistory, setYieldHistory] = useState([0]) - const [yieldRateHistory, setYieldRateHistory] = useState([0]) + const [plant, setPlant] = useState(INIT_PLANT); + const [heightHistory, setHeightHistory] = useState([0]); + const [leafHistory, setLeafHistory] = useState([0]); + const [yieldHistory, setYieldHistory] = useState([0]); + const [yieldRateHistory, setYieldRateHistory] = useState([0]); - const intervalRef = useRef | null>(null) - const tickRef = useRef(0) - const historyIntervalRef = useRef | null>(null) - const plantRef = useRef(plant) - plantRef.current = plant + const intervalRef = useRef | null>(null); + const tickRef = useRef(0); + const historyIntervalRef = useRef | null>( + null, + ); + const plantRef = useRef(plant); + plantRef.current = plant; const reset = useCallback(() => { - setPlant(INIT_PLANT) - setHeightHistory([0]) - setLeafHistory([0]) - setYieldHistory([0]) - setYieldRateHistory([0]) - tickRef.current = 0 - }, []) + setPlant(INIT_PLANT); + setHeightHistory([0]); + setLeafHistory([0]); + setYieldHistory([0]); + setYieldRateHistory([0]); + tickRef.current = 0; + }, []); // Stop simulation when plant reaches max height useEffect(() => { - if (plant.height >= MAX_HEIGHT && running) setRunning(false) - }, [plant.height, running]) + if (plant.height >= MAX_HEIGHT && running) setRunning(false); + }, [plant.height, running]); // Main simulation tick at ~20fps (reduced from 30fps to prevent browser overload) useEffect(() => { if (!running) { - if (intervalRef.current) clearInterval(intervalRef.current) - return + if (intervalRef.current) clearInterval(intervalRef.current); + return; } intervalRef.current = setInterval(() => { - tickRef.current += 1 - const t = tickRef.current + tickRef.current += 1; + const t = tickRef.current; - setPlant(prev => { - const rate = growthRate(env, speed) - const newHeight = Math.min(prev.height + rate * 0.4, MAX_HEIGHT) - const hp = newHeight / MAX_HEIGHT + setPlant((prev) => { + const rate = growthRate(env, speed); + const newHeight = Math.min(prev.height + rate * 0.4, MAX_HEIGHT); + const hp = newHeight / MAX_HEIGHT; // ── Branches: appear after 30% growth ── - const newBranches = [...prev.branches] - const expectedBranches = Math.min(Math.floor((hp - 0.3) / 0.12), MAX_BRANCHES) + const newBranches = [...prev.branches]; + const expectedBranches = Math.min( + Math.floor((hp - 0.3) / 0.12), + MAX_BRANCHES, + ); while (newBranches.length < Math.max(0, expectedBranches)) { - const bid = newBranches.length + const bid = newBranches.length; newBranches.push({ id: bid, - side: bid % 2 === 0 ? 'left' : 'right', + side: bid % 2 === 0 ? "left" : "right", heightFraction: 0.25 + bid * 0.11, length: 30 + Math.random() * 25, angle: -(25 + Math.random() * 20), scale: 0, swayOffset: Math.random() * Math.PI * 2, - thickness: 2.5 + Math.random() * 1.5 - }) + thickness: 2.5 + Math.random() * 1.5, + }); } - const grownBranches = newBranches.map(b => ({ + const grownBranches = newBranches.map((b) => ({ ...b, - scale: Math.min(b.scale + 0.012, 1) - })) + scale: Math.min(b.scale + 0.012, 1), + })); // ── Leaves: on main stem + on branch tips ── - const newLeaves = [...prev.leaves] - const expectedStemLeaves = Math.min(Math.floor(newHeight / LEAF_INTERVAL_PX), MAX_LEAVES) + const newLeaves = [...prev.leaves]; + const expectedStemLeaves = Math.min( + Math.floor(newHeight / LEAF_INTERVAL_PX), + MAX_LEAVES, + ); // Stem leaves (fixed: use 'added' counter; nextId-newLeaves.length was always 0 → infinite loop) - const stemLeafCount = newLeaves.filter(l => l.branchId === null).length - let nextId = newLeaves.length - let added = 0 + const stemLeafCount = newLeaves.filter( + (l) => l.branchId === null, + ).length; + let nextId = newLeaves.length; + let added = 0; while (stemLeafCount + added < expectedStemLeaves) { - const idx = stemLeafCount + added + const idx = stemLeafCount + added; newLeaves.push({ id: nextId, - side: idx % 2 === 0 ? 'left' : 'right', + side: idx % 2 === 0 ? "left" : "right", heightFraction: (idx + 1) / (expectedStemLeaves + 1), scale: 0, swayOffset: Math.random() * Math.PI * 2, length: 16 + Math.random() * 14, - branchId: null - }) - nextId++ - added++ + branchId: null, + }); + nextId++; + added++; } // Branch-tip leaves (1 per mature branch, once branch scale > 0.6) for (const b of grownBranches) { if (b.scale > 0.6) { - const hasLeaf = newLeaves.some(l => l.branchId === b.id) + const hasLeaf = newLeaves.some((l) => l.branchId === b.id); if (!hasLeaf) { newLeaves.push({ id: nextId++, @@ -628,50 +846,57 @@ export default function PlantSimulator() { scale: 0, swayOffset: Math.random() * Math.PI * 2, length: 14 + Math.random() * 10, - branchId: b.id - }) + branchId: b.id, + }); } } } - const grownLeaves = newLeaves.map(l => ({ + const grownLeaves = newLeaves.map((l) => ({ ...l, - scale: Math.min(l.scale + 0.018, 1) - })) + scale: Math.min(l.scale + 0.018, 1), + })); // ── Yield ── - const currentYieldRate = computeYieldRate(env, grownLeaves.length, hp) - const newYield = Math.min(prev.yield + currentYieldRate * 0.033, MAX_YIELD) + const currentYieldRate = computeYieldRate(env, grownLeaves.length, hp); + const newYield = Math.min( + prev.yield + currentYieldRate * 0.033, + MAX_YIELD, + ); // ── Fruits: appear after 45% on branches, then some on stem ── - const newFruits = [...prev.fruits] - const maxFruits = Math.min(Math.floor((hp - 0.45) / 0.08), grownBranches.length + 3) - let fNextId = newFruits.length + const newFruits = [...prev.fruits]; + const maxFruits = Math.min( + Math.floor((hp - 0.45) / 0.08), + grownBranches.length + 3, + ); + let fNextId = newFruits.length; while (newFruits.length < Math.max(0, maxFruits)) { - const fid = fNextId++ + const fid = fNextId++; // First fruits on branches, then on stem leaves - const onBranch = fid < grownBranches.length - const targetBranch = onBranch ? grownBranches[fid] : null + const onBranch = fid < grownBranches.length; + const targetBranch = onBranch ? grownBranches[fid] : null; const targetLeaf = onBranch - ? grownLeaves.find(l => l.branchId === targetBranch!.id) || grownLeaves[fid] - : grownLeaves[Math.min(fid, grownLeaves.length - 1)] - if (!targetLeaf) break + ? grownLeaves.find((l) => l.branchId === targetBranch!.id) || + grownLeaves[fid] + : grownLeaves[Math.min(fid, grownLeaves.length - 1)]; + if (!targetLeaf) break; newFruits.push({ id: fid, branchId: onBranch ? targetBranch!.id : null, leafId: targetLeaf.id, - side: targetLeaf.side === 'left' ? 'right' : 'left', + side: targetLeaf.side === "left" ? "right" : "left", scale: 0, swayOffset: Math.random() * Math.PI * 2, color: FRUIT_COLORS[fid % FRUIT_COLORS.length], - size: 5 + Math.random() * 4 - }) + size: 5 + Math.random() * 4, + }); } - const grownFruits = newFruits.map(f => ({ + const grownFruits = newFruits.map((f) => ({ ...f, - scale: Math.min(f.scale + 0.014, 1) - })) + scale: Math.min(f.scale + 0.014, 1), + })); return { height: newHeight, @@ -680,69 +905,84 @@ export default function PlantSimulator() { fruits: grownFruits, tick: t, yield: newYield, - yieldRate: currentYieldRate - } - }) - }, 50) + yieldRate: currentYieldRate, + }; + }); + }, 50); return () => { - if (intervalRef.current) clearInterval(intervalRef.current) - } - }, [running, speed, env]) + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [running, speed, env]); // History logging every 1 second (reads from plantRef to avoid setPlant side-effects) useEffect(() => { if (!running) { - if (historyIntervalRef.current) clearInterval(historyIntervalRef.current) - return + if (historyIntervalRef.current) clearInterval(historyIntervalRef.current); + return; } historyIntervalRef.current = setInterval(() => { - const p = plantRef.current - setHeightHistory(h => [...h.slice(-59), Math.round(p.height)]) - setLeafHistory(l => [...l.slice(-59), p.leaves.length]) - setYieldHistory(y => [...y.slice(-59), parseFloat(p.yield.toFixed(1))]) - setYieldRateHistory(r => [...r.slice(-59), p.yieldRate]) - }, 1000) + const p = plantRef.current; + setHeightHistory((h) => [...h.slice(-59), Math.round(p.height)]); + setLeafHistory((l) => [...l.slice(-59), p.leaves.length]); + setYieldHistory((y) => [...y.slice(-59), parseFloat(p.yield.toFixed(1))]); + setYieldRateHistory((r) => [...r.slice(-59), p.yieldRate]); + }, 1000); return () => { - if (historyIntervalRef.current) clearInterval(historyIntervalRef.current) - } - }, [running]) + if (historyIntervalRef.current) clearInterval(historyIntervalRef.current); + }; + }, [running]); - const t = useTranslations('plantSimulator') - const isFinished = plant.height >= MAX_HEIGHT + const t = useTranslations("plantSimulator"); + const isFinished = plant.height >= MAX_HEIGHT; - const statItems: { value: string | number; label: string; color: 'success' | 'warning' | 'error' | 'secondary' }[] = [ - { value: Math.round(plant.height), label: t('height'), color: 'success' }, - { value: plant.leaves.length, label: t('leaves'), color: 'warning' }, - { value: plant.branches.length, label: t('branches'), color: 'success' }, - { value: plant.fruits.length, label: t('fruits'), color: 'error' }, - { value: plant.yield.toFixed(1), label: t('yield'), color: 'warning' }, - { value: plant.yieldRate.toFixed(2), label: t('yieldRate'), color: 'secondary' } - ] + const statItems: { + value: string | number; + label: string; + color: "success" | "warning" | "error" | "secondary"; + }[] = [ + { value: Math.round(plant.height), label: t("height"), color: "success" }, + { value: plant.leaves.length, label: t("leaves"), color: "warning" }, + { value: plant.branches.length, label: t("branches"), color: "success" }, + { value: plant.fruits.length, label: t("fruits"), color: "error" }, + { value: plant.yield.toFixed(1), label: t("yield"), color: "warning" }, + { + value: plant.yieldRate.toFixed(2), + label: t("yieldRate"), + color: "secondary", + }, + ]; + + const primaryPanelSx = { + height: "100%", + minHeight: { xs: "auto", lg: 780 }, + }; return ( - - - 🌱 {t('title')} - - - - {/* ── Left: Plant visualization ── */} - - - + + + {/* ── Plant visualization ── */} + + + - + {statItems.map((item, idx) => ( - - + + {item.value} - + {item.label} @@ -751,103 +991,22 @@ export default function PlantSimulator() { {isFinished && ( - - 🌼 {t('maxGrowthReached')} + + 🌼 {t("maxGrowthReached")} )} - - {/* Controls */} - - - - {t('controls')} - - - - - - - - - - {t('growthSpeed')} - {speed.toFixed(1)}× - - setSpeed(Number(e.target.value))} - className='w-full' - /> -
- - - - ☀️ {t('light')} - {env.light}% - - setEnv(prev => ({ ...prev, light: Number(e.target.value) }))} - className='w-full' - /> - - - - - 💧 {t('water')} - {env.water}% - - setEnv(prev => ({ ...prev, water: Number(e.target.value) }))} - className='w-full' - /> - - - - - {t('effectiveRate')}{' '} - - {growthRate(env, speed).toFixed(2)}× - - - - - - {/* ── Right: Chart ── */} - - - + {/* ── Chart ── */} + + + - + - - - {t('progressGrowth')} + + + {t("progressGrowth")} - + - + {Math.round((plant.height / MAX_HEIGHT) * 100)}% - - - {t('lightStatus')} + + + {t("lightStatus")} - + - + {env.light}% - - - {t('waterStatus')} + + + {t("waterStatus")} - + - + {env.water}% - - - {t('yieldStatus')} + + + {t("yieldStatus")} - + - - + + {plant.yield.toFixed(1)}g - + {plant.yieldRate.toFixed(3)} g/s @@ -927,9 +1130,136 @@ export default function PlantSimulator() { - - - {t('description')} + + + {t("description")} + + + + + + + + + {/* ── Controls ── */} + + + + + {t("controls")} + + + + + + + + + + {t("growthSpeed")} + {speed.toFixed(1)}× + + setSpeed(Number(e.target.value))} + className="w-full" + /> + + + + + ☀️ {t("light")} + {env.light}% + + + setEnv((prev) => ({ + ...prev, + light: Number(e.target.value), + })) + } + className="w-full" + /> + + + + + 💧 {t("water")} + {env.water}% + + + setEnv((prev) => ({ + ...prev, + water: Number(e.target.value), + })) + } + className="w-full" + /> + + + + + {t("effectiveRate")}{" "} + + {growthRate(env, speed).toFixed(2)}× + @@ -937,5 +1267,5 @@ export default function PlantSimulator() {
- ) + ); }