From 6046bc3c28dcdf5be40a73b4057fafa039b6df55 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Wed, 8 Apr 2026 23:01:07 +0330 Subject: [PATCH] UPDATE --- .../{sensor-7 => solid-sensor}/page.tsx | 4 +- .../layout/vertical/VerticalMenu.tsx | 9 +- src/libs/api/services/cropZoningService.ts | 197 ++-- .../api/services/sensorExternalApiService.ts | 72 ++ .../farm/cropZoning/CropZoningWrapper.tsx | 83 +- .../dashboards/farm/sensor7/Sensor7Page.tsx | 1048 ++++++++++++++++- 6 files changed, 1273 insertions(+), 140 deletions(-) rename src/app/(dashboard)/(private)/{sensor-7 => solid-sensor}/page.tsx (62%) create mode 100644 src/libs/api/services/sensorExternalApiService.ts diff --git a/src/app/(dashboard)/(private)/sensor-7/page.tsx b/src/app/(dashboard)/(private)/solid-sensor/page.tsx similarity index 62% rename from src/app/(dashboard)/(private)/sensor-7/page.tsx rename to src/app/(dashboard)/(private)/solid-sensor/page.tsx index 8ffd30e..eb8f78e 100644 --- a/src/app/(dashboard)/(private)/sensor-7/page.tsx +++ b/src/app/(dashboard)/(private)/solid-sensor/page.tsx @@ -1,7 +1,7 @@ import Sensor7Page from "@/views/dashboards/farm/sensor7/Sensor7Page"; -const Sensor7 = async () => { +const SolidSensor = async () => { return ; }; -export default Sensor7; +export default SolidSensor; diff --git a/src/components/layout/vertical/VerticalMenu.tsx b/src/components/layout/vertical/VerticalMenu.tsx index 4cd06f2..d15db3e 100644 --- a/src/components/layout/vertical/VerticalMenu.tsx +++ b/src/components/layout/vertical/VerticalMenu.tsx @@ -111,12 +111,17 @@ const VerticalMenu = ({ scrollMenu }: Props) => { }> {t('cropZoning')} + {canShowSensor7Menu && ( - }> + + + + }> Sensor 7 - )} + )} + }> {t('plantSimulator')} diff --git a/src/libs/api/services/cropZoningService.ts b/src/libs/api/services/cropZoningService.ts index 262f136..7753e77 100644 --- a/src/libs/api/services/cropZoningService.ts +++ b/src/libs/api/services/cropZoningService.ts @@ -16,6 +16,11 @@ const PREFIX = "/api/crop-zoning"; const AREA_CACHE_KEY_PREFIX = "crop-zoning:area"; const AREA_CACHE_VERSION = "v1"; const AREA_CACHE_TTL_MS = 1000 * 60 * 60 * 6; +type CropZoningLayerEndpoint = + | "area" + | "water-need" + | "soil-quality" + | "cultivation-risk"; export interface Product { id: string; @@ -217,6 +222,7 @@ function getAreaCacheUserKey(): string { } function getAreaCacheKey( + endpoint: CropZoningLayerEndpoint, farmUuid: string, page: number, pageSize: number, @@ -225,6 +231,7 @@ function getAreaCacheKey( AREA_CACHE_KEY_PREFIX, AREA_CACHE_VERSION, getAreaCacheUserKey(), + endpoint, farmUuid, page, pageSize, @@ -232,6 +239,7 @@ function getAreaCacheKey( } function readCachedArea( + endpoint: CropZoningLayerEndpoint, farmUuid: string, page: number, pageSize: number, @@ -239,7 +247,9 @@ function readCachedArea( if (typeof window === "undefined") return null; try { - const raw = localStorage.getItem(getAreaCacheKey(farmUuid, page, pageSize)); + const raw = localStorage.getItem( + getAreaCacheKey(endpoint, farmUuid, page, pageSize), + ); if (!raw) { return null; @@ -248,7 +258,7 @@ function readCachedArea( const parsed = JSON.parse(raw) as CachedAreaEntry; if (!parsed?.expiresAt || parsed.expiresAt < Date.now()) { - localStorage.removeItem(getAreaCacheKey(farmUuid, page, pageSize)); + localStorage.removeItem(getAreaCacheKey(endpoint, farmUuid, page, pageSize)); return null; } @@ -259,6 +269,7 @@ function readCachedArea( } function writeCachedArea( + endpoint: CropZoningLayerEndpoint, farmUuid: string, page: number, pageSize: number, @@ -273,7 +284,7 @@ function writeCachedArea( }; localStorage.setItem( - getAreaCacheKey(farmUuid, page, pageSize), + getAreaCacheKey(endpoint, farmUuid, page, pageSize), JSON.stringify(payload), ); } catch { @@ -290,6 +301,97 @@ function logAreaRequest( console.log(`[crop-zoning][area][${phase}]`, payload); } +function getLayerEndpointUrl( + endpoint: CropZoningLayerEndpoint, + params: URLSearchParams, +): string { + return `${PREFIX}/${endpoint}/?${params.toString()}`; +} + +function getLayerArea( + endpoint: CropZoningLayerEndpoint, + farmUuid: string, + options?: { page?: number; pageSize?: number; useCache?: boolean }, +): Promise { + const page = options?.page ?? 1; + const pageSize = options?.pageSize ?? 10; + const useCache = options?.useCache ?? true; + + if (useCache) { + const cached = readCachedArea(endpoint, farmUuid, page, pageSize); + + if (cached) { + logAreaRequest("cache-hit", { + endpoint, + farmUuid, + page, + pageSize, + pagination: cached.pagination ?? null, + taskStatus: cached.task?.status ?? null, + zonesCount: cached.zones?.length ?? 0, + }); + return Promise.resolve(cached); + } + } + + const params = new URLSearchParams({ farm_uuid: farmUuid }); + + params.set("page", String(page)); + params.set("page_size", String(pageSize)); + + const requestUrl = getLayerEndpointUrl(endpoint, params); + + logAreaRequest("request", { + endpoint, + farmUuid, + page, + pageSize, + endpointUrl: requestUrl, + }); + + return unwrap( + apiClient.get>(requestUrl), + ).then((response) => { + if ("task_id" in response) { + logAreaRequest("response", { + endpoint, + farmUuid, + page, + pageSize, + taskId: response.task_id, + status: response.status, + }); + return normalizeTaskInitResponse(response); + } + + const normalized = normalizeAreaResult(response); + const taskStatus = normalized.task?.status?.toLowerCase(); + + logAreaRequest("response", { + endpoint, + farmUuid, + page, + pageSize, + taskStatus: normalized.task?.status ?? null, + pagination: normalized.pagination ?? null, + zonesCount: normalized.zones?.length ?? 0, + hasArea: Boolean(normalized.area), + }); + + if ( + normalized.area && + taskStatus !== "pending" && + taskStatus !== "processing" && + taskStatus !== "failure" && + taskStatus !== "failed" + ) { + writeCachedArea(endpoint, farmUuid, page, pageSize, normalized); + } + + return normalized; + }); +} + export const cropZoningService = { getProducts(): Promise<{ products: Product[] }> { return unwrap( @@ -323,79 +425,28 @@ export const cropZoningService = { farmUuid: string, options?: { page?: number; pageSize?: number; useCache?: boolean }, ): Promise { - const page = options?.page ?? 1; - const pageSize = options?.pageSize ?? 10; - const useCache = options?.useCache ?? true; + return getLayerArea("area", farmUuid, options); + }, - if (useCache) { - const cached = readCachedArea(farmUuid, page, pageSize); + getWaterNeedArea( + farmUuid: string, + options?: { page?: number; pageSize?: number; useCache?: boolean }, + ): Promise { + return getLayerArea("water-need", farmUuid, options); + }, - if (cached) { - logAreaRequest("cache-hit", { - farmUuid, - page, - pageSize, - pagination: cached.pagination ?? null, - taskStatus: cached.task?.status ?? null, - zonesCount: cached.zones?.length ?? 0, - }); - return Promise.resolve(cached); - } - } + getSoilQualityArea( + farmUuid: string, + options?: { page?: number; pageSize?: number; useCache?: boolean }, + ): Promise { + return getLayerArea("soil-quality", farmUuid, options); + }, - const params = new URLSearchParams({ farm_uuid: farmUuid }); - - params.set("page", String(page)); - params.set("page_size", String(pageSize)); - - const endpoint = `${PREFIX}/area/?${params.toString()}`; - - logAreaRequest("request", { - farmUuid, - page, - pageSize, - endpoint, - }); - - return unwrap( - apiClient.get>(endpoint), - ).then((response) => { - if ("task_id" in response) { - logAreaRequest("response", { - farmUuid, - page, - pageSize, - taskId: response.task_id, - status: response.status, - }); - return normalizeTaskInitResponse(response); - } - - const normalized = normalizeAreaResult(response); - const taskStatus = normalized.task?.status?.toLowerCase(); - - logAreaRequest("response", { - farmUuid, - page, - pageSize, - taskStatus: normalized.task?.status ?? null, - pagination: normalized.pagination ?? null, - zonesCount: normalized.zones?.length ?? 0, - hasArea: Boolean(normalized.area), - }); - - if ( - normalized.area && - taskStatus !== "pending" && - taskStatus !== "processing" && - taskStatus !== "failure" && - taskStatus !== "failed" - ) { - writeCachedArea(farmUuid, page, pageSize, normalized); - } - - return normalized; - }); + getCultivationRiskArea( + farmUuid: string, + options?: { page?: number; pageSize?: number; useCache?: boolean }, + ): Promise { + return getLayerArea("cultivation-risk", farmUuid, options); }, getAreaStatus( diff --git a/src/libs/api/services/sensorExternalApiService.ts b/src/libs/api/services/sensorExternalApiService.ts new file mode 100644 index 0000000..135727d --- /dev/null +++ b/src/libs/api/services/sensorExternalApiService.ts @@ -0,0 +1,72 @@ +import { apiClient } from "../client"; + +const PREFIX = "/api/sensor-external-api"; + +export interface SensorExternalFarmSensor { + uuid: string; + sensor_catalog_uuid: string | null; + physical_device_uuid: string; + name: string; + sensor_type: string; + is_active: boolean; + specifications?: Record | null; + power_source?: Record | null; + created_at: string; + updated_at: string; +} + +export interface SensorExternalCatalog { + uuid: string; + code: string; + name: string; + description?: string | null; + customizable_fields?: unknown[]; + supported_power_sources?: unknown[]; + returned_data_fields?: string[]; + sample_payload?: Record | null; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface SensorExternalRequestLog { + id: number; + farm_uuid: string; + sensor_catalog_uuid: string | null; + physical_device_uuid: string; + farm_sensor: SensorExternalFarmSensor | null; + sensor_catalog: SensorExternalCatalog | null; + payload: Record | null; + created_at: string; +} + +export interface SensorExternalRequestLogsResponse { + code: number; + msg: string; + count: number; + next: string | null; + previous: string | null; + data: SensorExternalRequestLog[]; +} + +export const sensorExternalApiService = { + listRequestLogs(params: { + farmUuid: string; + page?: number; + pageSize?: number; + }): Promise { + const searchParams = new URLSearchParams({ farm_uuid: params.farmUuid }); + + if (typeof params.page === "number") { + searchParams.set("page", String(params.page)); + } + + if (typeof params.pageSize === "number") { + searchParams.set("page_size", String(params.pageSize)); + } + + return apiClient.get( + `${PREFIX}/logs/?${searchParams.toString()}`, + ); + }, +}; diff --git a/src/views/dashboards/farm/cropZoning/CropZoningWrapper.tsx b/src/views/dashboards/farm/cropZoning/CropZoningWrapper.tsx index 198f273..f4844a8 100644 --- a/src/views/dashboards/farm/cropZoning/CropZoningWrapper.tsx +++ b/src/views/dashboards/farm/cropZoning/CropZoningWrapper.tsx @@ -80,9 +80,25 @@ export default function CropZoningWrapper() { return; } + const layerLabel = t(`layers.${activeLayer}`); + const loadingMessage = `${t("loadingArea")} - ${layerLabel}`; + const getLayerArea = (() => { + switch (activeLayer) { + case "waterNeed": + return cropZoningService.getWaterNeedArea; + case "soilQuality": + return cropZoningService.getSoilQualityArea; + case "cultivationRisk": + return cropZoningService.getCultivationRiskArea; + case "crops": + default: + return cropZoningService.getArea; + } + })(); + setLoading(true); setError(null); - setProgress({ message: t("loadingArea"), percent: 0 }); + setProgress({ message: loadingMessage, percent: 0 }); try { let polls = 0; @@ -110,7 +126,10 @@ export default function CropZoningWrapper() { setZonesData(firstPageZones as ZoneInitialData[]); setProgress({ - message: totalPages > 1 ? `${t("loadingArea")} (1/${totalPages})` : completedTaskMessage || t("loadingArea"), + message: + totalPages > 1 + ? `${loadingMessage} (1/${totalPages})` + : completedTaskMessage || loadingMessage, percent: totalPages > 1 ? 80 : 100, }); @@ -124,7 +143,7 @@ export default function CropZoningWrapper() { totalPages, }); - const pageRes = await cropZoningService.getArea(farmUuid, { + const pageRes = await getLayerArea(farmUuid, { page, pageSize, }); @@ -150,7 +169,7 @@ export default function CropZoningWrapper() { setZonesData(mergedZones); setProgress({ - message: `${t("loadingArea")} (${pagesLoaded}/${totalPages})`, + message: `${loadingMessage} (${pagesLoaded}/${totalPages})`, percent: Math.min(80 + pagesProgress, 100), }); } @@ -158,7 +177,7 @@ export default function CropZoningWrapper() { if (!cancelled) { setZonesData(mergeZones(zonePages)); setProgress({ - message: completedTaskMessage || t("loadingArea"), + message: completedTaskMessage || loadingMessage, percent: 100, }); } @@ -171,7 +190,7 @@ export default function CropZoningWrapper() { pageSize: ZONES_PAGE_SIZE, }); - const res = await cropZoningService.getArea(farmUuid, { + const res = await getLayerArea(farmUuid, { page: 1, pageSize: ZONES_PAGE_SIZE, }); @@ -192,7 +211,7 @@ export default function CropZoningWrapper() { if (task) { setProgress({ - message: task.message || task.stage_label || t("loadingArea"), + message: task.message || task.stage_label || loadingMessage, percent: Math.min(Math.round((task.progress_percent || 0) * 0.8), 80), }); } @@ -200,13 +219,13 @@ export default function CropZoningWrapper() { if (taskStatus === "completed" || taskStatus === "success") { await loadAllZonePages( res, - task?.message || task?.stage_label || t("loadingArea"), + task?.message || task?.stage_label || loadingMessage, ); break; } if ((!task && res.area) || (res.area && taskStatus !== "pending" && taskStatus !== "processing")) { - await loadAllZonePages(res, task?.message || task?.stage_label || t("loadingArea")); + await loadAllZonePages(res, task?.message || task?.stage_label || loadingMessage); break; } @@ -237,20 +256,54 @@ export default function CropZoningWrapper() { loadArea(); return () => { cancelled = true; }; - }, [farmUuid, isClientReady, t]); + }, [activeLayer, farmUuid, isClientReady, t]); const mapZonesData = useMemo(() => { - if (activeLayer === "crops" && zonesData) { - return zonesData.map(z => ({ + if (!zonesData) return null; + + return zonesData.map(z => { + if (activeLayer === "waterNeed") { + return { + zoneId: z.zoneId, + geometry: z.geometry, + color: z.waterNeedLayer?.color || "#94a3b8", + tooltipContent: `
${z.waterNeedLayer?.value || z.waterNeedLayer?.level || "نامشخص"}
`, + cultivable: false, + zoneInitialData: z, + }; + } + + if (activeLayer === "soilQuality") { + return { + zoneId: z.zoneId, + geometry: z.geometry, + color: z.soilQualityLayer?.color || "#94a3b8", + tooltipContent: `
${z.soilQualityLayer?.score ?? z.soilQualityLayer?.level ?? "نامشخص"}
`, + cultivable: false, + zoneInitialData: z, + }; + } + + if (activeLayer === "cultivationRisk") { + return { + zoneId: z.zoneId, + geometry: z.geometry, + color: z.cultivationRiskLayer?.color || "#94a3b8", + tooltipContent: `
${z.cultivationRiskLayer?.level || "نامشخص"}
`, + cultivable: false, + zoneInitialData: z, + }; + } + + return { zoneId: z.zoneId, geometry: z.geometry, color: z.crop ? CROP_COLORS[z.crop as CropType] || "#94a3b8" : "#94a3b8", tooltipContent: `
${z.crop || "نامشخص"}
`, cultivable: !!z.crop, zoneInitialData: z, - })); - } - return null; + }; + }); }, [activeLayer, zonesData]); const handleZoneClick = useCallback((zone: ZoneInitialData) => { diff --git a/src/views/dashboards/farm/sensor7/Sensor7Page.tsx b/src/views/dashboards/farm/sensor7/Sensor7Page.tsx index 5156f82..90a59d0 100644 --- a/src/views/dashboards/farm/sensor7/Sensor7Page.tsx +++ b/src/views/dashboards/farm/sensor7/Sensor7Page.tsx @@ -1,38 +1,466 @@ "use client"; +import { useCallback, useEffect, useMemo, useRef, 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 Typography from "@mui/material/Typography"; +import Divider from "@mui/material/Divider"; +import FormControl from "@mui/material/FormControl"; +import Grid from "@mui/material/Grid2"; +import InputLabel from "@mui/material/InputLabel"; +import LinearProgress from "@mui/material/LinearProgress"; +import MenuItem from "@mui/material/MenuItem"; +import Pagination from "@mui/material/Pagination"; +import Paper from "@mui/material/Paper"; +import Select from "@mui/material/Select"; +import type { SelectChangeEvent } from "@mui/material/Select"; +import Skeleton from "@mui/material/Skeleton"; +import Stack from "@mui/material/Stack"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; -import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; +import { alpha, useTheme } from "@mui/material/styles"; + import { useFarmHub } from "@/hooks/useFarmHub"; import { useFarmAccessProfile } from "@/hooks/useFarmAccessProfile"; +import type { ApiError } from "@/libs/api/client"; import { hasAccessByRule } from "@/libs/api/services/accessControlService"; +import { + sensorExternalApiService, + type SensorExternalCatalog, + type SensorExternalFarmSensor, + type SensorExternalRequestLog, +} from "@/libs/api/services/sensorExternalApiService"; const SENSOR_7_ACCESS_RULE = "sensor-7-page-access"; +const PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; +const DEFAULT_PAGE_SIZE = 20; -const MOCK_SENSOR_ROWS = [ - { id: "S7-001", name: "Sensor 7-A", status: "online", lastReading: "24.1" }, - { id: "S7-002", name: "Sensor 7-B", status: "offline", lastReading: "-" }, - { id: "S7-003", name: "Sensor 7-C", status: "online", lastReading: "23.7" }, -]; +type StatusColor = "success" | "warning" | "error" | "info"; +type AccentTone = "primary" | "success" | "warning" | "info"; + +const formatPersianNumber = (value: number): string => + value.toLocaleString("fa-IR"); + +const formatDateTime = (value?: string | null): string => { + if (!value) return "-"; + + try { + return new Intl.DateTimeFormat("fa-IR-u-ca-persian", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); + } catch { + return value; + } +}; + +const shortenUuid = (value?: string | null): string => { + if (!value) return "-"; + if (value.length <= 16) return value; + return `${value.slice(0, 8)}...${value.slice(-6)}`; +}; + +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 getPayloadFieldCount = (payload?: Record | null): number => { + if (!payload || typeof payload !== "object") return 0; + return Object.keys(payload).length; +}; + +const formatPayloadPreview = (payload?: Record | null): string => { + if (!payload || typeof payload !== "object") return "payload خالی است"; + + const entries = Object.entries(payload); + + if (!entries.length) return "payload خالی است"; + + const preview = entries + .slice(0, 3) + .map(([key, value]) => `${key}: ${formatScalar(value)}`) + .join(" | "); + + return entries.length > 3 ? `${preview} | +${entries.length - 3}` : preview; +}; + +const formatJson = (value: unknown): string => { + try { + return JSON.stringify(value ?? {}, null, 2); + } catch { + return "{}"; + } +}; + +const resolveErrorMessage = ( + error: unknown, + fallback: string, +): string => { + const apiError = error as + | (ApiError & { details?: { detail?: unknown; msg?: unknown } }) + | undefined; + + if (typeof apiError?.details?.detail === "string") { + return apiError.details.detail; + } + + if (typeof apiError?.details?.msg === "string") { + return apiError.details.msg; + } + + if (typeof apiError?.message === "string" && apiError.message.trim()) { + return apiError.message; + } + + return fallback; +}; + +const getLogStatus = ( + log: SensorExternalRequestLog, +): { + label: string; + description: string; + color: StatusColor; + icon: string; +} => { + if (log.farm_sensor && log.sensor_catalog) { + return { + label: "همگام", + description: "لاگ با سنسور مزرعه و کاتالوگ متناظر تکمیل شده است.", + color: "success", + icon: "tabler-circle-check", + }; + } + + if (log.farm_sensor && !log.sensor_catalog) { + return { + label: "بدون کاتالوگ", + description: "سنسور پیدا شده اما اطلاعات کاتالوگ کامل نیست.", + color: "warning", + icon: "tabler-book-off", + }; + } + + if (!log.farm_sensor && log.sensor_catalog_uuid) { + return { + label: "نیازمند تطبیق", + description: "برای این دستگاه هنوز سنسور مزرعه متناظر پیدا نشده است.", + color: "error", + icon: "tabler-link-off", + }; + } + + return { + label: "داده خام", + description: "لاگ ثبت شده اما هنوز داده تکمیلی برای آن موجود نیست.", + color: "info", + icon: "tabler-braces", + }; +}; + +const InsightMetricCard = ({ + icon, + label, + value, + helper, + tone, +}: { + icon: string; + label: string; + value: string; + helper: string; + tone: AccentTone; +}) => { + const theme = useTheme(); + const paletteColor = theme.palette[tone]; + + return ( + + + + + + + + + + {label} + + + {value} + + + {helper} + + + + + + ); +}; + +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 = () => ( + + + + + + +); const Sensor7Page = () => { + const theme = useTheme(); const { farmHub } = useFarmHub(); const farmUuid = farmHub?.farm_uuid ?? null; const { profile, isLoading, error } = useFarmAccessProfile(farmUuid); + const canAccessSensor7 = hasAccessByRule(profile, SENSOR_7_ACCESS_RULE); + + const [logs, setLogs] = useState([]); + const [count, setCount] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + const [loading, setLoading] = useState(false); + const [hasLoadedOnce, setHasLoadedOnce] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [selectedLogId, setSelectedLogId] = useState(null); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + const requestIdRef = useRef(0); + + 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 uniqueDevicesCount = useMemo( + () => new Set(logs.map((item) => item.physical_device_uuid)).size, + [logs], + ); + + const mappedLogsCount = useMemo( + () => logs.filter((item) => Boolean(item.farm_sensor)).length, + [logs], + ); + + const catalogedLogsCount = useMemo( + () => logs.filter((item) => Boolean(item.sensor_catalog)).length, + [logs], + ); + + const loadLogs = useCallback( + async (targetPage = page, targetPageSize = pageSize) => { + if (!farmUuid || !canAccessSensor7) { + 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); + } + } + }, + [canAccessSensor7, farmUuid, page, pageSize], + ); + + useEffect(() => { + setPage(1); + setSelectedLogId(null); + setLogs([]); + setCount(0); + setErrorMessage(null); + setHasLoadedOnce(false); + }, [farmUuid]); + + useEffect(() => { + if (!farmUuid || !canAccessSensor7) return; + void loadLogs(page, pageSize); + }, [canAccessSensor7, farmUuid, loadLogs, page, pageSize]); + + const handlePageSizeChange = (event: SelectChangeEvent) => { + const nextPageSize = Number(event.target.value); + setPageSize(nextPageSize); + setPage(1); + }; + + const heroBorder = alpha(theme.palette.primary.main, 0.18); + const heroGlow = alpha(theme.palette.primary.main, 0.2); if (isLoading) { return ( - - + + ); } @@ -40,10 +468,10 @@ const Sensor7Page = () => { if (!farmUuid) { return ( - - Sensor 7 + + مانیتورینگ سنسور خارجی - برای مشاهده این صفحه ابتدا یک مزرعه انتخاب کنید. + برای مشاهده لاگ ها ابتدا یک مزرعه فعال انتخاب کنید. @@ -53,23 +481,21 @@ const Sensor7Page = () => { if (error) { return ( - - Sensor 7 + + مانیتورینگ سنسور خارجی - {error.message || "خطا در دریافت پروفایل دسترسی مزرعه."} + {resolveErrorMessage(error, "خطا در دریافت سطح دسترسی مزرعه.")} ); } - const canAccessSensor7 = hasAccessByRule(profile, SENSOR_7_ACCESS_RULE); - if (!canAccessSensor7) { return ( - - Sensor 7 + + مانیتورینگ سنسور خارجی شما به این صفحه دسترسی ندارید. @@ -78,36 +504,562 @@ const Sensor7Page = () => { ); } + const metrics = [ + { + icon: "tabler-database", + label: "کل لاگ های مزرعه", + value: formatPersianNumber(count), + helper: + totalPages > 1 + ? `صفحه ${formatPersianNumber(page)} از ${formatPersianNumber(totalPages)}` + : "تمام رخدادهای ثبت شده برای مزرعه", + tone: "primary" as AccentTone, + }, + { + icon: "tabler-stack-2", + label: "رکوردهای همین صفحه", + value: formatPersianNumber(logs.length), + helper: `${formatPersianNumber(pageSize)} رکورد در هر صفحه`, + tone: "info" as AccentTone, + }, + { + icon: "tabler-devices", + label: "دستگاه های یکتا", + value: formatPersianNumber(uniqueDevicesCount), + helper: "بر پایه physical_device_uuid در همین صفحه", + tone: "warning" as AccentTone, + }, + { + icon: "tabler-link", + label: "لاگ های تطبیق یافته", + value: formatPersianNumber(mappedLogsCount), + helper: `${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 catalogLines: Array<{ label: string; value: string }> = selectedCatalog + ? [ + { label: "نام", value: selectedCatalog.name || "-" }, + { label: "کد", value: selectedCatalog.code || "-" }, + { + label: "فیلدهای خروجی", + value: Array.isArray(selectedCatalog.returned_data_fields) + ? selectedCatalog.returned_data_fields.join("، ") || "-" + : "-", + }, + { + label: "وضعیت", + value: selectedCatalog.is_active ? "فعال" : "غیرفعال", + }, + ] + : []; + return ( - - - - Sensor 7 - - - - - - Sensor ID - Name - Status - Last Reading - - - - {MOCK_SENSOR_ROWS.map((row) => ( - - {row.id} - {row.name} - {row.status} - {row.lastReading} - - ))} - -
-
-
-
+ + + + + + + + + + + + + مرکز مانیتورینگ لاگ سنسورهای خارجی + + + این صفحه تاریخچه درخواست های ورودی سنسورهای خارجی مزرعه انتخاب شده را + به صورت زنده، صفحه بندی شده و همراه با اطلاعات تکمیلی سنسور و کاتالوگ + نمایش می دهد. + + + + + + + + + آخرین همگام سازی + + + {lastUpdatedAt ? formatDateTime(lastUpdatedAt) : "هنوز دریافت نشده"} + + + لاگ ها به صورت نزولی و از جدیدترین به قدیمی ترین نمایش داده می شوند. + + + + + + + + + {metrics.map((metric) => ( + + + + ))} + + + + + + {loading && hasLoadedOnce ? : null} + + + + + + جریان لاگ ها + + + روی هر ردیف کلیک کنید تا payload و جزئیات سنسور همان لاگ در پنل سمت + راست باز شود. + + + + + + اندازه صفحه + + labelId="sensor7-page-size-label" + value={pageSize} + label="اندازه صفحه" + onChange={handlePageSizeChange} + > + {PAGE_SIZE_OPTIONS.map((option) => ( + + {formatPersianNumber(option)} + + ))} + + + + + + بازه فعلی + + + {count + ? `${formatPersianNumber((page - 1) * pageSize + 1)} تا ${formatPersianNumber( + Math.min(page * pageSize, count), + )}` + : "۰ تا ۰"} + + + + + + + + {!hasLoadedOnce && loading ? ( + + ) : errorMessage ? ( + {errorMessage} + ) : logs.length === 0 ? ( + + هنوز لاگی برای این مزرعه ثبت نشده است یا در این صفحه داده ای وجود ندارد. + + ) : ( + <> + + + + + وضعیت + زمان ثبت + دستگاه + سنسور مزرعه + کاتالوگ + خلاصه payload + + + + {logs.map((log) => { + const status = getLogStatus(log); + const isSelected = selectedLog?.id === log.id; + + return ( + setSelectedLogId(log.id)} + sx={{ + cursor: "pointer", + "& .MuiTableCell-root": { + borderColor: alpha(theme.palette.divider, 0.9), + }, + }} + > + + } + label={status.label} + sx={{ fontWeight: 600 }} + /> + + + + + {formatDateTime(log.created_at)} + + + ID: {formatPersianNumber(log.id)} + + + + + + + {shortenUuid(log.physical_device_uuid)} + + + {log.physical_device_uuid} + + + + + + {log.farm_sensor?.name || "تطبیق نشده"} + + + {log.farm_sensor?.sensor_type || "فاقد سنسور مزرعه"} + + + + + {log.sensor_catalog?.name || "نامشخص"} + + + {log.sensor_catalog?.code || shortenUuid(log.sensor_catalog_uuid)} + + + + + {formatPayloadPreview(log.payload)} + + + + ); + })} + +
+
+ + {totalPages > 1 ? ( + + setPage(value)} + /> + + ) : null} + + )} +
+
+
+ + + + + + + + + جزئیات لاگ انتخاب شده + + + اطلاعات enrich شده سنسور و کاتالوگ از همین پنل قابل بررسی است. + + + {selectedStatus ? ( + + ) : null} + + + {selectedLog ? ( + <> + + {selectedStatus?.description} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + payload دریافتی + + + + {formatJson(selectedLog.payload)} + + + + ) : ( + + یک لاگ از جدول سمت چپ انتخاب کنید تا جزئیات آن در این بخش نمایش داده شود. + + )} + + + + + + راهنمای سریع endpoint + + این ویو با `farm_uuid` فیلتر می شود، فقط `GET` را می پذیرد و صفحه بندی + آن با `page` و `page_size` کنترل می شود. + + + + + + + + + + + + اگر backend هنوز migrate نشده باشد، پاسخ `503` با پیام آماده نبودن جدول ها + برمی گردد. + + + + + +
+
); };