This commit is contained in:
2026-05-05 23:54:24 +03:30
parent 04da5ff2fc
commit 4b0f5601fc
23 changed files with 1486 additions and 316 deletions
+82
View File
@@ -0,0 +1,82 @@
# گزارش جای خالی کارت‌های داشبورد
## خلاصه
در داشبورد فعلی بعضی اسلات‌ها `Quick Access` دارند اما خود کارت رندر نمی‌شود.
علت اصلی این است که wrapper اسلات را می‌سازد، ولی بعضی کامپوننت‌ها وقتی داده‌ی کافی ندارند `return null` می‌کنند.
## کارت‌های خالی فعلی
### 1) `sensorValuesList`
- **ردیف:** `sensorMonitoring`
- **جای کارت:** ستون چپ ردیف سنسور
- **Route:** `/solid-sensor`
- **وضعیت:** فقط دکمه `دسترسی سریع` دیده می‌شود، خود کارت خالی است
- **علت فنی:** اگر `data.sensors.length === 0` باشد، کامپوننت رندر نمی‌شود
- **مرجع:** `src/views/dashboards/farm/SensorValuesList.tsx:30`
### 2) `sensorRadarChart`
- **ردیف:** `sensorMonitoring`
- **جای کارت:** ستون راست ردیف سنسور
- **Route:** `/solid-sensor`
- **وضعیت:** فقط دکمه `دسترسی سریع` دیده می‌شود، خود کارت خالی است
- **علت فنی:** اگر `data.series.length === 0` باشد، کامپوننت رندر نمی‌شود
- **مرجع:** `src/views/dashboards/farm/SensorRadarChart.tsx:30`
### 3) `sensorComparisonChart`
- **ردیف:** `sensorCharts`
- **جای کارت:** ستون بزرگ سمت چپ
- **Route:** `/solid-sensor`
- **وضعیت:** فقط دکمه `دسترسی سریع` دیده می‌شود، خود کارت خالی است
- **علت فنی:** اگر `data.series.length === 0` باشد، کامپوننت رندر نمی‌شود
- **مرجع:** `src/views/dashboards/farm/SensorComparisonChart.tsx:33`
## کارت‌هایی که ممکن است در شرایط خاص خالی شوند
این‌ها در HTML ارسالی شما فعلاً دیده می‌شوند، ولی در صورت خالی بودن داده می‌توانند ناپدید شوند:
### `waterNeedPrediction`
- **شرط خالی شدن:** `series.length === 0`
- **مرجع:** `src/views/dashboards/farm/WaterNeedPrediction.tsx:37`
### `soilMoistureHeatmap`
- **شرط خالی شدن:** `series.length === 0`
- **مرجع:** `src/views/dashboards/farm/SoilMoistureHeatmap.tsx:37`
### `yieldPredictionChart`
- **شرط خالی شدن:** `series.length === 0`
- **مرجع:** `src/views/dashboards/farm/YieldPredictionChart.tsx:57`
### `recommendationsList`
- **شرط خالی شدن:** `recommendations.length === 0`
- **مرجع:** `src/views/dashboards/farm/RecommendationsList.tsx:32`
## دلیل اینکه فقط آیکن دیده می‌شود
Wrapper همیشه این دکمه را رندر می‌کند:
- `src/views/dashboards/farm/FarmDashboardWrapper.tsx:921`
اما خود کارت از اینجا `null` برمی‌گرداند:
- `src/views/dashboards/farm/FarmDashboardWrapper.tsx:478`
یعنی:
- اسلات گرید وجود دارد
- دکمه quick access وجود دارد
- ولی محتوای کارت به خاطر داده‌ی خالی رندر نمی‌شود
## نتیجه نهایی
بر اساس HTML فعلی، **سه جای خالی قطعی** در داشبورد وجود دارد:
1. `sensorValuesList`
2. `sensorRadarChart`
3. `sensorComparisonChart`
## پیشنهاد اصلاح
بهترین اصلاح این است که به‌جای `return null`:
- یک empty state واقعی داخل کارت نمایش داده شود
- یا در `FarmDashboardWrapper` وقتی داده‌ی کارت خالی است، اصلاً `Quick Access` هم رندر نشود
اگر خواستی، قدم بعدی را هم انجام می‌دهم و همین سه کارت خالی را با `empty state card` درست می‌کنم.
@@ -1,7 +1,5 @@
import { apiClient } from '../client' import { apiClient } from '../client'
import type { ApiError } from '../client'
const DETECTION_PREFIX = '/api/pest-detection'
const DISEASE_PREFIX = '/api/pest-disease' const DISEASE_PREFIX = '/api/pest-disease'
export interface RiskCard { export interface RiskCard {
@@ -29,12 +27,6 @@ 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
@@ -92,29 +84,10 @@ function toKpiCard(
export const pestDetectionDomainService = { export const pestDetectionDomainService = {
async getRiskSummary(farmUuid: string): Promise<PestRiskSummary> { async getRiskSummary(farmUuid: string): Promise<PestRiskSummary> {
let res: ApiResponse<Record<string, unknown>> | Record<string, unknown> const res = await apiClient.post<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
`${DISEASE_PREFIX}/risk-summary/`,
try { { farm_uuid: farmUuid }
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 diseaseRisk = (data?.diseaseRisk as Record<string, unknown> | undefined) ?? (data?.disease_risk as Record<string, unknown> | undefined)
+235 -31
View File
@@ -1,7 +1,7 @@
import { apiClient } from "../client"; import { apiClient } from "../client";
import { sensorExternalApiService } from "./sensorExternalApiService";
const SUMMARY_PREFIX = "/api/sensor-7-in-1"; const DEVICE_HUB_PREFIX = "/api/device-hub";
const SENSORS_PREFIX = "/api/sensors";
export interface ApiResponse<T> { export interface ApiResponse<T> {
code: number; code: number;
@@ -58,6 +58,35 @@ export interface Sensor7SummaryData {
soilMoistureHeatmap?: Record<string, unknown> | null; soilMoistureHeatmap?: Record<string, unknown> | null;
} }
export interface DeviceCatalogMeta {
uuid: string;
code: string;
name: string;
device_communication_type?: string | null;
}
export interface DeviceDetailResponse {
uuid: string;
physical_device_uuid: string;
name: string;
device_catalog?: DeviceCatalogMeta | null;
specifications?: Record<string, unknown> | null;
power_source?: Record<string, unknown> | null;
last_payload_at?: string | null;
}
export interface DeviceLatestPayloadResponse {
raw_payload?: Record<string, unknown> | null;
normalized_payload?: Record<string, unknown> | null;
readings?: unknown[];
last_payload_at?: string | null;
}
export interface DeviceCodesResponse {
physical_device_uuid: string;
device_codes: string[];
}
const EMPTY_COMPARISON_CHART: SensorComparisonChartResponse = { const EMPTY_COMPARISON_CHART: SensorComparisonChartResponse = {
currentValue: 0, currentValue: 0,
vsLastWeek: "+0.0%", vsLastWeek: "+0.0%",
@@ -74,12 +103,45 @@ const EMPTY_VALUES_LIST: SensorValuesListResponse = {
sensors: [], sensors: [],
}; };
const primaryDevicePromiseCache = new Map<string, Promise<string | null>>();
const deviceCodesPromiseCache = new Map<string, Promise<DeviceCodesResponse>>();
function extract<T>(response: ApiResponse<T> | T): T { function extract<T>(response: ApiResponse<T> | T): T {
return response && typeof response === "object" && "data" in response return response && typeof response === "object" && "data" in response
? (response as ApiResponse<T>).data ? (response as ApiResponse<T>).data
: (response as T); : (response as T);
} }
function buildDeviceHubUrl(physicalDeviceUuid: string, suffix = ""): string {
const base = `${DEVICE_HUB_PREFIX}/devices/${encodeURIComponent(physicalDeviceUuid)}/`;
return suffix ? `${base}${suffix}` : base;
}
function buildDeviceHubRequestUrl(
physicalDeviceUuid: string,
suffix = "",
queryParams?: Record<string, string | undefined>,
): string {
const baseUrl = buildDeviceHubUrl(physicalDeviceUuid, suffix);
if (!queryParams) {
return baseUrl;
}
const searchParams = new URLSearchParams();
Object.entries(queryParams).forEach(([key, value]) => {
if (value) {
searchParams.set(key, value);
}
});
const queryString = searchParams.toString();
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
}
function normalizeSeries(series: unknown): SensorChartSeries[] { function normalizeSeries(series: unknown): SensorChartSeries[] {
if (!Array.isArray(series)) return []; if (!Array.isArray(series)) return [];
@@ -171,10 +233,134 @@ export function normalizeValuesListResponse(
}; };
} }
function normalizeDeviceCodesResponse(
data?: Partial<DeviceCodesResponse> | null,
): DeviceCodesResponse {
if (!data || typeof data !== "object") {
return {
physical_device_uuid: "",
device_codes: [],
};
}
return {
physical_device_uuid:
typeof data.physical_device_uuid === "string" ? data.physical_device_uuid : "",
device_codes: Array.isArray(data.device_codes)
? data.device_codes.filter(
(value): value is string => typeof value === "string" && value.length > 0,
)
: [],
};
}
async function getCachedDeviceCodes(
physicalDeviceUuid: string,
): Promise<DeviceCodesResponse> {
const cached = deviceCodesPromiseCache.get(physicalDeviceUuid);
if (cached) {
return cached;
}
const request = apiClient
.get<ApiResponse<DeviceCodesResponse> | DeviceCodesResponse>(
buildDeviceHubUrl(physicalDeviceUuid, "device-codes/"),
)
.then((response) => normalizeDeviceCodesResponse(extract(response)))
.catch((error) => {
deviceCodesPromiseCache.delete(physicalDeviceUuid);
throw error;
});
deviceCodesPromiseCache.set(physicalDeviceUuid, request);
return request;
}
async function resolveDeviceCode(
physicalDeviceUuid: string,
deviceCode?: string,
): Promise<string | undefined> {
if (deviceCode) {
return deviceCode;
}
try {
const response = await getCachedDeviceCodes(physicalDeviceUuid);
return response.device_codes[0];
} catch {
return undefined;
}
}
export const sensor7Service = { export const sensor7Service = {
async getSummary(farmUuid: string): Promise<Sensor7SummaryData> { async resolvePrimaryPhysicalDeviceUuid(farmUuid: string): Promise<string | null> {
const cached = primaryDevicePromiseCache.get(farmUuid);
if (cached) {
return cached;
}
const request = sensorExternalApiService
.listRequestLogs({
farmUuid,
page: 1,
pageSize: 50,
})
.then((response) => {
const firstLog = Array.isArray(response.data)
? response.data.find((item) => typeof item.physical_device_uuid === "string")
: null;
return firstLog?.physical_device_uuid ?? null;
})
.catch(() => null);
primaryDevicePromiseCache.set(farmUuid, request);
return request;
},
async getDeviceDetail(physicalDeviceUuid: string): Promise<DeviceDetailResponse> {
const deviceCode = await resolveDeviceCode(physicalDeviceUuid);
const response = await apiClient.get<ApiResponse<DeviceDetailResponse> | DeviceDetailResponse>(
buildDeviceHubRequestUrl(physicalDeviceUuid, "", { device_code: deviceCode }),
);
return extract(response);
},
async getDeviceCodes(physicalDeviceUuid: string): Promise<DeviceCodesResponse> {
return getCachedDeviceCodes(physicalDeviceUuid);
},
async getLatestPayload(
physicalDeviceUuid: string,
deviceCode?: string,
): Promise<DeviceLatestPayloadResponse> {
const resolvedDeviceCode = await resolveDeviceCode(physicalDeviceUuid, deviceCode);
const response = await apiClient.get<
ApiResponse<DeviceLatestPayloadResponse> | DeviceLatestPayloadResponse
>(
buildDeviceHubRequestUrl(physicalDeviceUuid, "latest/", {
device_code: resolvedDeviceCode,
}),
);
return extract(response);
},
async getSummary(
physicalDeviceUuid: string,
deviceCode?: string,
): Promise<Sensor7SummaryData> {
const resolvedDeviceCode = await resolveDeviceCode(physicalDeviceUuid, deviceCode);
const response = await apiClient.get<ApiResponse<Sensor7SummaryData> | Sensor7SummaryData>( const response = await apiClient.get<ApiResponse<Sensor7SummaryData> | Sensor7SummaryData>(
`${SUMMARY_PREFIX}/summary/?farm_uuid=${encodeURIComponent(farmUuid)}`, buildDeviceHubRequestUrl(physicalDeviceUuid, "summary/", {
device_code: resolvedDeviceCode,
}),
); );
const data = extract(response) ?? {}; const data = extract(response) ?? {};
@@ -188,50 +374,68 @@ export const sensor7Service = {
}, },
async getComparisonChart(params: { async getComparisonChart(params: {
farmUuid: string; physicalDeviceUuid: string;
deviceCode?: string;
range?: "7d" | "30d"; range?: "7d" | "30d";
}): Promise<SensorComparisonChartResponse> { }): Promise<SensorComparisonChartResponse> {
const searchParams = new URLSearchParams({ const resolvedDeviceCode = await resolveDeviceCode(
farm_uuid: params.farmUuid, params.physicalDeviceUuid,
range: params.range ?? "7d", params.deviceCode,
});
const response = await apiClient.get<Partial<SensorComparisonChartResponse>>(
`${SENSORS_PREFIX}/comparison-chart/?${searchParams.toString()}`,
); );
return normalizeComparisonChartResponse(response); const response = await apiClient.get<
ApiResponse<Partial<SensorComparisonChartResponse>> | Partial<SensorComparisonChartResponse>
>(
buildDeviceHubRequestUrl(params.physicalDeviceUuid, "comparison-chart/", {
range: params.range ?? "7d",
device_code: resolvedDeviceCode,
}),
);
return normalizeComparisonChartResponse(extract(response));
}, },
async getRadarChart(params: { async getRadarChart(params: {
farmUuid: string; physicalDeviceUuid: string;
deviceCode?: string;
range?: "today" | "7d" | "30d"; range?: "today" | "7d" | "30d";
}): Promise<SensorRadarChartResponse> { }): Promise<SensorRadarChartResponse> {
const searchParams = new URLSearchParams({ const resolvedDeviceCode = await resolveDeviceCode(
farm_uuid: params.farmUuid, params.physicalDeviceUuid,
range: params.range ?? "7d", params.deviceCode,
});
const response = await apiClient.get<Partial<SensorRadarChartResponse>>(
`${SENSORS_PREFIX}/radar-chart/?${searchParams.toString()}`,
); );
return normalizeRadarChartResponse(response); const response = await apiClient.get<
ApiResponse<Partial<SensorRadarChartResponse>> | Partial<SensorRadarChartResponse>
>(
buildDeviceHubRequestUrl(params.physicalDeviceUuid, "radar-chart/", {
range: params.range ?? "7d",
device_code: resolvedDeviceCode,
}),
);
return normalizeRadarChartResponse(extract(response));
}, },
async getValuesList(params: { async getValuesList(params: {
farmUuid: string; physicalDeviceUuid: string;
deviceCode?: string;
range?: "1h" | "24h" | "7d"; range?: "1h" | "24h" | "7d";
}): Promise<SensorValuesListResponse> { }): Promise<SensorValuesListResponse> {
const searchParams = new URLSearchParams({ const resolvedDeviceCode = await resolveDeviceCode(
farm_uuid: params.farmUuid, params.physicalDeviceUuid,
range: params.range ?? "7d", params.deviceCode,
});
const response = await apiClient.get<Partial<SensorValuesListResponse>>(
`${SENSORS_PREFIX}/values-list/?${searchParams.toString()}`,
); );
return normalizeValuesListResponse(response); const response = await apiClient.get<
ApiResponse<Partial<SensorValuesListResponse>> | Partial<SensorValuesListResponse>
>(
buildDeviceHubRequestUrl(params.physicalDeviceUuid, "values-list/", {
range: params.range ?? "7d",
device_code: resolvedDeviceCode,
}),
);
return normalizeValuesListResponse(extract(response));
}, },
}; };
@@ -10,9 +10,6 @@ import CardContent from '@mui/material/CardContent'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import Chip from '@mui/material/Chip' import Chip from '@mui/material/Chip'
// Component Imports
import OptionMenu from '@core/components/option-menu'
type AnomalyItem = { type AnomalyItem = {
sensor: string sensor: string
value: string value: string
@@ -35,7 +32,6 @@ const AnomalyDetectionCard = ({ data }: AnomalyDetectionCardProps) => {
avatar={<i className='tabler-alert-triangle text-xl' />} avatar={<i className='tabler-alert-triangle text-xl' />}
title={t('cards.anomalyDetectionCard')} title={t('cards.anomalyDetectionCard')}
subheader={t('subheaders.outOfRangeValues')} subheader={t('subheaders.outOfRangeValues')}
action={<OptionMenu options={[t('optionMenu.viewAll'), t('optionMenu.configure'), t('optionMenu.export')]} />}
sx={{ '& .MuiCardHeader-avatar': { mr: 3 } }} sx={{ '& .MuiCardHeader-avatar': { mr: 3 } }}
/> />
<CardContent className='flex flex-col gap-3'> <CardContent className='flex flex-col gap-3'>
@@ -14,7 +14,6 @@ import { useTheme } from '@mui/material/styles'
import type { ApexOptions } from 'apexcharts' import type { ApexOptions } from 'apexcharts'
// Component Imports // Component Imports
import OptionMenu from '@core/components/option-menu'
import CardStatsVertical from '@/components/card-statistics/Vertical' import CardStatsVertical from '@/components/card-statistics/Vertical'
// Styled Component Imports // Styled Component Imports
@@ -77,7 +76,6 @@ const EconomicOverview = ({ data }: EconomicOverviewProps) => {
<CardHeader <CardHeader
title={t('cards.economicOverview')} title={t('cards.economicOverview')}
subheader={t('subheaders.costsAndRoi')} subheader={t('subheaders.costsAndRoi')}
action={<OptionMenu options={[t('optionMenu.exportPdf'), t('optionMenu.exportExcel'), t('optionMenu.details')]} />}
/> />
<CardContent className='flex flex-col gap-4'> <CardContent className='flex flex-col gap-4'>
{economicData.length > 0 && ( {economicData.length > 0 && (
@@ -17,9 +17,6 @@ import TimelineConnector from '@mui/lab/TimelineConnector'
import MuiTimeline from '@mui/lab/Timeline' import MuiTimeline from '@mui/lab/Timeline'
import type { TimelineProps } from '@mui/lab/Timeline' import type { TimelineProps } from '@mui/lab/Timeline'
// Component Imports
import OptionMenu from '@core/components/option-menu'
// Styled Timeline // Styled Timeline
const Timeline = styled(MuiTimeline)<TimelineProps>({ const Timeline = styled(MuiTimeline)<TimelineProps>({
paddingLeft: 0, paddingLeft: 0,
@@ -52,7 +49,6 @@ const FarmAlertsTimeline = ({ data }: FarmAlertsTimelineProps) => {
title={t('cards.farmAlertsTimeline')} title={t('cards.farmAlertsTimeline')}
titleTypographyProps={{ variant: 'h5' }} titleTypographyProps={{ variant: 'h5' }}
subheader={t('subheaders.explainableRecommendations')} subheader={t('subheaders.explainableRecommendations')}
action={<OptionMenu options={[t('optionMenu.viewAll'), t('optionMenu.configure'), t('optionMenu.export')]} />}
sx={{ '& .MuiCardHeader-avatar': { mr: 3 } }} sx={{ '& .MuiCardHeader-avatar': { mr: 3 } }}
/> />
<CardContent className='flex flex-col gap-6 pbe-5'> <CardContent className='flex flex-col gap-6 pbe-5'>
@@ -19,7 +19,6 @@ import type { ApexOptions } from 'apexcharts'
import type { ThemeColor } from '@core/types' import type { ThemeColor } from '@core/types'
// Components Imports // Components Imports
import OptionMenu from '@core/components/option-menu'
import CustomAvatar from '@core/components/mui/Avatar' import CustomAvatar from '@core/components/mui/Avatar'
// Styled Component Imports // Styled Component Imports
@@ -98,7 +97,6 @@ const FarmAlertsTracker = ({ data }: FarmAlertsTrackerProps) => {
<CardHeader <CardHeader
title={t('cards.farmAlertsTracker')} title={t('cards.farmAlertsTracker')}
subheader={t('subheaders.requiresAttention')} subheader={t('subheaders.requiresAttention')}
action={<OptionMenu options={[t('optionMenu.viewAll'), t('optionMenu.dismiss'), t('optionMenu.settings')]} />}
/> />
<CardContent className='flex flex-col sm:flex-row items-center justify-between gap-7'> <CardContent className='flex flex-col sm:flex-row items-center justify-between gap-7'>
<div className='flex flex-col gap-6 is-full sm:is-[unset]'> <div className='flex flex-col gap-6 is-full sm:is-[unset]'>
@@ -2,10 +2,11 @@
// React Imports // React Imports
import type { ComponentType, RefObject } from "react"; import type { ComponentType, RefObject } from "react";
import { useEffect, useMemo, useState, useCallback, useContext } from "react"; import { useEffect, useMemo, useState, useCallback, useContext, useRef } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useFarmHub } from "@/hooks/useFarmHub"; import { useFarmHub } from "@/hooks/useFarmHub";
import { format } from "date-fns"; import { format } from "date-fns";
import Link from "next/link";
// Context Imports // Context Imports
import NavbarSlotContext from "@/contexts/navbarSlotContext"; import NavbarSlotContext from "@/contexts/navbarSlotContext";
@@ -36,6 +37,9 @@ import AnomalyDetectionCard from "@views/dashboards/farm/AnomalyDetectionCard";
import NDVIHealthCard from "@views/dashboards/farm/NDVIHealthCard"; import NDVIHealthCard from "@views/dashboards/farm/NDVIHealthCard";
import RecommendationsList from "@views/dashboards/farm/RecommendationsList"; import RecommendationsList from "@views/dashboards/farm/RecommendationsList";
import EconomicOverview from "@views/dashboards/farm/EconomicOverview"; import EconomicOverview from "@views/dashboards/farm/EconomicOverview";
import FarmerTodoSummaryCard from "@views/dashboards/farm/FarmerTodoSummaryCard";
import FarmerCalendarSummaryCard from "@views/dashboards/farm/FarmerCalendarSummaryCard";
import FarmWalletSummaryCard from "@views/dashboards/farm/FarmWalletSummaryCard";
// Config & Service // Config & Service
import { import {
@@ -60,6 +64,9 @@ import { soilService } from "@/libs/api/services/soilService";
import { cropHealthService } from "@/libs/api/services/cropHealthService"; import { cropHealthService } from "@/libs/api/services/cropHealthService";
import { economicOverviewService } from "@/libs/api/services/economicOverviewService"; import { economicOverviewService } from "@/libs/api/services/economicOverviewService";
import { yieldHarvestService } from "@/libs/api/services/yieldHarvestService"; import { yieldHarvestService } from "@/libs/api/services/yieldHarvestService";
import { farmerTodoService } from "@/libs/api/services/farmerTodoService";
import { eventService } from "@/libs/api/services/eventService";
import { pestDetectionDomainService } from "@/libs/api/services/pestDetectionDomainService";
import FarmDashboardSettingsDropdown from "@views/dashboards/farm/FarmDashboardSettingsDropdown"; import FarmDashboardSettingsDropdown from "@views/dashboards/farm/FarmDashboardSettingsDropdown";
const cardRowSx = { const cardRowSx = {
@@ -86,9 +93,67 @@ const CARD_COMPONENTS: Record<
soilMoistureHeatmap: SoilMoistureHeatmap, soilMoistureHeatmap: SoilMoistureHeatmap,
ndviHealthCard: NDVIHealthCard, ndviHealthCard: NDVIHealthCard,
recommendationsList: RecommendationsList, recommendationsList: RecommendationsList,
diseaseRiskCard: FarmOverviewKPIs,
pestRiskCard: FarmOverviewKPIs,
farmerTodoSummary: FarmerTodoSummaryCard,
farmerCalendarSummary: FarmerCalendarSummaryCard,
farmWalletSummary: FarmWalletSummaryCard,
economicOverview: EconomicOverview, economicOverview: EconomicOverview,
}; };
const CARD_ROUTES: Record<CardId, string> = {
farmOverviewKpis: "/yield-harvest",
farmWeatherCard: "/water-data",
farmAlertsTracker: "/farm-alerts",
sensorValuesList: "/solid-sensor",
sensorRadarChart: "/solid-sensor",
sensorComparisonChart: "/solid-sensor",
anomalyDetectionCard: "/soil-data",
farmAlertsTimeline: "/farm-alerts",
waterNeedPrediction: "/water-data",
harvestPredictionCard: "/yield-harvest",
yieldPredictionChart: "/yield-harvest",
soilMoistureHeatmap: "/soil-data",
ndviHealthCard: "/crop-zoning",
recommendationsList: "/farm-alerts",
diseaseRiskCard: "/pest-risk",
pestRiskCard: "/pest-risk",
farmerTodoSummary: "/farmer-todos",
farmerCalendarSummary: "/farmer-calendar",
farmWalletSummary: "/wallet",
economicOverview: "/economic-overview",
};
const QuickAccessButton = ({ href }: { href: string }) => {
return (
<IconButton
component={Link}
href={href}
size="small"
color="primary"
aria-label="دسترسی سریع"
title="دسترسی سریع"
sx={{
position: "absolute",
insetInlineEnd: 12,
insetBlockStart: 12,
zIndex: 2,
inlineSize: 32,
blockSize: 32,
border: (theme) => `1px solid ${theme.palette.divider}`,
backgroundColor: "background.paper",
backdropFilter: "blur(8px)",
boxShadow: 1,
"&:hover": {
backgroundColor: "action.hover",
},
}}
>
<i className="tabler-external-link text-base" />
</IconButton>
);
};
function mergeRowOrderAfterDrag( function mergeRowOrderAfterDrag(
currentRowOrder: string[], currentRowOrder: string[],
newVisibleOrder: string[], newVisibleOrder: string[],
@@ -221,6 +286,7 @@ function buildRecommendationsData(
async function loadDashboardCardData( async function loadDashboardCardData(
cardId: CardId, cardId: CardId,
farmUuid: string, farmUuid: string,
sensorPhysicalDeviceUuid?: string | null,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
switch (cardId) { switch (cardId) {
case "farmOverviewKpis": { case "farmOverviewKpis": {
@@ -234,18 +300,25 @@ async function loadDashboardCardData(
return buildTrackerCardData(result); return buildTrackerCardData(result);
} }
case "sensorValuesList": case "sensorValuesList":
return (await sensor7Service.getValuesList({ farmUuid })) as unknown as Record< if (!sensorPhysicalDeviceUuid) return {};
return (await sensor7Service.getValuesList({
physicalDeviceUuid: sensorPhysicalDeviceUuid,
})) as unknown as Record<
string, string,
unknown unknown
>; >;
case "sensorRadarChart": case "sensorRadarChart":
return (await sensor7Service.getRadarChart({ farmUuid })) as unknown as Record< if (!sensorPhysicalDeviceUuid) return {};
return (await sensor7Service.getRadarChart({
physicalDeviceUuid: sensorPhysicalDeviceUuid,
})) as unknown as Record<
string, string,
unknown unknown
>; >;
case "sensorComparisonChart": case "sensorComparisonChart":
if (!sensorPhysicalDeviceUuid) return {};
return (await sensor7Service.getComparisonChart({ return (await sensor7Service.getComparisonChart({
farmUuid, physicalDeviceUuid: sensorPhysicalDeviceUuid,
})) as unknown as Record<string, unknown>; })) as unknown as Record<string, unknown>;
case "anomalyDetectionCard": case "anomalyDetectionCard":
return await soilService.getAnomalies(farmUuid); return await soilService.getAnomalies(farmUuid);
@@ -276,92 +349,145 @@ async function loadDashboardCardData(
const result = await farmAlertsService.analyzeTracker({ farmUuid }); const result = await farmAlertsService.analyzeTracker({ farmUuid });
return buildRecommendationsData(result); return buildRecommendationsData(result);
} }
case "diseaseRiskCard": {
const summary = await pestDetectionDomainService.getRiskSummary(farmUuid);
return (summary.diseaseRisk as Record<string, unknown>) ?? {};
}
case "pestRiskCard": {
const summary = await pestDetectionDomainService.getRiskSummary(farmUuid);
return (summary.pestRisk as Record<string, unknown>) ?? {};
}
case "economicOverview": { case "economicOverview": {
const summary = await economicOverviewService.getSummary(farmUuid); const summary = await economicOverviewService.getSummary(farmUuid);
return (summary.economicOverview as Record<string, unknown>) ?? {}; return (summary.economicOverview as Record<string, unknown>) ?? {};
} }
case "farmerTodoSummary": {
const summary = await farmerTodoService.getSummary(farmUuid);
return summary as unknown as Record<string, unknown>;
}
case "farmerCalendarSummary": {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59).toISOString();
const events = await eventService.listEvents({ farm_uuid: farmUuid, start, end });
const normalized = events
.map((event) => {
const startDate = new Date(event.start);
if (Number.isNaN(startDate.getTime())) return null;
return {
id: event.id,
title: event.title,
start: startDate,
allDay: Boolean(event.allDay),
categoryLabel: String(event.extendedProps?.calendar ?? "ETC"),
};
})
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.sort((left, right) => left.start.getTime() - right.start.getTime());
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const todayEnd = new Date();
todayEnd.setHours(23, 59, 59, 999);
const weekEnd = new Date(todayStart);
weekEnd.setDate(weekEnd.getDate() + 7);
const upcomingEvents = normalized.filter(
(event) => event.start.getTime() >= Date.now(),
);
const nextEvent = upcomingEvents[0];
return {
todayCount: normalized.filter(
(event) =>
event.start.getTime() >= todayStart.getTime() &&
event.start.getTime() <= todayEnd.getTime(),
).length,
weekCount: normalized.filter(
(event) =>
event.start.getTime() >= todayStart.getTime() &&
event.start.getTime() <= weekEnd.getTime(),
).length,
nextEventTitle: nextEvent?.title ?? "رویداد زمان بندی شده ای وجود ندارد",
nextEventMeta: nextEvent
? `${nextEvent.allDay ? "تمام روز" : nextEvent.start.toLocaleString("fa-IR")}`
: "تقویم مزرعه در این بازه خالی است.",
upcomingEvents: upcomingEvents.slice(0, 2).map((event) => ({
id: event.id,
title: event.title,
dateLabel: event.allDay
? event.start.toLocaleDateString("fa-IR")
: event.start.toLocaleString("fa-IR"),
categoryLabel: event.categoryLabel,
})),
};
}
case "farmWalletSummary":
return {
balance: "۲۴۸,۵۰۰,۰۰۰ تومان",
pendingSettlement: "۱۲,۴۰۰,۰۰۰ تومان",
monthlyInflow: "۹۴,۸۰۰,۰۰۰ تومان",
monthlyOutflow: "۵۱,۲۰۰,۰۰۰ تومان",
healthLabel: "نقدینگی پایدار",
};
default: default:
return {}; return {};
} }
} }
type FarmDashboardCardProps = { type DashboardCardRendererProps = {
cardId: CardId; cardId: CardId;
farmUuid?: string; data?: Record<string, unknown>;
overview?: boolean;
}; };
const FarmDashboardCard = ({ const OVERVIEW_KPI_ROUTES: Record<string, string> = {
"predicted-yield": "/yield-harvest",
"quality-score": "/yield-harvest",
"days-to-harvest": "/yield-harvest",
"harvest-readiness": "/yield-harvest",
"loss-risk": "/yield-harvest",
"water-stress-index": "/water-data",
"disease-risk": "/pest-risk",
"pest-risk": "/pest-risk",
};
function getOverviewData(
cardsData: Partial<Record<CardId, Record<string, unknown>>>,
): Record<string, unknown> {
const baseKpis = Array.isArray(cardsData?.farmOverviewKpis?.kpis)
? (cardsData.farmOverviewKpis?.kpis as Record<string, unknown>[])
: [];
const waterStressKpis =
cardsData?.waterNeedPrediction != null &&
typeof cardsData.waterNeedPrediction === "object" &&
cardsData.waterNeedPrediction.waterStressIndex != null
? [cardsData.waterNeedPrediction.waterStressIndex as Record<string, unknown>]
: [];
const diseaseKpis = Array.isArray(cardsData?.diseaseRiskCard?.kpis)
? (cardsData.diseaseRiskCard?.kpis as Record<string, unknown>[])
: [];
const pestKpis = Array.isArray(cardsData?.pestRiskCard?.kpis)
? (cardsData.pestRiskCard?.kpis as Record<string, unknown>[])
: [];
return {
...cardsData?.farmOverviewKpis,
kpis: [...baseKpis, ...waterStressKpis, ...diseaseKpis, ...pestKpis],
};
}
const DashboardCardRenderer = ({
cardId, cardId,
farmUuid, data,
overview = false, }: DashboardCardRendererProps) => {
}: FarmDashboardCardProps) => {
const [data, setData] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(Boolean(farmUuid));
useEffect(() => {
let active = true;
if (!farmUuid) {
setData(null);
setLoading(false);
return;
}
setLoading(true);
loadDashboardCardData(cardId, farmUuid)
.then((nextData) => {
if (active) {
setData(nextData);
}
})
.catch(() => {
if (active) {
setData(null);
}
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
}, [cardId, farmUuid]);
if (loading) {
if (overview) {
return (
<Grid size={12}>
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight={180}
>
<CircularProgress />
</Box>
</Grid>
);
}
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight={200}
>
<CircularProgress size={28} />
</Box>
);
}
if (!data) return null; if (!data) return null;
if (cardId === "diseaseRiskCard" || cardId === "pestRiskCard") {
return <FarmOverviewKPIs data={data} showQuickAccess={false} />;
}
const Component = CARD_COMPONENTS[cardId]; const Component = CARD_COMPONENTS[cardId];
return Component ? <Component data={data} /> : null; return Component ? <Component data={data} /> : null;
@@ -371,6 +497,7 @@ const FarmDashboardWrapper = () => {
const t = useTranslations("farmDashboard"); const t = useTranslations("farmDashboard");
const { farmHub } = useFarmHub(); const { farmHub } = useFarmHub();
const farmUuid = farmHub?.farm_uuid; const farmUuid = farmHub?.farm_uuid;
const [sensorPhysicalDeviceUuid, setSensorPhysicalDeviceUuid] = useState<string | null>(null);
const { setSlotContent } = useContext(NavbarSlotContext); const { setSlotContent } = useContext(NavbarSlotContext);
const [config, setConfig] = useState<FarmDashboardConfig>( const [config, setConfig] = useState<FarmDashboardConfig>(
DEFAULT_FARM_DASHBOARD_CONFIG, DEFAULT_FARM_DASHBOARD_CONFIG,
@@ -395,6 +522,11 @@ const FarmDashboardWrapper = () => {
"soilMoistureHeatmap", "soilMoistureHeatmap",
"ndviHealthCard", "ndviHealthCard",
"recommendationsList", "recommendationsList",
"diseaseRiskCard",
"pestRiskCard",
"farmerTodoSummary",
"farmerCalendarSummary",
"farmWalletSummary",
"economicOverview", "economicOverview",
] as CardId[] ] as CardId[]
).map((id) => [id, t(`cards.${id}`)]), ).map((id) => [id, t(`cards.${id}`)]),
@@ -415,6 +547,8 @@ const FarmDashboardWrapper = () => {
"predictions", "predictions",
"soilHeatmap", "soilHeatmap",
"ndviRecommendations", "ndviRecommendations",
"pestRisk",
"dailyOperations",
"economic", "economic",
] as RowId[] ] as RowId[]
).map((id) => [id, t(`rows.${id}`)]), ).map((id) => [id, t(`rows.${id}`)]),
@@ -423,7 +557,17 @@ const FarmDashboardWrapper = () => {
); );
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [cardsLoading, setCardsLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [cardsData, setCardsData] = useState<
Partial<Record<CardId, Record<string, unknown>>>
>({});
const [failedCardIds, setFailedCardIds] = useState<CardId[]>([]);
const previousVisibleRowOrderRef = useRef<string[]>([]);
const visibleRowOrderChangedRef = useRef(false);
const lastCardsRequestKeyRef = useRef<string | null>(null);
const activeCardsRequestKeyRef = useRef<string | null>(null);
const completedCardsRequestKeyRef = useRef<string | null>(null);
const disabledSet = useMemo( const disabledSet = useMemo(
() => new Set(config.disabledCardIds), () => new Set(config.disabledCardIds),
@@ -444,6 +588,14 @@ const FarmDashboardWrapper = () => {
[config.rowOrder, hasVisibleCard], [config.rowOrder, hasVisibleCard],
); );
const visibleCardIds = useMemo(
() =>
config.rowOrder.flatMap((rowId) =>
ROW_CARDS[rowId as RowId].filter((cardId) => !disabledSet.has(cardId)),
),
[config.rowOrder, disabledSet],
);
const [containerRef, orderedRows, setOrderedRows] = useDragAndDrop( const [containerRef, orderedRows, setOrderedRows] = useDragAndDrop(
visibleRowOrder, visibleRowOrder,
{ {
@@ -458,9 +610,40 @@ const FarmDashboardWrapper = () => {
// },[visibleRowOrder,visibleRowOrder]) // },[visibleRowOrder,visibleRowOrder])
useEffect(() => {
if (!farmUuid) {
setSensorPhysicalDeviceUuid(null);
return;
}
let active = true;
sensor7Service
.resolvePrimaryPhysicalDeviceUuid(farmUuid)
.then((deviceUuid) => {
if (active) {
setSensorPhysicalDeviceUuid(deviceUuid);
}
})
.catch(() => {
if (active) {
setSensorPhysicalDeviceUuid(null);
}
});
return () => {
active = false;
};
}, [farmUuid]);
useEffect(() => { useEffect(() => {
if (!farmUuid) { if (!farmUuid) {
setConfig(DEFAULT_FARM_DASHBOARD_CONFIG); setConfig(DEFAULT_FARM_DASHBOARD_CONFIG);
setCardsData({});
setFailedCardIds([]);
lastCardsRequestKeyRef.current = null;
activeCardsRequestKeyRef.current = null;
completedCardsRequestKeyRef.current = null;
setLoading(false); setLoading(false);
return; return;
} }
@@ -485,13 +668,116 @@ const FarmDashboardWrapper = () => {
}, [farmUuid]); }, [farmUuid]);
useEffect(() => { useEffect(() => {
let active = true;
if (!farmUuid) {
setCardsData({});
setFailedCardIds([]);
lastCardsRequestKeyRef.current = null;
activeCardsRequestKeyRef.current = null;
completedCardsRequestKeyRef.current = null;
setCardsLoading(false);
return;
}
if (loading) return;
if (visibleCardIds.length === 0) {
setCardsData({});
setFailedCardIds([]);
lastCardsRequestKeyRef.current = null;
activeCardsRequestKeyRef.current = null;
completedCardsRequestKeyRef.current = null;
setCardsLoading(false);
return;
}
const requestKey = `${farmUuid}:${sensorPhysicalDeviceUuid ?? "none"}:${visibleCardIds.join(",")}`;
if (lastCardsRequestKeyRef.current !== requestKey) {
lastCardsRequestKeyRef.current = requestKey;
setFailedCardIds([]);
completedCardsRequestKeyRef.current = null;
}
if (
activeCardsRequestKeyRef.current === requestKey ||
completedCardsRequestKeyRef.current === requestKey
) {
return;
}
const cardsToFetch = visibleCardIds.filter(
(cardId) => !failedCardIds.includes(cardId),
);
if (cardsToFetch.length === 0) {
setCardsLoading(false);
return;
}
setCardsLoading(true);
activeCardsRequestKeyRef.current = requestKey;
Promise.allSettled(
cardsToFetch.map(async (cardId) => ({
cardId,
data: await loadDashboardCardData(cardId, farmUuid, sensorPhysicalDeviceUuid),
})),
)
.then((results) => {
if (!active) return;
const nextData: Partial<Record<CardId, Record<string, unknown>>> = {};
const nextFailedCardIds: CardId[] = [];
results.forEach((result, index) => {
const cardId = cardsToFetch[index];
if (result.status === "fulfilled") {
nextData[cardId] = result.value.data;
} else {
nextFailedCardIds.push(cardId);
}
});
setCardsData((prev) => ({ ...prev, ...nextData }));
setFailedCardIds((prev) =>
Array.from(new Set([...prev, ...nextFailedCardIds])),
);
})
.finally(() => {
if (active) {
activeCardsRequestKeyRef.current = null;
completedCardsRequestKeyRef.current = requestKey;
setCardsLoading(false);
}
});
return () => {
active = false;
};
}, [farmUuid, loading, sensorPhysicalDeviceUuid, visibleCardIds]);
useEffect(() => {
const visibleRowOrderChanged = !areStringArraysEqual(
previousVisibleRowOrderRef.current,
visibleRowOrder,
);
previousVisibleRowOrderRef.current = visibleRowOrder;
visibleRowOrderChangedRef.current = visibleRowOrderChanged;
if (!visibleRowOrderChanged) return;
if (areStringArraysEqual(orderedRows, visibleRowOrder)) return; if (areStringArraysEqual(orderedRows, visibleRowOrder)) return;
setOrderedRows(visibleRowOrder); setOrderedRows(visibleRowOrder);
}, [orderedRows, setOrderedRows, visibleRowOrder]); }, [orderedRows, setOrderedRows, visibleRowOrder]);
useEffect(() => { useEffect(() => {
if (!farmUuid) return; if (!farmUuid) return;
if (loading) return; if (loading) return;
if (visibleRowOrderChangedRef.current) return;
if (areStringArraysEqual(orderedRows, visibleRowOrder)) return; if (areStringArraysEqual(orderedRows, visibleRowOrder)) return;
const newRowOrder = mergeRowOrderAfterDrag( const newRowOrder = mergeRowOrderAfterDrag(
config.rowOrder, config.rowOrder,
@@ -559,7 +845,7 @@ const FarmDashboardWrapper = () => {
saving, saving,
]); ]);
if (loading) { if (loading || cardsLoading) {
return ( return (
<Box <Box
display="flex" display="flex"
@@ -620,18 +906,30 @@ const FarmDashboardWrapper = () => {
)} )}
<Grid container spacing={6} sx={{ flex: 1, minWidth: 0 }}> <Grid container spacing={6} sx={{ flex: 1, minWidth: 0 }}>
{isOverviewRow && cards.includes("farmOverviewKpis") && ( {isOverviewRow && cards.includes("farmOverviewKpis") && (
<FarmDashboardCard <Grid size={12}>
cardId="farmOverviewKpis" <FarmOverviewKPIs
farmUuid={farmUuid} data={getOverviewData(cardsData)}
overview showQuickAccess
/> getQuickAccessHref={(kpi) =>
OVERVIEW_KPI_ROUTES[kpi.id] ?? CARD_ROUTES.farmOverviewKpis
}
/>
</Grid>
)} )}
{!isOverviewRow && {!isOverviewRow &&
cards.map((cardId: CardId) => { cards.map((cardId: CardId) => {
const size = CARD_GRID_SIZE[cardId]; const size = CARD_GRID_SIZE[cardId];
return ( return (
<Grid key={cardId} size={size} sx={cardRowSx}> <Grid
<FarmDashboardCard cardId={cardId} farmUuid={farmUuid} /> key={cardId}
size={size}
sx={{ ...cardRowSx, position: "relative" }}
>
<QuickAccessButton href={CARD_ROUTES[cardId]} />
<DashboardCardRenderer
cardId={cardId}
data={cardsData?.[cardId]}
/>
</Grid> </Grid>
); );
})} })}
+141 -31
View File
@@ -2,6 +2,9 @@
// MUI Imports // MUI Imports
import Grid from "@mui/material/Grid2"; import Grid from "@mui/material/Grid2";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import Link from "next/link";
// Component Imports // Component Imports
import CardStatsVertical from "@components/card-statistics/Vertical"; import CardStatsVertical from "@components/card-statistics/Vertical";
@@ -19,10 +22,75 @@ type KpiItem = {
interface FarmOverviewKPIsProps { interface FarmOverviewKPIsProps {
data?: Record<string, unknown>; data?: Record<string, unknown>;
showQuickAccess?: boolean;
getQuickAccessHref?: (kpi: KpiItem) => string | null;
} }
const FarmOverviewKPIs = ({ data }: FarmOverviewKPIsProps) => { const WATER_STRESS_FALLBACK_KPI: KpiItem = {
const kpis = (data?.kpis as KpiItem[] | undefined) ?? []; id: "water-stress-index",
title: "شاخص تنش آبی",
subtitle: "فعلی",
stats: "",
avatarColor: "secondary",
avatarIcon: "tabler-droplet",
chipText: "بدون داده",
chipColor: "secondary",
};
const normalizeKpi = (
item: Record<string, unknown> | undefined,
index: number,
): KpiItem => {
const hasReadableContent = Boolean(
item &&
(typeof item.title === "string" ||
typeof item.subtitle === "string" ||
typeof item.stats === "string" ||
typeof item.chipText === "string"),
);
if (!hasReadableContent) {
return {
...WATER_STRESS_FALLBACK_KPI,
id: `${WATER_STRESS_FALLBACK_KPI.id}-${index}`,
};
}
return {
id:
typeof item?.id === "string" && item.id
? item.id
: `farm-overview-kpi-${index}`,
title: typeof item?.title === "string" ? item.title : "-",
subtitle: typeof item?.subtitle === "string" ? item.subtitle : "",
stats: typeof item?.stats === "string" ? item.stats : "",
avatarColor:
typeof item?.avatarColor === "string"
? item.avatarColor
: WATER_STRESS_FALLBACK_KPI.avatarColor,
avatarIcon:
typeof item?.avatarIcon === "string"
? item.avatarIcon
: WATER_STRESS_FALLBACK_KPI.avatarIcon,
chipText:
typeof item?.chipText === "string"
? item.chipText
: WATER_STRESS_FALLBACK_KPI.chipText,
chipColor:
typeof item?.chipColor === "string"
? item.chipColor
: WATER_STRESS_FALLBACK_KPI.chipColor,
};
};
const FarmOverviewKPIs = ({
data,
showQuickAccess = false,
getQuickAccessHref,
}: FarmOverviewKPIsProps) => {
const rawKpis = (data?.kpis as Record<string, unknown>[] | undefined) ?? [];
const kpis = rawKpis.map((item, index) => normalizeKpi(item, index));
if (kpis.length === 0) return null; if (kpis.length === 0) return null;
const getGridSize = (count: number) => { const getGridSize = (count: number) => {
@@ -35,35 +103,77 @@ const FarmOverviewKPIs = ({ data }: FarmOverviewKPIsProps) => {
}; };
return ( return (
<> <Grid container spacing={6}>
{kpis.map((kpi) => ( {kpis.map((kpi) => {
<Grid const quickAccessHref = getQuickAccessHref?.(kpi) ?? null;
key={kpi.id}
size={getGridSize(kpis.length)} return (
sx={{ display: "flex", width: "100%" }} <Grid
> key={kpi.id}
<CardStatsVertical size={getGridSize(kpis.length)}
title={kpi.title} sx={{ display: "flex", width: "100%" }}
subtitle={kpi.subtitle} >
stats={kpi.stats} <Box sx={{ position: "relative", display: "flex", width: "100%" }}>
avatarColor={ {showQuickAccess && quickAccessHref && (
(kpi.avatarColor as <IconButton
| "success" component={Link}
| "info" href={quickAccessHref}
| "primary" size="small"
| "secondary" color="primary"
| "warning") ?? "primary" aria-label={`دسترسی سریع ${kpi.title}`}
} title={`دسترسی سریع ${kpi.title}`}
avatarIcon={kpi.avatarIcon ?? "tabler-chart-bar"} sx={{
avatarSkin="light" position: "absolute",
avatarSize={44} insetInlineEnd: 12,
chipText={kpi.chipText ?? ""} insetBlockStart: 12,
chipColor={(kpi.chipColor as "success" | "warning") ?? "success"} zIndex: 2,
chipVariant="tonal" inlineSize: 32,
/> blockSize: 32,
</Grid> border: (theme) => `1px solid ${theme.palette.divider}`,
))} backgroundColor: "background.paper",
</> backdropFilter: "blur(8px)",
boxShadow: 1,
"&:hover": {
backgroundColor: "action.hover",
},
}}
>
<i className="tabler-external-link text-base" />
</IconButton>
)}
<CardStatsVertical
title={kpi.title}
subtitle={kpi.subtitle}
stats={kpi.stats}
avatarColor={
(kpi.avatarColor as
| "success"
| "info"
| "primary"
| "secondary"
| "error"
| "warning") ?? "primary"
}
avatarIcon={kpi.avatarIcon ?? "tabler-chart-bar"}
avatarSkin="light"
avatarSize={44}
chipText={kpi.chipText ?? ""}
chipColor={
(kpi.chipColor as
| "success"
| "warning"
| "secondary"
| "primary"
| "info"
| "error") ?? "success"
}
chipVariant="tonal"
/>
</Box>
</Grid>
);
})}
</Grid>
); );
}; };
@@ -0,0 +1,73 @@
'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'
import Box from '@mui/material/Box'
import { alpha, useTheme } from '@mui/material/styles'
import CustomAvatar from '@core/components/mui/Avatar'
interface FarmWalletSummaryCardProps {
data?: Record<string, unknown>
}
const FarmWalletSummaryCard = ({ data }: FarmWalletSummaryCardProps) => {
const theme = useTheme()
const balance = String(data?.balance ?? '۲۴۸,۵۰۰,۰۰۰ تومان')
const pendingSettlement = String(data?.pendingSettlement ?? '۱۲,۴۰۰,۰۰۰ تومان')
const monthlyInflow = String(data?.monthlyInflow ?? '۹۴,۸۰۰,۰۰۰ تومان')
const monthlyOutflow = String(data?.monthlyOutflow ?? '۵۱,۲۰۰,۰۰۰ تومان')
const healthLabel = String(data?.healthLabel ?? 'جریان نقدی پایدار')
return (
<Card sx={{ height: '100%' }}>
<CardHeader
title='خلاصه کیف پول'
subheader='وضعیت نقدینگی و تسویه های مزرعه'
avatar={
<CustomAvatar skin='light' color='info' size={42}>
<i className='tabler-wallet text-xl' />
</CustomAvatar>
}
action={<Chip size='small' color='success' variant='tonal' label={healthLabel} />}
/>
<CardContent className='flex flex-col gap-4'>
<Box sx={{ p: 3, borderRadius: 4, backgroundColor: alpha(theme.palette.info.main, 0.06) }}>
<Typography variant='body2' color='text.secondary'>
موجودی کل
</Typography>
<Typography variant='h5'>{balance}</Typography>
</Box>
<div className='grid grid-cols-2 gap-3'>
<Box sx={{ p: 3, borderRadius: 4, backgroundColor: alpha(theme.palette.warning.main, 0.06) }}>
<Typography variant='body2' color='text.secondary'>
در انتظار تسویه
</Typography>
<Typography className='font-semibold'>{pendingSettlement}</Typography>
</Box>
<Box sx={{ p: 3, borderRadius: 4, backgroundColor: alpha(theme.palette.success.main, 0.06) }}>
<Typography variant='body2' color='text.secondary'>
ورودی ماه
</Typography>
<Typography className='font-semibold'>{monthlyInflow}</Typography>
</Box>
</div>
<Box sx={{ p: 3, borderRadius: 4, border: `1px solid ${alpha(theme.palette.divider, 0.8)}` }}>
<Typography variant='body2' color='text.secondary'>
خروجی ۳۰ روز اخیر
</Typography>
<Typography className='font-semibold' color='error.main'>
{monthlyOutflow}
</Typography>
</Box>
</CardContent>
</Card>
)
}
export default FarmWalletSummaryCard
@@ -14,9 +14,6 @@ import { useTheme } from '@mui/material/styles'
// Third-party Imports // Third-party Imports
import type { ApexOptions } from 'apexcharts' import type { ApexOptions } from 'apexcharts'
// Component Imports
import OptionMenu from '@core/components/option-menu'
// Styled Component Imports // Styled Component Imports
const AppReactApexCharts = dynamic(() => import('@/libs/styles/AppReactApexCharts')) const AppReactApexCharts = dynamic(() => import('@/libs/styles/AppReactApexCharts'))
@@ -91,7 +88,6 @@ const FarmWeatherCard = ({ data }: FarmWeatherCardProps) => {
title={t('cards.farmWeatherCard')} title={t('cards.farmWeatherCard')}
subheader={condition ? `${condition}, ${temperature}${unit}` : `${temperature}${unit}`} subheader={condition ? `${condition}, ${temperature}${unit}` : `${temperature}${unit}`}
className='pbe-3' className='pbe-3'
action={<OptionMenu options={[t('optionMenu.refresh'), t('optionMenu.sevenDayForecast'), t('optionMenu.details')]} />}
/> />
<CardContent> <CardContent>
<div className='flex items-center gap-2 mbe-2'> <div className='flex items-center gap-2 mbe-2'>
@@ -0,0 +1,98 @@
'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'
import Box from '@mui/material/Box'
import { alpha, useTheme } from '@mui/material/styles'
import CustomAvatar from '@core/components/mui/Avatar'
type UpcomingEvent = {
id: string
title: string
dateLabel: string
categoryLabel: string
}
interface FarmerCalendarSummaryCardProps {
data?: Record<string, unknown>
}
const FarmerCalendarSummaryCard = ({ data }: FarmerCalendarSummaryCardProps) => {
const theme = useTheme()
const todayCount = Number(data?.todayCount ?? 0)
const weekCount = Number(data?.weekCount ?? 0)
const nextEventTitle = String(data?.nextEventTitle ?? 'رویداد زمان بندی شده ای وجود ندارد')
const nextEventMeta = String(data?.nextEventMeta ?? 'تقویم روزانه را از صفحه تقویم دنبال کن.')
const upcomingEvents = (data?.upcomingEvents as UpcomingEvent[] | undefined) ?? []
return (
<Card sx={{ height: '100%' }}>
<CardHeader
title='خلاصه تقویم کشاورز'
subheader='نمای سریع از رویدادهای امروز و این هفته'
avatar={
<CustomAvatar skin='light' color='primary' size={42}>
<i className='tabler-calendar-event text-xl' />
</CustomAvatar>
}
/>
<CardContent className='flex flex-col gap-4'>
<div className='grid grid-cols-2 gap-3'>
<Box sx={{ p: 3, borderRadius: 4, backgroundColor: alpha(theme.palette.warning.main, 0.06) }}>
<Typography variant='body2' color='text.secondary'>
برنامه امروز
</Typography>
<Typography variant='h6'>{todayCount} رویداد</Typography>
</Box>
<Box sx={{ p: 3, borderRadius: 4, backgroundColor: alpha(theme.palette.primary.main, 0.06) }}>
<Typography variant='body2' color='text.secondary'>
این هفته
</Typography>
<Typography variant='h6'>{weekCount} رویداد</Typography>
</Box>
</div>
<Box sx={{ p: 3, borderRadius: 4, border: `1px solid ${alpha(theme.palette.divider, 0.8)}` }}>
<Typography className='font-medium' color='text.primary'>
{nextEventTitle}
</Typography>
<Typography variant='body2' color='text.secondary'>
{nextEventMeta}
</Typography>
</Box>
<div className='flex flex-col gap-3'>
{upcomingEvents.slice(0, 2).map(event => (
<Box
key={event.id}
sx={{
p: 3,
borderRadius: 4,
backgroundColor: alpha(theme.palette.success.main, 0.05),
border: `1px solid ${alpha(theme.palette.success.main, 0.14)}`,
}}
>
<div className='flex items-start justify-between gap-3'>
<div>
<Typography className='font-medium' color='text.primary'>
{event.title}
</Typography>
<Typography variant='body2' color='text.secondary'>
{event.dateLabel}
</Typography>
</div>
<Chip size='small' label={event.categoryLabel} variant='tonal' color='success' />
</div>
</Box>
))}
</div>
</CardContent>
</Card>
)
}
export default FarmerCalendarSummaryCard
@@ -0,0 +1,100 @@
'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'
import Box from '@mui/material/Box'
import { alpha, useTheme } from '@mui/material/styles'
import CustomAvatar from '@core/components/mui/Avatar'
interface FarmerTodoSummaryCardProps {
data?: Record<string, unknown>
}
const FarmerTodoSummaryCard = ({ data }: FarmerTodoSummaryCardProps) => {
const theme = useTheme()
const todayTasksCount = Number(data?.todayTasksCount ?? 0)
const completedCount = Number(data?.completedCount ?? 0)
const urgentCount = Number(data?.urgentCount ?? 0)
const progressValue = Number(data?.progressValue ?? 0)
const nextTask = (data?.nextTask as Record<string, unknown> | undefined) ?? null
return (
<Card sx={{ height: '100%' }}>
<CardHeader
title='خلاصه کارهای روزانه'
subheader='وضعیت سریع کارهای امروز مزرعه'
avatar={
<CustomAvatar skin='light' color='success' size={42}>
<i className='tabler-checklist text-xl' />
</CustomAvatar>
}
/>
<CardContent className='flex flex-col gap-4'>
<div className='flex items-center justify-between gap-3'>
<Box>
<Typography variant='h4'>{todayTasksCount}</Typography>
<Typography variant='body2'>کار باز امروز</Typography>
</Box>
<Chip
size='small'
color={urgentCount > 0 ? 'error' : 'success'}
variant='tonal'
label={urgentCount > 0 ? `${urgentCount} فوری` : 'بدون مورد فوری'}
/>
</div>
<div>
<div className='flex items-center justify-between gap-3 mbe-2'>
<Typography variant='body2' color='text.secondary'>
پیشرفت انجام کارها
</Typography>
<Typography className='font-semibold'>{progressValue}%</Typography>
</div>
<LinearProgress
variant='determinate'
value={Math.max(0, Math.min(progressValue, 100))}
sx={{
blockSize: 8,
borderRadius: 999,
backgroundColor: alpha(theme.palette.success.main, 0.14),
'& .MuiLinearProgress-bar': {
borderRadius: 999,
},
}}
/>
</div>
<div className='grid grid-cols-2 gap-3'>
<Box sx={{ p: 3, borderRadius: 4, backgroundColor: alpha(theme.palette.primary.main, 0.06) }}>
<Typography variant='body2' color='text.secondary'>
انجام شده
</Typography>
<Typography variant='h6'>{completedCount}</Typography>
</Box>
<Box sx={{ p: 3, borderRadius: 4, backgroundColor: alpha(theme.palette.warning.main, 0.06) }}>
<Typography variant='body2' color='text.secondary'>
کار بعدی
</Typography>
<Typography variant='h6'>{String(nextTask?.time ?? '--:--')}</Typography>
</Box>
</div>
<Box sx={{ p: 3, borderRadius: 4, border: `1px solid ${alpha(theme.palette.divider, 0.8)}` }}>
<Typography className='font-medium' color='text.primary'>
{String(nextTask?.title ?? 'برای امروز هنوز کاری ثبت نشده')}
</Typography>
<Typography variant='body2' color='text.secondary'>
{nextTask ? `${String(nextTask.zone ?? '-')}` : 'از صفحه کارهای روزانه می‌توانی تسک جدید ثبت کنی.'}
</Typography>
</Box>
</CardContent>
</Card>
)
}
export default FarmerTodoSummaryCard
@@ -12,7 +12,6 @@ import Chip from '@mui/material/Chip'
// Component Imports // Component Imports
import CustomAvatar from '@core/components/mui/Avatar' import CustomAvatar from '@core/components/mui/Avatar'
import OptionMenu from '@core/components/option-menu'
interface HarvestPredictionCardProps { interface HarvestPredictionCardProps {
data?: Record<string, unknown> data?: Record<string, unknown>
@@ -35,7 +34,6 @@ const HarvestPredictionCard = ({ data }: HarvestPredictionCardProps) => {
} }
title={t('cards.harvestPredictionCard')} title={t('cards.harvestPredictionCard')}
subheader={t('subheaders.aiEstimatedDate')} subheader={t('subheaders.aiEstimatedDate')}
action={<OptionMenu options={[t('optionMenu.details'), t('optionMenu.adjust'), t('optionMenu.export')]} />}
/> />
<CardContent className='flex flex-col gap-4' sx={{ flex: 1, justifyContent: 'space-between' }}> <CardContent className='flex flex-col gap-4' sx={{ flex: 1, justifyContent: 'space-between' }}>
<div className='flex items-center gap-4'> <div className='flex items-center gap-4'>
@@ -13,7 +13,6 @@ import Typography from '@mui/material/Typography'
import classnames from 'classnames' import classnames from 'classnames'
// Component Imports // Component Imports
import OptionMenu from '@core/components/option-menu'
import CustomAvatar from '@core/components/mui/Avatar' import CustomAvatar from '@core/components/mui/Avatar'
type RecommendationType = { type RecommendationType = {
@@ -37,7 +36,6 @@ const RecommendationsList = ({ data }: RecommendationsListProps) => {
<CardHeader <CardHeader
title={t('cards.recommendationsList')} title={t('cards.recommendationsList')}
subheader={t('subheaders.actionItems')} subheader={t('subheaders.actionItems')}
action={<OptionMenu options={[t('optionMenu.export'), t('optionMenu.snooze'), t('optionMenu.markDone')]} />}
/> />
<CardContent className='flex flex-col gap-4'> <CardContent className='flex flex-col gap-4'>
{recommendations.map((item, index) => ( {recommendations.map((item, index) => (
@@ -13,15 +13,19 @@ import { useTheme } from '@mui/material/styles'
// Third-party Imports // Third-party Imports
import type { ApexOptions } from 'apexcharts' import type { ApexOptions } from 'apexcharts'
import DeviceCodeSelect, {
type DeviceCodeSelectProps
} from '@/views/dashboards/farm/shared-sensors/DeviceCodeSelect'
// Styled Component Imports // Styled Component Imports
const AppReactApexCharts = dynamic(() => import('@/libs/styles/AppReactApexCharts')) const AppReactApexCharts = dynamic(() => import('@/libs/styles/AppReactApexCharts'))
interface SensorComparisonChartProps { interface SensorComparisonChartProps {
data?: Record<string, unknown> data?: Record<string, unknown>
deviceCodeSelectProps?: DeviceCodeSelectProps
} }
const SensorComparisonChart = ({ data }: SensorComparisonChartProps) => { const SensorComparisonChart = ({ data, deviceCodeSelectProps }: SensorComparisonChartProps) => {
const t = useTranslations('farmDashboard') const t = useTranslations('farmDashboard')
const series = (data?.series as Array<{ name: string; data: number[] }>) ?? [] const series = (data?.series as Array<{ name: string; data: number[] }>) ?? []
const categories = const categories =
@@ -30,7 +34,6 @@ const SensorComparisonChart = ({ data }: SensorComparisonChartProps) => {
const currentValue = (data?.currentValue as number | undefined) ?? 48 const currentValue = (data?.currentValue as number | undefined) ?? 48
const vsLastWeek = (data?.vsLastWeek as string) ?? t('fallback.plusPercentVsLastWeek', { val: '5' }) const vsLastWeek = (data?.vsLastWeek as string) ?? t('fallback.plusPercentVsLastWeek', { val: '5' })
const theme = useTheme() const theme = useTheme()
if (series.length === 0) return null
const options: ApexOptions = { const options: ApexOptions = {
chart: { chart: {
@@ -81,15 +84,24 @@ const SensorComparisonChart = ({ data }: SensorComparisonChartProps) => {
<CardHeader <CardHeader
title={t('cards.sensorComparisonChart')} title={t('cards.sensorComparisonChart')}
subheader={t('subheaders.todayVsLastWeek')} subheader={t('subheaders.todayVsLastWeek')}
action={deviceCodeSelectProps ? <DeviceCodeSelect {...deviceCodeSelectProps} /> : null}
/> />
<CardContent> <CardContent>
<div className='flex items-center gap-4 mbe-4'> {series.length > 0 ? (
<Typography variant='h4'>{currentValue}%</Typography> <>
<Typography color='success.main' variant='body2'> <div className='flex items-center gap-4 mbe-4'>
{vsLastWeek} <Typography variant='h4'>{currentValue}%</Typography>
<Typography color='success.main' variant='body2'>
{vsLastWeek}
</Typography>
</div>
<AppReactApexCharts type='area' height={280} width='100%' series={series} options={options} />
</>
) : (
<Typography variant='body2' color='text.secondary'>
برای `device_code` انتخابشده دادهای برای نمودار مقایسهای موجود نیست.
</Typography> </Typography>
</div> )}
<AppReactApexCharts type='area' height={280} width='100%' series={series} options={options} />
</CardContent> </CardContent>
</Card> </Card>
) )
+14 -7
View File
@@ -8,29 +8,30 @@ 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 { useTheme } from '@mui/material/styles' import { useTheme } from '@mui/material/styles'
// Third Party Imports // Third Party Imports
import type { ApexOptions } from 'apexcharts' import type { ApexOptions } from 'apexcharts'
import DeviceCodeSelect, {
// Component Imports type DeviceCodeSelectProps
import OptionMenu from '@core/components/option-menu' } from '@/views/dashboards/farm/shared-sensors/DeviceCodeSelect'
// Styled Component Imports // Styled Component Imports
const AppReactApexCharts = dynamic(() => import('@/libs/styles/AppReactApexCharts')) const AppReactApexCharts = dynamic(() => import('@/libs/styles/AppReactApexCharts'))
interface SensorRadarChartProps { interface SensorRadarChartProps {
data?: Record<string, unknown> data?: Record<string, unknown>
deviceCodeSelectProps?: DeviceCodeSelectProps
} }
const SensorRadarChart = ({ data }: SensorRadarChartProps) => { const SensorRadarChart = ({ data, deviceCodeSelectProps }: SensorRadarChartProps) => {
const t = useTranslations('farmDashboard') const t = useTranslations('farmDashboard')
const series = (data?.series as Array<{ name: string; data: number[] }>) ?? [] const series = (data?.series as Array<{ name: string; data: number[] }>) ?? []
const labels = (data?.labels as string[]) ?? [] const labels = (data?.labels as string[]) ?? []
const theme = useTheme() const theme = useTheme()
const textDisabled = 'var(--mui-palette-text-disabled)' const textDisabled = 'var(--mui-palette-text-disabled)'
const divider = 'var(--mui-palette-divider)' const divider = 'var(--mui-palette-divider)'
if (series.length === 0) return null
const options: ApexOptions = { const options: ApexOptions = {
chart: { chart: {
@@ -74,10 +75,16 @@ const SensorRadarChart = ({ data }: SensorRadarChartProps) => {
<CardHeader <CardHeader
title={t('cards.sensorRadarChart')} title={t('cards.sensorRadarChart')}
subheader={t('subheaders.todayVsIdealRanges')} subheader={t('subheaders.todayVsIdealRanges')}
action={<OptionMenu options={[t('optionMenu.today'), t('optionMenu.thisWeek'), t('optionMenu.thisMonth')]} />} action={deviceCodeSelectProps ? <DeviceCodeSelect {...deviceCodeSelectProps} /> : null}
/> />
<CardContent> <CardContent>
<AppReactApexCharts type='radar' height={373} width='100%' series={series} options={options} /> {series.length > 0 ? (
<AppReactApexCharts type='radar' height={373} width='100%' series={series} options={options} />
) : (
<Typography variant='body2' color='text.secondary'>
برای `device_code` انتخابشده دادهای برای نمودار رادار موجود نیست.
</Typography>
)}
</CardContent> </CardContent>
</Card> </Card>
) )
+34 -28
View File
@@ -11,9 +11,9 @@ import Typography from '@mui/material/Typography'
// Third-party Imports // Third-party Imports
import classnames from 'classnames' import classnames from 'classnames'
import DeviceCodeSelect, {
// Component Imports type DeviceCodeSelectProps
import OptionMenu from '@core/components/option-menu' } from '@/views/dashboards/farm/shared-sensors/DeviceCodeSelect'
type SensorDataType = { type SensorDataType = {
title: string title: string
@@ -25,45 +25,51 @@ type SensorDataType = {
interface SensorValuesListProps { interface SensorValuesListProps {
data?: Record<string, unknown> data?: Record<string, unknown>
deviceCodeSelectProps?: DeviceCodeSelectProps
} }
const SensorValuesList = ({ data }: SensorValuesListProps) => { const SensorValuesList = ({ data, deviceCodeSelectProps }: SensorValuesListProps) => {
const t = useTranslations('farmDashboard') const t = useTranslations('farmDashboard')
const sensors = (data?.sensors as SensorDataType[] | undefined) ?? [] const sensors = (data?.sensors as SensorDataType[] | undefined) ?? []
if (sensors.length === 0) return null
return ( return (
<Card> <Card>
<CardHeader <CardHeader
title={t('cards.sensorValuesList')} title={t('cards.sensorValuesList')}
subheader={t('subheaders.realtimeData')} subheader={t('subheaders.realtimeData')}
action={<OptionMenu options={[t('optionMenu.lastHour'), t('optionMenu.last24h'), t('optionMenu.last7Days')]} />} action={deviceCodeSelectProps ? <DeviceCodeSelect {...deviceCodeSelectProps} /> : null}
/> />
<CardContent className='flex flex-col gap-4'> <CardContent className='flex flex-col gap-4'>
{sensors.map((item, index) => ( {sensors.length > 0 ? (
<div key={index} className='flex items-center gap-4'> sensors.map((item, index) => (
<div className='flex flex-wrap justify-between items-center gap-x-4 gap-y-1 is-full'> <div key={index} className='flex items-center gap-4'>
<div className='flex flex-col'> <div className='flex flex-wrap justify-between items-center gap-x-4 gap-y-1 is-full'>
<Typography className='font-medium' color='text.primary'> <div className='flex flex-col'>
{item.title} <Typography className='font-medium' color='text.primary'>
</Typography> {item.title}
<Typography variant='body2'>{item.subtitle}</Typography> </Typography>
</div> <Typography variant='body2'>{item.subtitle}</Typography>
<div className='flex items-center gap-1'> </div>
<i <div className='flex items-center gap-1'>
className={classnames( <i
item.trend === 'negative' ? 'tabler-chevron-down text-error' : 'tabler-chevron-up text-success', className={classnames(
'text-xl' item.trend === 'negative' ? 'tabler-chevron-down text-error' : 'tabler-chevron-up text-success',
)} 'text-xl'
/> )}
<Typography />
variant='h6' <Typography
color={`${item.trend === 'negative' ? 'error' : 'success'}.main`} variant='h6'
>{`${item.trendNumber > 0 ? '+' : ''}${item.trendNumber}%`}</Typography> color={`${item.trend === 'negative' ? 'error' : 'success'}.main`}
>{`${item.trendNumber > 0 ? '+' : ''}${item.trendNumber}%`}</Typography>
</div>
</div> </div>
</div> </div>
</div> ))
))} ) : (
<Typography variant='body2' color='text.secondary'>
برای `device_code` انتخابشده دادهای برای لیست مقادیر موجود نیست.
</Typography>
)}
</CardContent> </CardContent>
</Card> </Card>
) )
@@ -16,9 +16,6 @@ import { useTheme } from '@mui/material/styles'
// Third-party Imports // Third-party Imports
import type { ApexOptions } from 'apexcharts' import type { ApexOptions } from 'apexcharts'
// Component Imports
import OptionMenu from '@core/components/option-menu'
// Styled Component Imports // Styled Component Imports
const AppReactApexCharts = dynamic(() => import('@/libs/styles/AppReactApexCharts')) const AppReactApexCharts = dynamic(() => import('@/libs/styles/AppReactApexCharts'))
@@ -82,7 +79,6 @@ const WaterNeedPrediction = ({ data }: WaterNeedPredictionProps) => {
<CardHeader <CardHeader
title={t('cards.waterNeedPrediction')} title={t('cards.waterNeedPrediction')}
subheader={t('subheaders.aiForecast')} subheader={t('subheaders.aiForecast')}
action={<OptionMenu options={[t('optionMenu.export'), t('optionMenu.adjust'), t('optionMenu.details')]} />}
/> />
<CardContent> <CardContent>
<div className='flex items-center gap-4 mbe-4'> <div className='flex items-center gap-4 mbe-4'>
@@ -16,7 +16,6 @@ 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 CustomAvatar from '@core/components/mui/Avatar' import CustomAvatar from '@core/components/mui/Avatar'
// Styled Component Imports // Styled Component Imports
@@ -94,7 +93,6 @@ const YieldPredictionChart = ({ data }: YieldPredictionChartProps) => {
<CardHeader <CardHeader
title={t('cards.yieldPredictionChart')} title={t('cards.yieldPredictionChart')}
subheader={t('subheaders.thisYearVsLastYear')} subheader={t('subheaders.thisYearVsLastYear')}
action={<OptionMenu options={[t('optionMenu.export'), t('optionMenu.compare'), t('optionMenu.details')]} />}
/> />
<CardContent className='flex flex-col gap-4' sx={{ flex: 1 }}> <CardContent className='flex flex-col gap-4' sx={{ flex: 1 }}>
<AppReactApexCharts type='line' height={280} width='100%' series={series} options={options} /> <AppReactApexCharts type='line' height={280} width='100%' series={series} options={options} />
@@ -12,6 +12,8 @@ export const ROW_IDS = [
'predictions', 'predictions',
'soilHeatmap', 'soilHeatmap',
'ndviRecommendations', 'ndviRecommendations',
'pestRisk',
'dailyOperations',
'economic' 'economic'
] as const ] as const
@@ -32,6 +34,11 @@ export const CARD_IDS = [
'soilMoistureHeatmap', 'soilMoistureHeatmap',
'ndviHealthCard', 'ndviHealthCard',
'recommendationsList', 'recommendationsList',
'diseaseRiskCard',
'pestRiskCard',
'farmerTodoSummary',
'farmerCalendarSummary',
'farmWalletSummary',
'economicOverview' 'economicOverview'
] as const ] as const
@@ -53,6 +60,11 @@ export const CARD_TO_ROW: Record<CardId, RowId> = {
soilMoistureHeatmap: 'soilHeatmap', soilMoistureHeatmap: 'soilHeatmap',
ndviHealthCard: 'ndviRecommendations', ndviHealthCard: 'ndviRecommendations',
recommendationsList: 'ndviRecommendations', recommendationsList: 'ndviRecommendations',
diseaseRiskCard: 'overviewKpis',
pestRiskCard: 'overviewKpis',
farmerTodoSummary: 'dailyOperations',
farmerCalendarSummary: 'dailyOperations',
farmWalletSummary: 'dailyOperations',
economicOverview: 'economic' economicOverview: 'economic'
} }
@@ -72,6 +84,11 @@ export const CARD_GRID_SIZE: Record<CardId, { xs?: number; sm?: number; md?: num
soilMoistureHeatmap: { xs: 12, lg: 12 }, soilMoistureHeatmap: { xs: 12, lg: 12 },
ndviHealthCard: { xs: 12, md: 6, lg: 4 }, ndviHealthCard: { xs: 12, md: 6, lg: 4 },
recommendationsList: { xs: 12, md: 6, lg: 8 }, recommendationsList: { xs: 12, md: 6, lg: 8 },
diseaseRiskCard: { xs: 12, md: 6, lg: 6 },
pestRiskCard: { xs: 12, md: 6, lg: 6 },
farmerTodoSummary: { xs: 12, md: 6, lg: 4 },
farmerCalendarSummary: { xs: 12, md: 6, lg: 4 },
farmWalletSummary: { xs: 12, md: 6, lg: 4 },
economicOverview: { xs: 12, lg: 12 } economicOverview: { xs: 12, lg: 12 }
} }
@@ -91,6 +108,11 @@ export const CARD_LABELS: Record<CardId, string> = {
soilMoistureHeatmap: 'Soil Moisture Heatmap', soilMoistureHeatmap: 'Soil Moisture Heatmap',
ndviHealthCard: 'NDVI Health', ndviHealthCard: 'NDVI Health',
recommendationsList: 'Recommendations', recommendationsList: 'Recommendations',
diseaseRiskCard: 'Disease Risk',
pestRiskCard: 'Pest Risk',
farmerTodoSummary: 'Todo Summary',
farmerCalendarSummary: 'Calendar Summary',
farmWalletSummary: 'Wallet Summary',
economicOverview: 'Economic Overview' economicOverview: 'Economic Overview'
} }
@@ -104,12 +126,14 @@ export const ROW_LABELS: Record<RowId, string> = {
predictions: 'Predictions', predictions: 'Predictions',
soilHeatmap: 'Soil Moisture Heatmap', soilHeatmap: 'Soil Moisture Heatmap',
ndviRecommendations: 'NDVI & Recommendations', ndviRecommendations: 'NDVI & Recommendations',
pestRisk: 'Pest Risk',
dailyOperations: 'Daily Operations',
economic: 'Economic Overview' economic: 'Economic Overview'
} }
/** Cards that belong to each row (for rendering) */ /** Cards that belong to each row (for rendering) */
export const ROW_CARDS: Record<RowId, CardId[]> = { export const ROW_CARDS: Record<RowId, CardId[]> = {
overviewKpis: ['farmOverviewKpis'], overviewKpis: ['farmOverviewKpis', 'diseaseRiskCard', 'pestRiskCard'],
weatherAlerts: ['farmWeatherCard', 'farmAlertsTracker'], weatherAlerts: ['farmWeatherCard', 'farmAlertsTracker'],
sensorMonitoring: ['sensorValuesList', 'sensorRadarChart'], sensorMonitoring: ['sensorValuesList', 'sensorRadarChart'],
sensorCharts: ['sensorComparisonChart', 'anomalyDetectionCard'], sensorCharts: ['sensorComparisonChart', 'anomalyDetectionCard'],
@@ -117,6 +141,8 @@ export const ROW_CARDS: Record<RowId, CardId[]> = {
predictions: ['harvestPredictionCard', 'yieldPredictionChart'], predictions: ['harvestPredictionCard', 'yieldPredictionChart'],
soilHeatmap: ['soilMoistureHeatmap'], soilHeatmap: ['soilMoistureHeatmap'],
ndviRecommendations: ['ndviHealthCard', 'recommendationsList'], ndviRecommendations: ['ndviHealthCard', 'recommendationsList'],
pestRisk: [],
dailyOperations: ['farmerTodoSummary', 'farmerCalendarSummary', 'farmWalletSummary'],
economic: ['economicOverview'] economic: ['economicOverview']
} }
+217 -70
View File
@@ -31,6 +31,7 @@ import { alpha, useTheme } from "@mui/material/styles";
import { useFarmHub } from "@/hooks/useFarmHub"; import { useFarmHub } from "@/hooks/useFarmHub";
import { import {
normalizeComparisonChartResponse, normalizeComparisonChartResponse,
type DeviceCodesResponse,
normalizeRadarChartResponse, normalizeRadarChartResponse,
normalizeValuesListResponse, normalizeValuesListResponse,
sensor7Service, sensor7Service,
@@ -290,6 +291,11 @@ const Sensor7Page = () => {
useState<SensorValuesListResponse>(EMPTY_VALUES_LIST); useState<SensorValuesListResponse>(EMPTY_VALUES_LIST);
const [dashboardLoading, setDashboardLoading] = useState(false); const [dashboardLoading, setDashboardLoading] = useState(false);
const [dashboardErrorMessage, setDashboardErrorMessage] = useState<string | null>(null); const [dashboardErrorMessage, setDashboardErrorMessage] = useState<string | null>(null);
const [deviceCodes, setDeviceCodes] = useState<DeviceCodesResponse["device_codes"]>([]);
const [selectedDeviceCode, setSelectedDeviceCode] = useState("");
const [deviceCodesLoading, setDeviceCodesLoading] = useState(false);
const [deviceCodesResolved, setDeviceCodesResolved] = useState(false);
const [deviceCodesErrorMessage, setDeviceCodesErrorMessage] = useState<string | null>(null);
const [logs, setLogs] = useState<SensorExternalRequestLog[]>([]); const [logs, setLogs] = useState<SensorExternalRequestLog[]>([]);
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
@@ -300,89 +306,37 @@ const Sensor7Page = () => {
const [logsErrorMessage, setLogsErrorMessage] = useState<string | null>(null); const [logsErrorMessage, setLogsErrorMessage] = useState<string | null>(null);
const [selectedLogId, setSelectedLogId] = useState<number | null>(null); const [selectedLogId, setSelectedLogId] = useState<number | null>(null);
const [selectedDeviceId, setSelectedDeviceId] = useState<string>(""); const [selectedDeviceId, setSelectedDeviceId] = useState<string>("");
const [resolvedPrimaryDeviceId, setResolvedPrimaryDeviceId] = useState<string>("");
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
setSelectedLogId(null); setSelectedLogId(null);
setSelectedDeviceId(""); setSelectedDeviceId("");
setResolvedPrimaryDeviceId("");
setDeviceCodes([]);
setSelectedDeviceCode("");
setDeviceCodesLoading(false);
setDeviceCodesResolved(false);
setDeviceCodesErrorMessage(null);
}, [farmUuid]); }, [farmUuid]);
useEffect(() => { useEffect(() => {
if (!farmUuid) { if (!farmUuid) {
setSummaryData(null); setResolvedPrimaryDeviceId("");
setComparisonChartData(EMPTY_COMPARISON_CHART);
setRadarChartData(EMPTY_RADAR_CHART);
setSensorValuesListData(EMPTY_VALUES_LIST);
setDashboardLoading(false);
setDashboardErrorMessage(null);
return; return;
} }
let isCancelled = false; let isCancelled = false;
const loadDashboardData = async () => { const resolveDevice = async () => {
setDashboardLoading(true); const deviceId = await sensor7Service.resolvePrimaryPhysicalDeviceUuid(farmUuid);
setDashboardErrorMessage(null);
const results = await Promise.allSettled([ if (!isCancelled) {
sensor7Service.getSummary(farmUuid), setResolvedPrimaryDeviceId(deviceId ?? "");
sensor7Service.getComparisonChart({
farmUuid,
range: DEFAULT_COMPARISON_RANGE,
}),
sensor7Service.getRadarChart({
farmUuid,
range: DEFAULT_RADAR_RANGE,
}),
sensor7Service.getValuesList({
farmUuid,
range: DEFAULT_VALUES_RANGE,
}),
]);
if (isCancelled) return;
const [summaryResult, comparisonResult, radarResult, valuesListResult] = results;
const nextSummaryData = summaryResult.status === "fulfilled" ? summaryResult.value : null;
const nextComparisonChartData =
comparisonResult.status === "fulfilled"
? comparisonResult.value
: normalizeComparisonChartResponse(nextSummaryData?.sensorComparisonChart);
const nextRadarChartData =
radarResult.status === "fulfilled"
? radarResult.value
: normalizeRadarChartResponse(nextSummaryData?.sensorRadarChart);
const nextValuesListData =
valuesListResult.status === "fulfilled"
? valuesListResult.value
: mapSummaryValuesList(nextSummaryData?.sensorValuesList);
setSummaryData(nextSummaryData);
setComparisonChartData(nextComparisonChartData);
setRadarChartData(nextRadarChartData);
setSensorValuesListData(nextValuesListData);
const failedResults = results.filter(
(result): result is PromiseRejectedResult => result.status === "rejected",
);
if (failedResults.length === results.length) {
setDashboardErrorMessage(
getErrorMessage(failedResults[0]?.reason, "بارگذاری داده های سنسور انجام نشد."),
);
} else if (failedResults.length > 0) {
setDashboardErrorMessage(
"بخشی از داده های سنسور بارگذاری نشد و داده های در دسترس نمایش داده شدند.",
);
} else {
setDashboardErrorMessage(null);
} }
setDashboardLoading(false);
}; };
void loadDashboardData(); void resolveDevice();
return () => { return () => {
isCancelled = true; isCancelled = true;
@@ -501,6 +455,184 @@ const Sensor7Page = () => {
return sensorOptions[0]?.value ?? ""; return sensorOptions[0]?.value ?? "";
}, [selectedDeviceId, sensorOptions, summaryData?.sensor?.physicalDeviceUuid]); }, [selectedDeviceId, sensorOptions, summaryData?.sensor?.physicalDeviceUuid]);
const dashboardDeviceId = activeDeviceId || resolvedPrimaryDeviceId;
useEffect(() => {
if (!dashboardDeviceId) {
setDeviceCodes([]);
setSelectedDeviceCode("");
setDeviceCodesLoading(false);
setDeviceCodesResolved(false);
setDeviceCodesErrorMessage(null);
return;
}
let isCancelled = false;
const loadDeviceCodes = async () => {
setDeviceCodesLoading(true);
setDeviceCodesResolved(false);
setDeviceCodesErrorMessage(null);
try {
const response = await sensor7Service.getDeviceCodes(dashboardDeviceId);
if (isCancelled) return;
const nextDeviceCodes = Array.isArray(response.device_codes) ? response.device_codes : [];
setDeviceCodes(nextDeviceCodes);
setSelectedDeviceCode((current) =>
current && nextDeviceCodes.includes(current) ? current : (nextDeviceCodes[0] ?? ""),
);
if (nextDeviceCodes.length === 0) {
setDeviceCodesErrorMessage("برای این دستگاه هیچ device_code ای ثبت نشده است.");
}
} catch (error) {
if (isCancelled) return;
setDeviceCodes([]);
setSelectedDeviceCode("");
setDeviceCodesErrorMessage(
getErrorMessage(error, "بارگذاری device_code های این دستگاه انجام نشد."),
);
} finally {
if (!isCancelled) {
setDeviceCodesLoading(false);
setDeviceCodesResolved(true);
}
}
};
void loadDeviceCodes();
return () => {
isCancelled = true;
};
}, [dashboardDeviceId]);
useEffect(() => {
if (!farmUuid || !dashboardDeviceId) {
setSummaryData(null);
setComparisonChartData(EMPTY_COMPARISON_CHART);
setRadarChartData(EMPTY_RADAR_CHART);
setSensorValuesListData(EMPTY_VALUES_LIST);
setDashboardLoading(false);
setDashboardErrorMessage(null);
return;
}
if (!deviceCodesResolved) {
return;
}
if (!selectedDeviceCode) {
setSummaryData(null);
setComparisonChartData(EMPTY_COMPARISON_CHART);
setRadarChartData(EMPTY_RADAR_CHART);
setSensorValuesListData(EMPTY_VALUES_LIST);
setDashboardLoading(false);
if (deviceCodesErrorMessage) {
setDashboardErrorMessage(deviceCodesErrorMessage);
}
return;
}
let isCancelled = false;
const loadDashboardData = async () => {
setDashboardLoading(true);
setDashboardErrorMessage(null);
const results = await Promise.allSettled([
sensor7Service.getSummary(dashboardDeviceId, selectedDeviceCode),
sensor7Service.getComparisonChart({
physicalDeviceUuid: dashboardDeviceId,
deviceCode: selectedDeviceCode,
range: DEFAULT_COMPARISON_RANGE,
}),
sensor7Service.getRadarChart({
physicalDeviceUuid: dashboardDeviceId,
deviceCode: selectedDeviceCode,
range: DEFAULT_RADAR_RANGE,
}),
sensor7Service.getValuesList({
physicalDeviceUuid: dashboardDeviceId,
deviceCode: selectedDeviceCode,
range: DEFAULT_VALUES_RANGE,
}),
]);
if (isCancelled) return;
const [summaryResult, comparisonResult, radarResult, valuesListResult] = results;
const nextSummaryData = summaryResult.status === "fulfilled" ? summaryResult.value : null;
const nextComparisonChartData =
comparisonResult.status === "fulfilled"
? comparisonResult.value
: normalizeComparisonChartResponse(nextSummaryData?.sensorComparisonChart);
const nextRadarChartData =
radarResult.status === "fulfilled"
? radarResult.value
: normalizeRadarChartResponse(nextSummaryData?.sensorRadarChart);
const nextValuesListData =
valuesListResult.status === "fulfilled"
? valuesListResult.value
: mapSummaryValuesList(nextSummaryData?.sensorValuesList);
setSummaryData(nextSummaryData);
setComparisonChartData(nextComparisonChartData);
setRadarChartData(nextRadarChartData);
setSensorValuesListData(nextValuesListData);
const failedResults = results.filter(
(result): result is PromiseRejectedResult => result.status === "rejected",
);
if (failedResults.length === results.length) {
setDashboardErrorMessage(
getErrorMessage(failedResults[0]?.reason, "بارگذاری داده های سنسور انجام نشد."),
);
} else if (failedResults.length > 0) {
setDashboardErrorMessage(
"بخشی از داده های سنسور بارگذاری نشد و داده های در دسترس نمایش داده شدند.",
);
} else {
setDashboardErrorMessage(null);
}
setDashboardLoading(false);
};
void loadDashboardData();
return () => {
isCancelled = true;
};
}, [
dashboardDeviceId,
deviceCodesErrorMessage,
deviceCodesResolved,
farmUuid,
selectedDeviceCode,
]);
const deviceCodeSelectProps = useMemo(
() => ({
options: deviceCodes,
value: selectedDeviceCode,
onChange: setSelectedDeviceCode,
loading: deviceCodesLoading,
disabled: !dashboardDeviceId,
label: "device_code",
}),
[dashboardDeviceId, deviceCodes, deviceCodesLoading, selectedDeviceCode],
);
const filteredLogs = useMemo(() => { const filteredLogs = useMemo(() => {
if (!activeDeviceId) return logs; if (!activeDeviceId) return logs;
@@ -600,6 +732,12 @@ const Sensor7Page = () => {
</Alert> </Alert>
) : null} ) : null}
{deviceCodesErrorMessage && !dashboardErrorMessage ? (
<Alert severity={deviceCodes.length > 0 ? "warning" : "error"}>
{deviceCodesErrorMessage}
</Alert>
) : null}
<Grid container spacing={3}> <Grid container spacing={3}>
{metrics.map((metric) => ( {metrics.map((metric) => (
<Grid key={metric.label} size={{ xs: 12, sm: 6, xl: 3 }}> <Grid key={metric.label} size={{ xs: 12, sm: 6, xl: 3 }}>
@@ -612,13 +750,22 @@ const Sensor7Page = () => {
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid size={{ xs: 12, lg: 7 }}> <Grid size={{ xs: 12, lg: 7 }}>
<SensorComparisonChart data={comparisonChartData as unknown as Record<string, unknown>} /> <SensorComparisonChart
data={comparisonChartData as unknown as Record<string, unknown>}
deviceCodeSelectProps={deviceCodeSelectProps}
/>
</Grid> </Grid>
<Grid size={{ xs: 12, lg: 5 }}> <Grid size={{ xs: 12, lg: 5 }}>
<SensorValuesList data={sensorValuesListData as unknown as Record<string, unknown>} /> <SensorValuesList
data={sensorValuesListData as unknown as Record<string, unknown>}
deviceCodeSelectProps={deviceCodeSelectProps}
/>
</Grid> </Grid>
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
<SensorRadarChart data={radarChartData as unknown as Record<string, unknown>} /> <SensorRadarChart
data={radarChartData as unknown as Record<string, unknown>}
deviceCodeSelectProps={deviceCodeSelectProps}
/>
</Grid> </Grid>
</Grid> </Grid>
@@ -647,8 +794,8 @@ const Sensor7Page = () => {
محتوای سنسور انتخاب شده محتوای سنسور انتخاب شده
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" className="mt-1"> <Typography variant="body2" color="text.secondary" className="mt-1">
داده های این بخش از endpoint لاگ سنسور خارجی خوانده می شوند؛ سنسور را از داده های این بخش از لاگ سنسور و API داینامیک device-hub خوانده می شوند؛
لیست انتخاب کنید و روی هر ردیف بزنید تا جزئیات همان لاگ نمایش داده شود. سنسور را از لیست انتخاب کنید و روی هر ردیف بزنید تا جزئیات همان لاگ نمایش داده شود.
</Typography> </Typography>
</Box> </Box>
@@ -0,0 +1,60 @@
'use client'
import FormControl from '@mui/material/FormControl'
import InputLabel from '@mui/material/InputLabel'
import MenuItem from '@mui/material/MenuItem'
import Select from '@mui/material/Select'
import type { SelectChangeEvent } from '@mui/material/Select'
export interface DeviceCodeSelectProps {
options: string[]
value: string
onChange: (value: string) => void
loading?: boolean
disabled?: boolean
label?: string
minWidth?: number
}
const DeviceCodeSelect = ({
options,
value,
onChange,
loading = false,
disabled = false,
label = 'device_code',
minWidth = 180
}: DeviceCodeSelectProps) => {
const labelId = `device-code-select-${label}`
const handleChange = (event: SelectChangeEvent<string>) => {
onChange(event.target.value)
}
return (
<FormControl size='small' sx={{ minWidth }}>
<InputLabel id={labelId}>{label}</InputLabel>
<Select<string>
labelId={labelId}
value={value}
label={label}
onChange={handleChange}
disabled={disabled || (loading && options.length === 0)}
>
{options.length > 0 ? (
options.map(option => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))
) : (
<MenuItem value='' disabled>
{loading ? 'در حال بارگذاری...' : 'device_code موجود نیست'}
</MenuItem>
)}
</Select>
</FormControl>
)
}
export default DeviceCodeSelect