UPDATE
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user