diff --git a/src/shared/store/VehicleStore/index.ts b/src/shared/store/VehicleStore/index.ts index 49a424c..04d1685 100644 --- a/src/shared/store/VehicleStore/index.ts +++ b/src/shared/store/VehicleStore/index.ts @@ -1,7 +1,7 @@ import { authInstance, languageInstance, mobxFetch } from "@shared"; import { makeAutoObservable, runInAction } from "mobx"; import { getVehicleSessionsApi } from "./api"; -import { Vehicle, VehicleMaintenanceSession } from "./types"; +import { Vehicle, VehicleMaintenanceSession, SetMaintenanceModeResponse } from "./types"; export type { Vehicle, VehicleMaintenanceSession } from "./types"; @@ -218,23 +218,32 @@ class VehicleStore { }); }; - setMaintenanceMode = async (uuid: string, enabled: boolean) => { - const response = await authInstance.post( + setMaintenanceMode = async ( + uuid: string, + enabled: boolean, + durationSeconds?: number, + ): Promise => { + const response = await authInstance.post( `/devices/${uuid}/maintenance-mode`, { enabled, + ...(enabled && durationSeconds !== undefined + ? { duration_seconds: durationSeconds } + : {}), }, ); - const normalizedVehicle = this.normalizeVehicleItem(response.data); + if (!response.data.saved) { + throw new Error("Maintenance mode was not saved"); + } runInAction(() => { this.mergeVehicleInCaches({ - ...normalizedVehicle.vehicle, uuid, - maintenance_mode_on: - normalizedVehicle.vehicle.maintenance_mode_on ?? enabled, + maintenance_mode_on: response.data.enabled, + maintenance_mode_duration_seconds: response.data.duration_seconds, }); }); + return response.data; }; setDemoMode = async (uuid: string, enabled: boolean) => { diff --git a/src/shared/store/VehicleStore/types.ts b/src/shared/store/VehicleStore/types.ts index 11bd39e..def34bf 100644 --- a/src/shared/store/VehicleStore/types.ts +++ b/src/shared/store/VehicleStore/types.ts @@ -11,6 +11,7 @@ export type Vehicle = { snapshot_update_blocked?: boolean; demo_mode_enabled?: boolean; maintenance_mode_on?: boolean; + maintenance_mode_duration_seconds?: number; city_id?: number; }; device_status?: { @@ -21,13 +22,24 @@ export type Vehicle = { last_update: string; is_connected: boolean; current_route_id?: number; + maintenance_mode_on?: boolean; + maintenance_mode_duration_seconds?: number; }; }; export type VehicleMaintenanceSession = { - duration_seconds: number; - ended_at: string; + duration_seconds?: number | null; + ended_at?: string | null; id: number; started_at: string; vehicle_id: number; }; + +export type SetMaintenanceModeResponse = { + message: string; + enabled: boolean; + duration_seconds: number; + saved: boolean; + sent: boolean; + warning?: string; +}; diff --git a/src/widgets/DevicesTable/VehicleSessionsModal.tsx b/src/widgets/DevicesTable/VehicleSessionsModal.tsx index 901a075..ea826c2 100644 --- a/src/widgets/DevicesTable/VehicleSessionsModal.tsx +++ b/src/widgets/DevicesTable/VehicleSessionsModal.tsx @@ -21,7 +21,7 @@ interface VehicleSessionsModalProps { onClose: () => void; } -const formatDateTime = (value: string) => { +const formatDateTime = (value?: string | null) => { if (!value) return "-"; const date = new Date(value); @@ -38,8 +38,8 @@ const formatDateTime = (value: string) => { }).format(date); }; -const formatDuration = (durationSeconds: number) => { - if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { +const formatDuration = (durationSeconds?: number | null) => { + if (durationSeconds == null || !Number.isFinite(durationSeconds) || durationSeconds < 0) { return "-"; } @@ -152,10 +152,10 @@ export const VehicleSessionsModal = observer( {formatDateTime(session.started_at)} - {formatDateTime(session.ended_at)} + {session.ended_at ? formatDateTime(session.ended_at) : "В процессе"} - {formatDuration(session.duration_seconds)} + {session.ended_at ? formatDuration(session.duration_seconds) : "—"} ))} diff --git a/src/widgets/DevicesTable/index.tsx b/src/widgets/DevicesTable/index.tsx index d0a3008..6a94093 100644 --- a/src/widgets/DevicesTable/index.tsx +++ b/src/widgets/DevicesTable/index.tsx @@ -34,12 +34,26 @@ import { Typography, Checkbox, Tooltip, + TextField, + MenuItem, } from "@mui/material"; import { toast } from "react-toastify"; import { useNavigate } from "react-router-dom"; import { DeleteModal } from "@widgets"; import { DeviceLogsModal } from "./DeviceLogsModal"; import { VehicleSessionsModal } from "./VehicleSessionsModal"; +import { isAxiosError } from "axios"; + +const maintenanceDurations = [0, 900, 1800, 3600, 7200]; +const MAX_MAINTENANCE_DURATION_SECONDS = 2_147_483_647; +const formatMaintenanceDuration = (seconds: number) => { + if (seconds === 0) return "Без автоматического отключения"; + return [ + Math.floor(seconds / 3600) ? `${Math.floor(seconds / 3600)} ч` : "", + Math.floor((seconds % 3600) / 60) ? `${Math.floor((seconds % 3600) / 60)} мин` : "", + seconds % 60 ? `${seconds % 60} с` : "", + ].filter(Boolean).join(" "); +}; export type ConnectedDevice = string; @@ -81,6 +95,7 @@ type RowData = { current_snapshot_uuid: string | null; snapshot_update_blocked: boolean; maintenance_mode_on: boolean; + maintenance_mode_duration_seconds: number; demo_mode_enabled: boolean; current_route_id: number | null; }; @@ -120,6 +135,7 @@ const transformToRows = (vehicles: Vehicle[]): RowData[] => { current_snapshot_uuid: vehicle.vehicle.current_snapshot_uuid ?? null, snapshot_update_blocked: vehicle.vehicle.snapshot_update_blocked ?? false, maintenance_mode_on: vehicle.vehicle.maintenance_mode_on ?? false, + maintenance_mode_duration_seconds: vehicle.vehicle.maintenance_mode_duration_seconds ?? 0, demo_mode_enabled: vehicle.vehicle.demo_mode_enabled ?? false, current_route_id: vehicle.device_status?.current_route_id ?? null, }; @@ -169,6 +185,11 @@ export const DevicesTable = observer(() => { ); const [maintenanceConfirm, setMaintenanceConfirm] = useState(null); + const [maintenanceDuration, setMaintenanceDuration] = useState(0); + const [customMaintenanceDuration, setCustomMaintenanceDuration] = useState(false); + const [customMaintenanceHours, setCustomMaintenanceHours] = useState(0); + const [customMaintenanceMinutes, setCustomMaintenanceMinutes] = useState(0); + const [savedMaintenanceDuration, setSavedMaintenanceDuration] = useState(0); const [demoConfirm, setDemoConfirm] = useState( null, ); @@ -238,6 +259,7 @@ export const DevicesTable = observer(() => { }, [rows, selectedIds]); const applyMaintenanceMode = async (toggle: PendingModeToggle) => { + if (!canMaintenanceDevices) return false; setMaintenanceLoadingUuids((prev) => { const next = new Set(prev); next.add(toggle.deviceUuid); @@ -245,20 +267,42 @@ export const DevicesTable = observer(() => { }); try { - await setMaintenanceMode(toggle.deviceUuid, toggle.nextEnabled); - await getVehicles(); - await getDevices(); - toast.success( - toggle.nextEnabled - ? "Устройство отправлено на ТО" - : "Режим ТО отключен", + const result = await setMaintenanceMode( + toggle.deviceUuid, + toggle.nextEnabled, + toggle.nextEnabled ? maintenanceDuration : undefined, ); + if (!result.sent) { + toast.warning( + result.enabled + ? "Настройка сохранена. Режим ТО включится после подключения устройства." + : "Настройка сохранена. Режим ТО отключится после подключения устройства.", + ); + } else { + toast.success(result.enabled + ? `Команда включения ТО отправлена. Длительность: ${formatMaintenanceDuration(result.duration_seconds)}` + : "Команда отключения ТО отправлена"); + } + const refresh = await Promise.allSettled([getVehicles(), getDevices()]); + if (refresh.some((result) => result.status === "rejected")) { + toast.warning("Настройка ТО сохранена, но обновить таблицу не удалось. Обновите страницу."); + } + return true; } catch (error) { console.error( `Error toggling maintenance mode for ${toggle.deviceUuid}:`, error, ); - toast.error("Не удалось изменить режим ТО"); + const status = isAxiosError(error) ? error.response?.status : undefined; + const messages: Record = { + 400: "Некорректные параметры режима ТО", + 401: "Войдите в систему, чтобы изменить режим ТО", + 403: "Нет права на управление режимом ТО", + 404: "Устройство не найдено", + 500: "Сервер не смог сохранить настройку ТО", + }; + toast.error((status && messages[status]) || "Не удалось изменить режим ТО"); + return false; } finally { setMaintenanceLoadingUuids((prev) => { const next = new Set(prev); @@ -298,7 +342,12 @@ export const DevicesTable = observer(() => { }; const openMaintenanceConfirm = (row: RowData) => { - if (!row.device_uuid) return; + if (!row.device_uuid || !canMaintenanceDevices || maintenanceConfirmSubmitting) return; + setMaintenanceDuration(row.maintenance_mode_duration_seconds); + setCustomMaintenanceDuration(false); + setCustomMaintenanceHours(Math.floor(row.maintenance_mode_duration_seconds / 3600)); + setCustomMaintenanceMinutes(Math.floor((row.maintenance_mode_duration_seconds % 3600) / 60)); + setSavedMaintenanceDuration(row.maintenance_mode_duration_seconds); setMaintenanceConfirm({ deviceUuid: row.device_uuid, nextEnabled: !row.maintenance_mode_on, @@ -316,12 +365,13 @@ export const DevicesTable = observer(() => { }; const handleConfirmMaintenanceToggle = async () => { - if (!maintenanceConfirm) return; + if (!maintenanceConfirm || !canMaintenanceDevices || maintenanceConfirmSubmitting) return; setMaintenanceConfirmSubmitting(true); try { - await applyMaintenanceMode(maintenanceConfirm); - setMaintenanceConfirm(null); + if (await applyMaintenanceMode(maintenanceConfirm)) { + setMaintenanceConfirm(null); + } } finally { setMaintenanceConfirmSubmitting(false); } @@ -441,9 +491,13 @@ export const DevicesTable = observer(() => { sx={{ display: "flex", justifyContent: "center", width: "100%" }} onClick={(e) => e.stopPropagation()} > + + { sx={{ p: 0 }} onChange={() => openMaintenanceConfirm(rowData)} /> + + ); }, @@ -694,6 +750,7 @@ export const DevicesTable = observer(() => { demoConfirmSubmitting, routes, canWriteDevices, + canMaintenanceDevices, isMaintenanceOnly, ], ); @@ -708,7 +765,7 @@ export const DevicesTable = observer(() => { } if (!canWriteDevices) { return columns.filter( - (c) => c.field !== "maintenance_mode_on" && c.field !== "demo_mode_enabled", + (c) => c.field !== "demo_mode_enabled", ); } return columns; @@ -732,6 +789,29 @@ export const DevicesTable = observer(() => { fetchData(); }, [getDevices, getSnapshots, getVehicles, getRoutes, isMaintenanceOnly, selectedCityStore.cityVersion]); + useEffect(() => { + let refreshing = false; + const refreshMaintenance = async () => { + if (document.visibilityState !== "visible" || refreshing || maintenanceConfirmSubmitting) return; + refreshing = true; + try { + const results = await Promise.allSettled([getVehicles(), getDevices()]); + for (const result of results) { + if (result.status === "rejected") { + console.error("Failed to refresh maintenance state:", result.reason); + } + } + } finally { + refreshing = false; + } + }; + const timer = window.setInterval(refreshMaintenance, 15_000); + document.addEventListener("visibilitychange", refreshMaintenance); + return () => { + window.clearInterval(timer); + document.removeEventListener("visibilitychange", refreshMaintenance); + }; + }, [getVehicles, getDevices, maintenanceConfirmSubmitting]); const handleOpenSendSnapshotModal = () => { if (!canWriteDevices) { @@ -1027,13 +1107,83 @@ export const DevicesTable = observer(() => { sx={{ width: "min(640px, 92vw)", p: 3 }} > - Подтверждение режима ТО + {maintenanceConfirm?.nextEnabled ? "Включить режим ТО" : "Отключить режим ТО"} {maintenanceConfirm?.nextEnabled ? `Включить режим ТО для устройства ${maintenanceConfirm.tailNumber}?` : `Отключить режим ТО для устройства ${maintenanceConfirm?.tailNumber}?`} + {maintenanceConfirm?.nextEnabled && ( + + { + const seconds = Number(event.target.value); + if (seconds === -1) { + setCustomMaintenanceDuration(true); + return; + } + setCustomMaintenanceDuration(false); + setMaintenanceDuration(seconds); + }} + helperText="Автоотключение выполняется на устройстве после применения команды." + > + {maintenanceDurations.map((seconds) => ( + + {formatMaintenanceDuration(seconds)} + + ))} + {!maintenanceDurations.includes(savedMaintenanceDuration) && ( + + {formatMaintenanceDuration(savedMaintenanceDuration)} (сохранено) + + )} + Своё время + + {customMaintenanceDuration && ( + + { + const hours = Math.max(0, Math.floor(Number(event.target.value) || 0)); + setCustomMaintenanceHours(hours); + setMaintenanceDuration(hours * 3600 + customMaintenanceMinutes * 60); + }} + /> + { + const minutes = Math.min(59, Math.max(0, Math.floor(Number(event.target.value) || 0))); + setCustomMaintenanceMinutes(minutes); + setMaintenanceDuration(customMaintenanceHours * 3600 + minutes * 60); + }} + /> + + )} + {customMaintenanceDuration && (maintenanceDuration < 60 || maintenanceDuration > MAX_MAINTENANCE_DURATION_SECONDS) && ( + + {maintenanceDuration < 60 + ? "Укажите длительность не меньше одной минуты." + : "Указанная длительность превышает допустимый максимум."} + + )} + + )}