diff --git a/src/views/dashboards/farm/sensor7/BACKEND_REQUIREMENTS.md b/src/views/dashboards/farm/sensor7/BACKEND_REQUIREMENTS.md new file mode 100644 index 0000000..7f250de --- /dev/null +++ b/src/views/dashboards/farm/sensor7/BACKEND_REQUIREMENTS.md @@ -0,0 +1,312 @@ +# Backend Requirements For Sensor Content Page + +## Goal + +This page is now focused on showing the content of a selected sensor, plus three analytics cards that each consume their own API: + +- `SensorComparisonChart.tsx` +- `SensorValuesList.tsx` +- `SensorRadarChart.tsx` + +The page currently uses mock data in the frontend. To switch it to real data, the backend needs to provide the APIs and payload contracts below. + +--- + +## 1) Main Sensor Logs API + +This API powers: + +- sensor selector +- top summary cards +- health panel +- logs table + +### Suggested endpoint + +`GET /api/sensor-external-api/logs/` + +### Required query params + +- `farm_uuid` - required +- `page` - required for pagination +- `page_size` - required for pagination + +### Recommended extra query params + +- `physical_device_uuid` - to fetch only one sensor directly from backend +- `sensor_type` - optional future filter +- `date_from` - optional +- `date_to` - optional + +### Required response shape + +```json +{ + "code": 200, + "msg": "success", + "count": 120, + "next": null, + "previous": null, + "data": [ + { + "id": 108, + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "sensor_catalog_uuid": "catalog-7in1-01", + "physical_device_uuid": "device-7in1-0001", + "farm_sensor": { + "uuid": "farm-sensor-7in1-01", + "sensor_catalog_uuid": "catalog-7in1-01", + "physical_device_uuid": "device-7in1-0001", + "name": "سنسور خاک 7 در 1 - بلوک شمالی", + "sensor_type": "Soil 7 in 1", + "is_active": true, + "specifications": {}, + "power_source": {}, + "created_at": "2025-01-01T08:00:00Z", + "updated_at": "2025-01-10T08:00:00Z" + }, + "sensor_catalog": { + "uuid": "catalog-7in1-01", + "code": "SOIL-7IN1", + "name": "کاتالوگ سنسور خاک 7 در 1", + "description": "string", + "customizable_fields": [], + "supported_power_sources": [], + "returned_data_fields": [ + "moisture", + "temperature", + "humidity", + "ph", + "ec", + "nitrogen", + "phosphorus", + "potassium" + ], + "sample_payload": {}, + "is_active": true, + "created_at": "2025-01-01T08:00:00Z", + "updated_at": "2025-01-10T08:00:00Z" + }, + "payload": { + "moisture": 61, + "temperature": 27.4, + "humidity": 58, + "ph": 6.7, + "ec": 1.45, + "nitrogen": 44, + "phosphorus": 32, + "potassium": 39 + }, + "created_at": "2025-05-02T11:20:00Z" + } + ] +} +``` + +### Notes + +- `payload` must keep numeric values as numbers, not strings +- `physical_device_uuid` is required because the page groups and filters by sensor device +- `farm_sensor.name` and `farm_sensor.sensor_type` are needed for sensor dropdown labels +- `sensor_catalog.returned_data_fields` is needed to understand sensor structure + +--- + +## 2) Comparison Chart API + +This API powers `src/views/dashboards/farm/SensorComparisonChart.tsx` + +### Suggested endpoint + +`GET /api/sensors/comparison-chart/` + +### Required query params + +- `farm_uuid` +- `physical_device_uuid` +- `range` - example: `7d`, `30d` + +### Required response shape + +```json +{ + "series": [ + { + "name": "moisture", + "data": [56, 58, 55, 60, 62, 61, 59] + }, + { + "name": "temperature", + "data": [26.2, 26.7, 27.0, 27.2, 27.5, 27.4, 27.1] + } + ], + "categories": ["شنبه", "یکشنبه", "دوشنبه", "سه شنبه", "چهارشنبه", "پنج شنبه", "جمعه"], + "currentValue": 61, + "vsLastWeek": "+5.4%" +} +``` + +### Notes + +- This endpoint is independent and should not be derived in frontend from logs +- `currentValue` is shown in large text in the card +- `vsLastWeek` is shown as summary text in the card + +--- + +## 3) Sensor Values List API + +This API powers `src/views/dashboards/farm/SensorValuesList.tsx` + +### Suggested endpoint + +`GET /api/sensors/values-list/` + +### Required query params + +- `farm_uuid` +- `physical_device_uuid` +- `range` - example: `1h`, `24h`, `7d` + +### Required response shape + +```json +{ + "sensors": [ + { + "title": "Moisture", + "subtitle": "مقدار فعلی: 61%", + "trendNumber": 4.8, + "trend": "positive", + "unit": "%" + }, + { + "title": "Temperature", + "subtitle": "مقدار فعلی: 27.4°C", + "trendNumber": -1.2, + "trend": "negative", + "unit": "°C" + } + ] +} +``` + +### Notes + +- `trend` must be either `positive` or `negative` +- `trendNumber` should be numeric +- `subtitle` can already be formatted by backend if preferred + +--- + +## 4) Radar Chart API + +This API powers `src/views/dashboards/farm/SensorRadarChart.tsx` + +### Suggested endpoint + +`GET /api/sensors/radar-chart/` + +### Required query params + +- `farm_uuid` +- `physical_device_uuid` +- `range` - example: `today`, `7d`, `30d` + +### Required response shape + +```json +{ + "labels": [ + "Moisture", + "Temperature", + "Humidity", + "PH", + "EC", + "Nitrogen", + "Potassium" + ], + "series": [ + { + "name": "وضعیت فعلی", + "data": [61, 27.4, 58, 6.7, 1.45, 44, 39] + }, + { + "name": "بازه ایده آل", + "data": [60, 26, 55, 6.5, 1.3, 42, 38] + } + ] +} +``` + +### Notes + +- This endpoint is also independent +- `labels.length` must match every `series[i].data.length` + +--- + +## 5) Recommended Shared Backend Rules + +These rules should be consistent across all four APIs: + +- all timestamps in ISO 8601 format +- all numeric sensor values returned as numbers +- all UUID-like identifiers stable and non-null when available +- responses filtered by `farm_uuid` +- responses filtered by `physical_device_uuid` when a single sensor page is opened +- clear 4xx/5xx error messages in `msg` or `detail` + +--- + +## 6) Sensor Metadata Recommended For Future Use + +These fields are not all required immediately, but they will help future UI work: + +- sensor display name +- sensor type +- installation location +- block / zone / greenhouse name +- unit for each measurable field +- min / max ideal thresholds per field +- sensor online / offline state +- last sync timestamp +- battery status +- signal strength + +Example: + +```json +{ + "field_meta": { + "moisture": { "label": "Moisture", "unit": "%", "ideal_min": 45, "ideal_max": 70 }, + "temperature": { "label": "Temperature", "unit": "°C", "ideal_min": 18, "ideal_max": 30 }, + "ph": { "label": "PH", "unit": "", "ideal_min": 5.8, "ideal_max": 7.2 } + } +} +``` + +--- + +## 7) Current Frontend State + +Right now the page: + +- uses full mock data for logs and sensor details +- uses mock-derived data for charts +- has the right-side details panel removed +- expects the three chart cards to be fed from three separate APIs later + +--- + +## 8) Minimum Backend Delivery Checklist + +To connect the page to real backend data, the minimum required items are: + +- one paginated logs API with `farm_uuid` and ideally `physical_device_uuid` +- one API for comparison chart card +- one API for values list card +- one API for radar chart card +- numeric sensor payload values +- stable sensor identity using `physical_device_uuid` + diff --git a/src/views/dashboards/farm/sensor7/Sensor7Page.tsx b/src/views/dashboards/farm/sensor7/Sensor7Page.tsx index 27c7614..dd74c1f 100644 --- a/src/views/dashboards/farm/sensor7/Sensor7Page.tsx +++ b/src/views/dashboards/farm/sensor7/Sensor7Page.tsx @@ -1,14 +1,12 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; -import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; import Chip from "@mui/material/Chip"; -import CircularProgress from "@mui/material/CircularProgress"; import Divider from "@mui/material/Divider"; import FormControl from "@mui/material/FormControl"; import Grid from "@mui/material/Grid2"; @@ -30,17 +28,19 @@ import TableRow from "@mui/material/TableRow"; import Typography from "@mui/material/Typography"; import { alpha, useTheme } from "@mui/material/styles"; -import { useFarmHub } from "@/hooks/useFarmHub"; -import type { ApiError } from "@/libs/api/client"; import { - sensorExternalApiService, type SensorExternalCatalog, type SensorExternalFarmSensor, type SensorExternalRequestLog, } from "@/libs/api/services/sensorExternalApiService"; +import SensorComparisonChart from "@/views/dashboards/farm/SensorComparisonChart"; +import SensorRadarChart from "@/views/dashboards/farm/SensorRadarChart"; +import SensorValuesList from "@/views/dashboards/farm/SensorValuesList"; +import SensorHealthPanel from "@/views/dashboards/farm/shared-sensors/SensorHealthPanel"; const PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; const DEFAULT_PAGE_SIZE = 20; +const MOCK_FARM_UUID = "11111111-1111-1111-1111-111111111111"; type StatusColor = "success" | "warning" | "error" | "info"; type AccentTone = "primary" | "success" | "warning" | "info"; @@ -95,36 +95,181 @@ const formatPayloadPreview = (payload?: Record | null): string return entries.length > 3 ? `${preview} | +${entries.length - 3}` : preview; }; -const formatJson = (value: unknown): string => { - try { - return JSON.stringify(value ?? {}, null, 2); - } catch { - return "{}"; - } +const normalizeMetricLabel = (key: string): string => + key + .replace(/_/g, " ") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .trim(); + +const extractNumericPayloadEntries = ( + payload?: Record | null, +): Array<[string, number]> => { + if (!payload || typeof payload !== "object") return []; + + return Object.entries(payload).filter( + (entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1]), + ); }; -const resolveErrorMessage = ( - error: unknown, - fallback: string, -): string => { - const apiError = error as - | (ApiError & { details?: { detail?: unknown; msg?: unknown } }) - | undefined; +const buildMockSeries = (baseValue: number): number[] => + [0.92, 0.98, 0.94, 1.03, 0.97, 1.05, 1] + .map((multiplier) => Number((baseValue * multiplier).toFixed(1))); - if (typeof apiError?.details?.detail === "string") { - return apiError.details.detail; - } +const createMockCatalog = ( + uuid: string, + code: string, + name: string, + returnedFields: string[], +): SensorExternalCatalog => ({ + uuid, + code, + name, + description: `کاتالوگ نمونه برای ${name}`, + customizable_fields: [], + supported_power_sources: ["solar", "battery"], + returned_data_fields: returnedFields, + sample_payload: null, + is_active: true, + created_at: "2025-01-01T08:00:00Z", + updated_at: "2025-01-10T08:00:00Z", +}); - if (typeof apiError?.details?.msg === "string") { - return apiError.details.msg; - } +const createMockSensor = ( + uuid: string, + physicalDeviceUuid: string, + name: string, + sensorType: string, + catalogUuid: string, +): SensorExternalFarmSensor => ({ + uuid, + sensor_catalog_uuid: catalogUuid, + physical_device_uuid: physicalDeviceUuid, + name, + sensor_type: sensorType, + is_active: true, + specifications: { protection: "IP68", install_depth_cm: 30 }, + power_source: { primary: "solar", backup: "battery" }, + created_at: "2025-01-01T08:00:00Z", + updated_at: "2025-01-10T08:00:00Z", +}); - if (typeof apiError?.message === "string" && apiError.message.trim()) { - return apiError.message; - } +const sensor7Catalog = createMockCatalog( + "catalog-7in1-01", + "SOIL-7IN1", + "کاتالوگ سنسور خاک 7 در 1", + ["moisture", "temperature", "humidity", "ph", "ec", "nitrogen", "potassium"], +); + +const weatherCatalog = createMockCatalog( + "catalog-weather-01", + "WEATHER-PRO", + "کاتالوگ سنسور اقلیم", + ["air_temperature", "air_humidity", "wind_speed", "light"], +); + +const sensor7FarmSensor = createMockSensor( + "farm-sensor-7in1-01", + "device-7in1-0001", + "سنسور خاک 7 در 1 - بلوک شمالی", + "Soil 7 in 1", + sensor7Catalog.uuid, +); + +const weatherFarmSensor = createMockSensor( + "farm-sensor-weather-01", + "device-weather-0002", + "سنسور اقلیم - گلخانه 1", + "Climate Station", + weatherCatalog.uuid, +); + +const MOCK_LOGS: SensorExternalRequestLog[] = [ + { + id: 108, + farm_uuid: MOCK_FARM_UUID, + sensor_catalog_uuid: sensor7Catalog.uuid, + physical_device_uuid: sensor7FarmSensor.physical_device_uuid, + farm_sensor: sensor7FarmSensor, + sensor_catalog: sensor7Catalog, + payload: { + moisture: 61, + temperature: 27.4, + humidity: 58, + ph: 6.7, + ec: 1.45, + nitrogen: 44, + potassium: 39, + }, + created_at: "2025-05-02T11:20:00Z", + }, + { + id: 107, + farm_uuid: MOCK_FARM_UUID, + sensor_catalog_uuid: sensor7Catalog.uuid, + physical_device_uuid: sensor7FarmSensor.physical_device_uuid, + farm_sensor: sensor7FarmSensor, + sensor_catalog: sensor7Catalog, + payload: { + moisture: 58, + temperature: 26.9, + humidity: 56, + ph: 6.6, + ec: 1.38, + nitrogen: 42, + potassium: 37, + }, + created_at: "2025-05-02T08:15:00Z", + }, + { + id: 106, + farm_uuid: MOCK_FARM_UUID, + sensor_catalog_uuid: sensor7Catalog.uuid, + physical_device_uuid: sensor7FarmSensor.physical_device_uuid, + farm_sensor: sensor7FarmSensor, + sensor_catalog: sensor7Catalog, + payload: { + moisture: 54, + temperature: 28.1, + humidity: 54, + ph: 6.8, + ec: 1.41, + nitrogen: 40, + potassium: 35, + }, + created_at: "2025-05-01T17:40:00Z", + }, + { + id: 105, + farm_uuid: MOCK_FARM_UUID, + sensor_catalog_uuid: weatherCatalog.uuid, + physical_device_uuid: weatherFarmSensor.physical_device_uuid, + farm_sensor: weatherFarmSensor, + sensor_catalog: weatherCatalog, + payload: { + air_temperature: 31.2, + air_humidity: 47, + wind_speed: 12.4, + light: 840, + }, + created_at: "2025-05-02T10:00:00Z", + }, + { + id: 104, + farm_uuid: MOCK_FARM_UUID, + sensor_catalog_uuid: weatherCatalog.uuid, + physical_device_uuid: weatherFarmSensor.physical_device_uuid, + farm_sensor: weatherFarmSensor, + sensor_catalog: weatherCatalog, + payload: { + air_temperature: 29.8, + air_humidity: 50, + wind_speed: 10.2, + light: 790, + }, + created_at: "2025-05-02T07:30:00Z", + }, +]; - return fallback; -}; const getLogStatus = ( log: SensorExternalRequestLog, @@ -232,95 +377,6 @@ const InsightMetricCard = ({ ); }; -const DetailField = ({ - label, - value, - mono = false, -}: { - label: string; - value: string; - mono?: boolean; -}) => ( - `1px solid ${theme.palette.divider}`, - backgroundColor: (theme) => alpha(theme.palette.action.hover, 0.3), - }} - > - - {label} - - - {value} - - -); - -const EntityCard = ({ - title, - icon, - emptyLabel, - lines, -}: { - title: string; - icon: string; - emptyLabel: string; - lines: Array<{ label: string; value: string }>; -}) => { - const theme = useTheme(); - - return ( - - - - - - - {title} - - - {lines.length ? ( - - {lines.map((line) => ( - - ))} - - ) : ( - {emptyLabel} - )} - - - ); -}; - const LoadingState = () => ( @@ -332,116 +388,111 @@ const LoadingState = () => ( const Sensor7Page = () => { const theme = useTheme(); - const { farmHub } = useFarmHub(); - const farmUuid = farmHub?.farm_uuid ?? null; - const [logs, setLogs] = useState([]); - const [count, setCount] = useState(0); + const [count, setCount] = useState(MOCK_LOGS.length); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [loading, setLoading] = useState(false); - const [hasLoadedOnce, setHasLoadedOnce] = useState(false); + const [hasLoadedOnce, setHasLoadedOnce] = useState(true); const [errorMessage, setErrorMessage] = useState(null); const [selectedLogId, setSelectedLogId] = useState(null); - const [lastUpdatedAt, setLastUpdatedAt] = useState(null); - const requestIdRef = useRef(0); + const [selectedDeviceId, setSelectedDeviceId] = useState(""); + + const paginatedMockLogs = useMemo(() => { + const start = (page - 1) * pageSize; + return MOCK_LOGS.slice(start, start + pageSize); + }, [page, pageSize]); const totalPages = useMemo( () => Math.max(1, Math.ceil(count / pageSize)), [count, pageSize], ); - const selectedLog = useMemo( - () => logs.find((item) => item.id === selectedLogId) ?? logs[0] ?? null, - [logs, selectedLogId], + const sensorOptions = useMemo(() => { + const options = new Map< + string, + { + value: string; + sensorName: string; + sensorType: string; + } + >(); + + logs.forEach((log) => { + if (options.has(log.physical_device_uuid)) return; + + options.set(log.physical_device_uuid, { + value: log.physical_device_uuid, + sensorName: log.farm_sensor?.name || "سنسور بدون نام", + sensorType: log.farm_sensor?.sensor_type || "نوع نامشخص", + }); + }); + + return Array.from(options.values()); + }, [logs]); + + const activeDeviceId = useMemo(() => { + if (selectedDeviceId && sensorOptions.some((option) => option.value === selectedDeviceId)) { + return selectedDeviceId; + } + + return sensorOptions[0]?.value ?? ""; + }, [selectedDeviceId, sensorOptions]); + + const filteredLogs = useMemo( + () => logs.filter((item) => item.physical_device_uuid === activeDeviceId), + [activeDeviceId, logs], ); - const uniqueDevicesCount = useMemo( - () => new Set(logs.map((item) => item.physical_device_uuid)).size, - [logs], + const selectedLog = useMemo( + () => filteredLogs.find((item) => item.id === selectedLogId) ?? filteredLogs[0] ?? null, + [filteredLogs, selectedLogId], ); const mappedLogsCount = useMemo( - () => logs.filter((item) => Boolean(item.farm_sensor)).length, - [logs], + () => filteredLogs.filter((item) => Boolean(item.farm_sensor)).length, + [filteredLogs], ); const catalogedLogsCount = useMemo( - () => logs.filter((item) => Boolean(item.sensor_catalog)).length, - [logs], - ); - - const loadLogs = useCallback( - async (targetPage = page, targetPageSize = pageSize) => { - if (!farmUuid) { - setLogs([]); - setCount(0); - setSelectedLogId(null); - setErrorMessage(null); - setLoading(false); - setHasLoadedOnce(false); - return; - } - - const currentRequestId = ++requestIdRef.current; - setLoading(true); - setErrorMessage(null); - - try { - const response = await sensorExternalApiService.listRequestLogs({ - farmUuid, - page: targetPage, - pageSize: targetPageSize, - }); - - if (currentRequestId !== requestIdRef.current) return; - - const nextLogs = Array.isArray(response.data) ? response.data : []; - - setLogs(nextLogs); - setCount(typeof response.count === "number" ? response.count : 0); - setSelectedLogId((current) => - nextLogs.some((item) => item.id === current) - ? current - : (nextLogs[0]?.id ?? null), - ); - setHasLoadedOnce(true); - setLastUpdatedAt(new Date().toISOString()); - } catch (requestError) { - if (currentRequestId !== requestIdRef.current) return; - - setLogs([]); - setCount(0); - setSelectedLogId(null); - setErrorMessage( - resolveErrorMessage( - requestError, - "دریافت لاگ سنسورهای خارجی با خطا مواجه شد.", - ), - ); - setHasLoadedOnce(true); - } finally { - if (currentRequestId === requestIdRef.current) { - setLoading(false); - } - } - }, - [farmUuid, page, pageSize], + () => filteredLogs.filter((item) => Boolean(item.sensor_catalog)).length, + [filteredLogs], ); useEffect(() => { - setPage(1); - setSelectedLogId(null); - setLogs([]); - setCount(0); + setLoading(true); setErrorMessage(null); - setHasLoadedOnce(false); - }, [farmUuid]); + + const timeoutId = window.setTimeout(() => { + setLogs(paginatedMockLogs); + setCount(MOCK_LOGS.length); + setSelectedDeviceId((current) => + paginatedMockLogs.some((item) => item.physical_device_uuid === current) + ? current + : (paginatedMockLogs[0]?.physical_device_uuid ?? ""), + ); + setSelectedLogId((current) => + paginatedMockLogs.some((item) => item.id === current) + ? current + : (paginatedMockLogs[0]?.id ?? null), + ); + setHasLoadedOnce(true); + setLoading(false); + }, 250); + + return () => window.clearTimeout(timeoutId); + }, [paginatedMockLogs]); useEffect(() => { - if (!farmUuid) return; - void loadLogs(page, pageSize); - }, [farmUuid, loadLogs, page, pageSize]); + if (!filteredLogs.length) { + setSelectedLogId(null); + return; + } + + setSelectedLogId((current) => + filteredLogs.some((item) => item.id === current) ? current : filteredLogs[0].id, + ); + }, [filteredLogs]); const handlePageSizeChange = (event: SelectChangeEvent) => { const nextPageSize = Number(event.target.value); @@ -449,219 +500,145 @@ const Sensor7Page = () => { setPage(1); }; - const heroBorder = alpha(theme.palette.primary.main, 0.18); - const heroGlow = alpha(theme.palette.primary.main, 0.2); - - if (!farmUuid) { - return ( - - - مانیتورینگ سنسور خارجی - - برای مشاهده لاگ ها ابتدا یک مزرعه فعال انتخاب کنید. - - - - ); - } + const selectedSensorOption = + sensorOptions.find((option) => option.value === activeDeviceId) ?? null; const metrics = [ { - icon: "tabler-database", - label: "کل لاگ های مزرعه", - value: formatPersianNumber(count), + icon: "tabler-sensor", + label: "سنسور انتخاب شده", + value: selectedSensorOption?.sensorName || "-", helper: - totalPages > 1 - ? `صفحه ${formatPersianNumber(page)} از ${formatPersianNumber(totalPages)}` - : "تمام رخدادهای ثبت شده برای مزرعه", + selectedSensorOption?.sensorType || "پس از دریافت داده نمایش داده می شود", tone: "primary" as AccentTone, }, { icon: "tabler-stack-2", - label: "رکوردهای همین صفحه", - value: formatPersianNumber(logs.length), - helper: `${formatPersianNumber(pageSize)} رکورد در هر صفحه`, + label: "رکوردهای این سنسور", + value: formatPersianNumber(filteredLogs.length), + helper: `از ${formatPersianNumber(logs.length)} رکورد همین صفحه`, tone: "info" as AccentTone, }, { - icon: "tabler-devices", - label: "دستگاه های یکتا", - value: formatPersianNumber(uniqueDevicesCount), - helper: "بر پایه physical_device_uuid در همین صفحه", + icon: "tabler-device-analytics", + label: "آخرین دریافت", + value: selectedLog ? formatDateTime(selectedLog.created_at) : "-", + helper: "جدیدترین لاگ همین سنسور در صفحه جاری", tone: "warning" as AccentTone, }, { icon: "tabler-link", - label: "لاگ های تطبیق یافته", - value: formatPersianNumber(mappedLogsCount), - helper: `${formatPersianNumber(catalogedLogsCount)} مورد با کاتالوگ کامل`, + label: "پوشش اطلاعات سنسور", + value: `${formatPersianNumber(mappedLogsCount)} / ${formatPersianNumber(filteredLogs.length)}`, + helper: `${formatPersianNumber(mappedLogsCount)} مورد با سنسور مزرعه، ${formatPersianNumber(catalogedLogsCount)} مورد با کاتالوگ`, tone: "success" as AccentTone, }, ]; - const selectedStatus = selectedLog ? getLogStatus(selectedLog) : null; const selectedFarmSensor = selectedLog?.farm_sensor ?? null; - const selectedCatalog = selectedLog?.sensor_catalog ?? null; - const sensorLines: Array<{ label: string; value: string }> = selectedFarmSensor - ? [ - { label: "نام سنسور", value: selectedFarmSensor.name || "-" }, - { label: "نوع", value: selectedFarmSensor.sensor_type || "-" }, - { - label: "وضعیت", - value: selectedFarmSensor.is_active ? "فعال" : "غیرفعال", - }, - { - label: "دستگاه فیزیکی", - value: selectedFarmSensor.physical_device_uuid || "-", - }, - ] - : []; + const comparisonChartData = useMemo(() => { + const numericEntries = extractNumericPayloadEntries(selectedLog?.payload); + const primaryEntry = numericEntries[0] ?? ["moisture", 48]; + const secondaryEntry = numericEntries[1] ?? ["temperature", 32]; + const currentValue = Number(primaryEntry[1].toFixed(0)); + const previousAverage = + filteredLogs.length > 1 + ? filteredLogs + .slice(1, 4) + .map((log) => { + const value = log.payload?.[primaryEntry[0]]; + return typeof value === "number" ? value : null; + }) + .filter((value): value is number => value !== null) + : []; + const baseline = + previousAverage.length > 0 + ? previousAverage.reduce((sum, value) => sum + value, 0) / previousAverage.length + : currentValue * 0.93; + const deltaPercent = baseline ? (((currentValue - baseline) / baseline) * 100).toFixed(1) : "0.0"; - const catalogLines: Array<{ label: string; value: string }> = selectedCatalog - ? [ - { label: "نام", value: selectedCatalog.name || "-" }, - { label: "کد", value: selectedCatalog.code || "-" }, + return { + currentValue, + vsLastWeek: `${Number(deltaPercent) >= 0 ? "+" : ""}${deltaPercent}%`, + categories: ["شنبه", "یکشنبه", "دوشنبه", "سه شنبه", "چهارشنبه", "پنج شنبه", "جمعه"], + series: [ { - label: "فیلدهای خروجی", - value: Array.isArray(selectedCatalog.returned_data_fields) - ? selectedCatalog.returned_data_fields.join("، ") || "-" - : "-", + name: normalizeMetricLabel(primaryEntry[0]), + data: buildMockSeries(primaryEntry[1]), }, { - label: "وضعیت", - value: selectedCatalog.is_active ? "فعال" : "غیرفعال", + name: normalizeMetricLabel(secondaryEntry[0]), + data: buildMockSeries(secondaryEntry[1]), }, - ] - : []; + ], + }; + }, [filteredLogs, selectedLog?.payload]); + + const sensorValuesListData = useMemo(() => { + const numericEntries = extractNumericPayloadEntries(selectedLog?.payload); + const fallbackEntries: Array<[string, number]> = [ + ["moisture", 61], + ["temperature", 27], + ["ph", 6.8], + ["ec", 1.4], + ]; + const entries = (numericEntries.length ? numericEntries : fallbackEntries).slice(0, 5); + + return { + sensors: entries.map(([key, value], index) => { + const previousValue = + filteredLogs[index + 1] && typeof filteredLogs[index + 1].payload?.[key] === "number" + ? Number(filteredLogs[index + 1].payload?.[key]) + : value * (0.9 + index * 0.03); + const trendNumber = Number((((value - previousValue) / (previousValue || 1)) * 100).toFixed(1)); + + return { + title: normalizeMetricLabel(key), + subtitle: `مقدار فعلی: ${value.toLocaleString("fa-IR", { maximumFractionDigits: 2 })}`, + trendNumber, + trend: trendNumber < 0 ? "negative" : "positive", + unit: "", + }; + }), + }; + }, [filteredLogs, selectedLog?.payload]); + + const radarChartData = useMemo(() => { + const numericEntries = extractNumericPayloadEntries(selectedLog?.payload); + const entries = (numericEntries.length + ? numericEntries + : [ + ["moisture", 61], + ["temperature", 27], + ["humidity", 58], + ["ph", 68], + ["ec", 42], + ]).slice(0, 6); + + return { + labels: entries.map(([key]) => normalizeMetricLabel(key)), + series: [ + { + name: "وضعیت فعلی", + data: entries.map(([, value]) => Number(value.toFixed(1))), + }, + { + name: "بازه ایده آل", + data: entries.map(([, value], index) => + Number((value * (index % 2 === 0 ? 0.92 : 1.06)).toFixed(1)), + ), + }, + ], + }; + }, [selectedLog?.payload]); return ( - - - - - - - - - - - - مرکز مانیتورینگ لاگ سنسورهای خارجی - - - این صفحه تاریخچه درخواست های ورودی سنسورهای خارجی مزرعه انتخاب شده را - به صورت زنده، صفحه بندی شده و همراه با اطلاعات تکمیلی سنسور و کاتالوگ - نمایش می دهد. - - - - - - - - - آخرین همگام سازی - - - {lastUpdatedAt ? formatDateTime(lastUpdatedAt) : "هنوز دریافت نشده"} - - - لاگ ها به صورت نزولی و از جدیدترین به قدیمی ترین نمایش داده می شوند. - - - - - - + + این صفحه فعلاً با داده های mock نمایش داده می شود تا طراحی و توسعه رابط کاربری سریع تر + انجام شود. + {metrics.map((metric) => ( @@ -671,8 +648,25 @@ const Sensor7Page = () => { ))} + + + + + + + + + + + + + + - + { > - جریان لاگ ها + محتوای سنسور انتخاب شده - روی هر ردیف کلیک کنید تا payload و جزئیات سنسور همان لاگ در پنل سمت - راست باز شود. + این صفحه فقط داده های مربوط به یک سنسور را نشان می دهد؛ سنسور را از + لیست انتخاب کنید و روی هر ردیف بزنید تا جزئیات همان لاگ باز شود. @@ -706,6 +700,22 @@ const Sensor7Page = () => { spacing={2} alignItems={{ xs: "stretch", sm: "center" }} > + + سنسور + + labelId="sensor7-device-label" + value={activeDeviceId} + label="سنسور" + onChange={(event) => setSelectedDeviceId(event.target.value)} + > + {sensorOptions.map((option) => ( + + {option.sensorName} - {option.sensorType} + + ))} + + + اندازه صفحه @@ -731,14 +741,12 @@ const Sensor7Page = () => { }} > - بازه فعلی + تعداد لاگ های مزرعه - {count - ? `${formatPersianNumber((page - 1) * pageSize + 1)} تا ${formatPersianNumber( - Math.min(page * pageSize, count), - )}` - : "۰ تا ۰"} + {totalPages > 1 + ? `${formatPersianNumber(page)} / ${formatPersianNumber(totalPages)} صفحه` + : `${formatPersianNumber(count)} رکورد`} @@ -754,6 +762,10 @@ const Sensor7Page = () => { هنوز لاگی برای این مزرعه ثبت نشده است یا در این صفحه داده ای وجود ندارد. + ) : filteredLogs.length === 0 ? ( + + برای سنسور انتخاب شده در این صفحه لاگی پیدا نشد. + ) : ( <> @@ -769,7 +781,7 @@ const Sensor7Page = () => { - {logs.map((log) => { + {filteredLogs.map((log) => { const status = getLogStatus(log); const isSelected = selectedLog?.id === log.id; @@ -868,157 +880,6 @@ const Sensor7Page = () => { - - - - - - - - - جزئیات لاگ انتخاب شده - - - اطلاعات enrich شده سنسور و کاتالوگ از همین پنل قابل بررسی است. - - - {selectedStatus ? ( - - ) : null} - - - {selectedLog ? ( - <> - - {selectedStatus?.description} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - payload دریافتی - - - - {formatJson(selectedLog.payload)} - - - - ) : ( - - یک لاگ از جدول سمت چپ انتخاب کنید تا جزئیات آن در این بخش نمایش داده شود. - - )} - - - - - - راهنمای سریع endpoint - - این ویو با `farm_uuid` فیلتر می شود، فقط `GET` را می پذیرد و صفحه بندی - آن با `page` و `page_size` کنترل می شود. - - - - - - - - - - - - اگر backend هنوز migrate نشده باشد، پاسخ `503` با پیام آماده نبودن جدول ها - برمی گردد. - - - - - ); diff --git a/src/views/dashboards/farm/shared-sensors/SensorHealthPanel.tsx b/src/views/dashboards/farm/shared-sensors/SensorHealthPanel.tsx new file mode 100644 index 0000000..5ae242e --- /dev/null +++ b/src/views/dashboards/farm/shared-sensors/SensorHealthPanel.tsx @@ -0,0 +1,363 @@ +"use client"; + +import { useMemo } from "react"; + +import Alert from "@mui/material/Alert"; +import Box from "@mui/material/Box"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Chip from "@mui/material/Chip"; +import Grid from "@mui/material/Grid2"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { alpha, useTheme } from "@mui/material/styles"; + +type StatusColor = "success" | "warning" | "error" | "info"; +type AccentTone = "primary" | "success" | "warning" | "info"; +type HealthTone = "good" | "medium" | "critical" | "unknown"; + +export interface SensorHealthPanelProps { + payload?: Record | null; + sensorName?: string | null; + title?: string; + description?: string; + emptyMessage?: string; + maxItems?: number; +} + +const formatPersianNumber = (value: number): string => + value.toLocaleString("fa-IR"); + +const formatScalar = (value: unknown): string => { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + if (Array.isArray(value)) return `[${value.length} item]`; + if (typeof value === "object") return "{...}"; + return String(value); +}; + +const normalizeFieldLabel = (key: string): string => + key + .replace(/_/g, " ") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .trim(); + +const formatFieldValue = (value: unknown): string => { + if (typeof value === "number") { + return Number.isInteger(value) + ? formatPersianNumber(value) + : value.toLocaleString("fa-IR", { maximumFractionDigits: 2 }); + } + + if (typeof value === "boolean") { + return value ? "فعال" : "غیرفعال"; + } + + return formatScalar(value); +}; + +const getHealthTone = (key: string, value: unknown): HealthTone => { + const normalizedKey = key.toLowerCase(); + + if (typeof value === "boolean") { + return value ? "good" : "critical"; + } + + if (typeof value !== "number" || Number.isNaN(value)) { + return "unknown"; + } + + if ( + normalizedKey.includes("temperature") || + normalizedKey.includes("temp") || + normalizedKey.includes("soil_temp") + ) { + if (value >= 16 && value <= 30) return "good"; + if ((value >= 10 && value < 16) || (value > 30 && value <= 36)) return "medium"; + return "critical"; + } + + if ( + normalizedKey.includes("humidity") || + normalizedKey.includes("moisture") || + normalizedKey.includes("water") + ) { + if (value >= 35 && value <= 75) return "good"; + if ((value >= 20 && value < 35) || (value > 75 && value <= 90)) return "medium"; + return "critical"; + } + + if (normalizedKey.includes("ph")) { + if (value >= 5.8 && value <= 7.2) return "good"; + if ((value >= 5.2 && value < 5.8) || (value > 7.2 && value <= 7.8)) return "medium"; + return "critical"; + } + + if (normalizedKey.includes("ec") || normalizedKey.includes("salinity")) { + if (value >= 0.8 && value <= 2.2) return "good"; + if ((value >= 0.4 && value < 0.8) || (value > 2.2 && value <= 3.2)) return "medium"; + return "critical"; + } + + if ( + normalizedKey.includes("nitrogen") || + normalizedKey === "n" || + normalizedKey.includes("phosphorus") || + normalizedKey === "p" || + normalizedKey.includes("potassium") || + normalizedKey === "k" + ) { + if (value >= 20 && value <= 80) return "good"; + if ((value >= 10 && value < 20) || (value > 80 && value <= 100)) return "medium"; + return "critical"; + } + + if (value >= 0 && value <= 100) { + if (value >= 35 && value <= 75) return "good"; + if ((value >= 20 && value < 35) || (value > 75 && value <= 90)) return "medium"; + return "critical"; + } + + return "unknown"; +}; + +const getHealthToneMeta = ( + tone: HealthTone, +): { label: string; muiColor: StatusColor; paletteKey: AccentTone } => { + switch (tone) { + case "good": + return { label: "سالم", muiColor: "success", paletteKey: "success" }; + case "medium": + return { label: "قابل بررسی", muiColor: "warning", paletteKey: "warning" }; + case "critical": + return { label: "نیازمند رسیدگی", muiColor: "error", paletteKey: "warning" }; + default: + return { label: "نامشخص", muiColor: "info", paletteKey: "info" }; + } +}; + +const SensorHealthPanel = ({ + payload, + sensorName, + title = "سلامت بخش های سنسور", + description = "این بخش یک نمای تصویری از وضعیت قسمت های مختلف سنسور و داده های آخرین payload را نشان می دهد.", + emptyMessage = "هنوز داده کافی برای ساخت نمای سلامت سنسور در آخرین payload وجود ندارد.", + maxItems = 6, +}: SensorHealthPanelProps) => { + const theme = useTheme(); + + const healthItems = useMemo(() => { + if (!payload || typeof payload !== "object") return []; + + return Object.entries(payload) + .filter(([, value]) => + ["number", "string", "boolean"].includes(typeof value) || value === null, + ) + .slice(0, maxItems) + .map(([key, value]) => { + const tone = getHealthTone(key, value); + + return { + key, + label: normalizeFieldLabel(key), + value: formatFieldValue(value), + tone, + meta: getHealthToneMeta(tone), + }; + }); + }, [maxItems, payload]); + + const goodCount = healthItems.filter((item) => item.tone === "good").length; + const warningCount = healthItems.filter((item) => item.tone === "medium").length; + const criticalCount = healthItems.filter((item) => item.tone === "critical").length; + + return ( + + + + + + + + {[0, 1, 2].map((layer) => ( + + ))} + + {healthItems.slice(0, 4).map((item, index) => { + const palette = theme.palette[item.meta.paletteKey]; + + return ( + + + {item.label} + + + {item.value} + + + ); + })} + + + + + + + + + + + + + + {title} + + + {description} + + + + + + سنسور فعال + + + {sensorName || "سنسور انتخاب نشده"} + + + + {healthItems.length ? ( + + {healthItems.map((item) => ( + + + + + {item.label} + + + + {item.value} + + + ))} + + ) : ( + {emptyMessage} + )} + + + + + + ); +}; + +export default SensorHealthPanel;