feat: support custom maintenance mode duration #22

Merged
etoneya merged 1 commits from feat/maintenance into develop 2026-10-18 12:11:13 +00:00
4 changed files with 206 additions and 30 deletions
Showing only changes of commit fd582cbab8 - Show all commits

View File

@@ -1,7 +1,7 @@
import { authInstance, languageInstance, mobxFetch } from "@shared"; import { authInstance, languageInstance, mobxFetch } from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
import { getVehicleSessionsApi } from "./api"; import { getVehicleSessionsApi } from "./api";
import { Vehicle, VehicleMaintenanceSession } from "./types"; import { Vehicle, VehicleMaintenanceSession, SetMaintenanceModeResponse } from "./types";
export type { Vehicle, VehicleMaintenanceSession } from "./types"; export type { Vehicle, VehicleMaintenanceSession } from "./types";
@@ -218,23 +218,32 @@ class VehicleStore {
}); });
}; };
setMaintenanceMode = async (uuid: string, enabled: boolean) => { setMaintenanceMode = async (
const response = await authInstance.post( uuid: string,
enabled: boolean,
durationSeconds?: number,
): Promise<SetMaintenanceModeResponse> => {
const response = await authInstance.post<SetMaintenanceModeResponse>(
`/devices/${uuid}/maintenance-mode`, `/devices/${uuid}/maintenance-mode`,
{ {
enabled, 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(() => { runInAction(() => {
this.mergeVehicleInCaches({ this.mergeVehicleInCaches({
...normalizedVehicle.vehicle,
uuid, uuid,
maintenance_mode_on: maintenance_mode_on: response.data.enabled,
normalizedVehicle.vehicle.maintenance_mode_on ?? enabled, maintenance_mode_duration_seconds: response.data.duration_seconds,
}); });
}); });
return response.data;
}; };
setDemoMode = async (uuid: string, enabled: boolean) => { setDemoMode = async (uuid: string, enabled: boolean) => {

View File

@@ -11,6 +11,7 @@ export type Vehicle = {
snapshot_update_blocked?: boolean; snapshot_update_blocked?: boolean;
demo_mode_enabled?: boolean; demo_mode_enabled?: boolean;
maintenance_mode_on?: boolean; maintenance_mode_on?: boolean;
maintenance_mode_duration_seconds?: number;
city_id?: number; city_id?: number;
}; };
device_status?: { device_status?: {
@@ -21,13 +22,24 @@ export type Vehicle = {
last_update: string; last_update: string;
is_connected: boolean; is_connected: boolean;
current_route_id?: number; current_route_id?: number;
maintenance_mode_on?: boolean;
maintenance_mode_duration_seconds?: number;
}; };
}; };
export type VehicleMaintenanceSession = { export type VehicleMaintenanceSession = {
duration_seconds: number; duration_seconds?: number | null;
ended_at: string; ended_at?: string | null;
id: number; id: number;
started_at: string; started_at: string;
vehicle_id: number; vehicle_id: number;
}; };
export type SetMaintenanceModeResponse = {
message: string;
enabled: boolean;
duration_seconds: number;
saved: boolean;
sent: boolean;
warning?: string;
};

View File

@@ -21,7 +21,7 @@ interface VehicleSessionsModalProps {
onClose: () => void; onClose: () => void;
} }
const formatDateTime = (value: string) => { const formatDateTime = (value?: string | null) => {
if (!value) return "-"; if (!value) return "-";
const date = new Date(value); const date = new Date(value);
@@ -38,8 +38,8 @@ const formatDateTime = (value: string) => {
}).format(date); }).format(date);
}; };
const formatDuration = (durationSeconds: number) => { const formatDuration = (durationSeconds?: number | null) => {
if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { if (durationSeconds == null || !Number.isFinite(durationSeconds) || durationSeconds < 0) {
return "-"; return "-";
} }
@@ -152,10 +152,10 @@ export const VehicleSessionsModal = observer(
{formatDateTime(session.started_at)} {formatDateTime(session.started_at)}
</TableCell> </TableCell>
<TableCell> <TableCell>
{formatDateTime(session.ended_at)} {session.ended_at ? formatDateTime(session.ended_at) : "В процессе"}
</TableCell> </TableCell>
<TableCell align="right"> <TableCell align="right">
{formatDuration(session.duration_seconds)} {session.ended_at ? formatDuration(session.duration_seconds) : "—"}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}

View File

@@ -34,12 +34,26 @@ import {
Typography, Typography,
Checkbox, Checkbox,
Tooltip, Tooltip,
TextField,
MenuItem,
} from "@mui/material"; } from "@mui/material";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { DeleteModal } from "@widgets"; import { DeleteModal } from "@widgets";
import { DeviceLogsModal } from "./DeviceLogsModal"; import { DeviceLogsModal } from "./DeviceLogsModal";
import { VehicleSessionsModal } from "./VehicleSessionsModal"; 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; export type ConnectedDevice = string;
@@ -81,6 +95,7 @@ type RowData = {
current_snapshot_uuid: string | null; current_snapshot_uuid: string | null;
snapshot_update_blocked: boolean; snapshot_update_blocked: boolean;
maintenance_mode_on: boolean; maintenance_mode_on: boolean;
maintenance_mode_duration_seconds: number;
demo_mode_enabled: boolean; demo_mode_enabled: boolean;
current_route_id: number | null; current_route_id: number | null;
}; };
@@ -120,6 +135,7 @@ const transformToRows = (vehicles: Vehicle[]): RowData[] => {
current_snapshot_uuid: vehicle.vehicle.current_snapshot_uuid ?? null, current_snapshot_uuid: vehicle.vehicle.current_snapshot_uuid ?? null,
snapshot_update_blocked: vehicle.vehicle.snapshot_update_blocked ?? false, snapshot_update_blocked: vehicle.vehicle.snapshot_update_blocked ?? false,
maintenance_mode_on: vehicle.vehicle.maintenance_mode_on ?? 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, demo_mode_enabled: vehicle.vehicle.demo_mode_enabled ?? false,
current_route_id: vehicle.device_status?.current_route_id ?? null, current_route_id: vehicle.device_status?.current_route_id ?? null,
}; };
@@ -169,6 +185,11 @@ export const DevicesTable = observer(() => {
); );
const [maintenanceConfirm, setMaintenanceConfirm] = const [maintenanceConfirm, setMaintenanceConfirm] =
useState<PendingModeToggle | null>(null); useState<PendingModeToggle | null>(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<PendingModeToggle | null>( const [demoConfirm, setDemoConfirm] = useState<PendingModeToggle | null>(
null, null,
); );
@@ -238,6 +259,7 @@ export const DevicesTable = observer(() => {
}, [rows, selectedIds]); }, [rows, selectedIds]);
const applyMaintenanceMode = async (toggle: PendingModeToggle) => { const applyMaintenanceMode = async (toggle: PendingModeToggle) => {
if (!canMaintenanceDevices) return false;
setMaintenanceLoadingUuids((prev) => { setMaintenanceLoadingUuids((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.add(toggle.deviceUuid); next.add(toggle.deviceUuid);
@@ -245,20 +267,42 @@ export const DevicesTable = observer(() => {
}); });
try { try {
await setMaintenanceMode(toggle.deviceUuid, toggle.nextEnabled); const result = await setMaintenanceMode(
await getVehicles(); toggle.deviceUuid,
await getDevices(); toggle.nextEnabled,
toast.success( toggle.nextEnabled ? maintenanceDuration : undefined,
toggle.nextEnabled
? "Устройство отправлено на ТО"
: "Режим ТО отключен",
); );
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) { } catch (error) {
console.error( console.error(
`Error toggling maintenance mode for ${toggle.deviceUuid}:`, `Error toggling maintenance mode for ${toggle.deviceUuid}:`,
error, error,
); );
toast.error("Не удалось изменить режим ТО"); const status = isAxiosError(error) ? error.response?.status : undefined;
const messages: Record<number, string> = {
400: "Некорректные параметры режима ТО",
401: "Войдите в систему, чтобы изменить режим ТО",
403: "Нет права на управление режимом ТО",
404: "Устройство не найдено",
500: "Сервер не смог сохранить настройку ТО",
};
toast.error((status && messages[status]) || "Не удалось изменить режим ТО");
return false;
} finally { } finally {
setMaintenanceLoadingUuids((prev) => { setMaintenanceLoadingUuids((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -298,7 +342,12 @@ export const DevicesTable = observer(() => {
}; };
const openMaintenanceConfirm = (row: RowData) => { 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({ setMaintenanceConfirm({
deviceUuid: row.device_uuid, deviceUuid: row.device_uuid,
nextEnabled: !row.maintenance_mode_on, nextEnabled: !row.maintenance_mode_on,
@@ -316,12 +365,13 @@ export const DevicesTable = observer(() => {
}; };
const handleConfirmMaintenanceToggle = async () => { const handleConfirmMaintenanceToggle = async () => {
if (!maintenanceConfirm) return; if (!maintenanceConfirm || !canMaintenanceDevices || maintenanceConfirmSubmitting) return;
setMaintenanceConfirmSubmitting(true); setMaintenanceConfirmSubmitting(true);
try { try {
await applyMaintenanceMode(maintenanceConfirm); if (await applyMaintenanceMode(maintenanceConfirm)) {
setMaintenanceConfirm(null); setMaintenanceConfirm(null);
}
} finally { } finally {
setMaintenanceConfirmSubmitting(false); setMaintenanceConfirmSubmitting(false);
} }
@@ -441,9 +491,13 @@ export const DevicesTable = observer(() => {
sx={{ display: "flex", justifyContent: "center", width: "100%" }} sx={{ display: "flex", justifyContent: "center", width: "100%" }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<Tooltip title={`Сохранённая длительность ТО: ${formatMaintenanceDuration(rowData.maintenance_mode_duration_seconds)}`}>
<span>
<Checkbox <Checkbox
checked={rowData.maintenance_mode_on} checked={rowData.maintenance_mode_on}
inputProps={{ "aria-label": `Режим ТО: ${rowData.tail_number}` }}
disabled={ disabled={
!canMaintenanceDevices ||
!rowData.device_uuid || !rowData.device_uuid ||
isMaintenanceLoading || isMaintenanceLoading ||
maintenanceConfirmSubmitting maintenanceConfirmSubmitting
@@ -452,6 +506,8 @@ export const DevicesTable = observer(() => {
sx={{ p: 0 }} sx={{ p: 0 }}
onChange={() => openMaintenanceConfirm(rowData)} onChange={() => openMaintenanceConfirm(rowData)}
/> />
</span>
</Tooltip>
</Box> </Box>
); );
}, },
@@ -694,6 +750,7 @@ export const DevicesTable = observer(() => {
demoConfirmSubmitting, demoConfirmSubmitting,
routes, routes,
canWriteDevices, canWriteDevices,
canMaintenanceDevices,
isMaintenanceOnly, isMaintenanceOnly,
], ],
); );
@@ -708,7 +765,7 @@ export const DevicesTable = observer(() => {
} }
if (!canWriteDevices) { if (!canWriteDevices) {
return columns.filter( return columns.filter(
(c) => c.field !== "maintenance_mode_on" && c.field !== "demo_mode_enabled", (c) => c.field !== "demo_mode_enabled",
); );
} }
return columns; return columns;
@@ -732,6 +789,29 @@ export const DevicesTable = observer(() => {
fetchData(); fetchData();
}, [getDevices, getSnapshots, getVehicles, getRoutes, isMaintenanceOnly, selectedCityStore.cityVersion]); }, [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 = () => { const handleOpenSendSnapshotModal = () => {
if (!canWriteDevices) { if (!canWriteDevices) {
@@ -1027,13 +1107,83 @@ export const DevicesTable = observer(() => {
sx={{ width: "min(640px, 92vw)", p: 3 }} sx={{ width: "min(640px, 92vw)", p: 3 }}
> >
<Box component="h2" sx={{ mb: 1, typography: "h6" }}> <Box component="h2" sx={{ mb: 1, typography: "h6" }}>
Подтверждение режима ТО {maintenanceConfirm?.nextEnabled ? "Включить режим ТО" : "Отключить режим ТО"}
</Box> </Box>
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
{maintenanceConfirm?.nextEnabled {maintenanceConfirm?.nextEnabled
? `Включить режим ТО для устройства ${maintenanceConfirm.tailNumber}?` ? `Включить режим ТО для устройства ${maintenanceConfirm.tailNumber}?`
: `Отключить режим ТО для устройства ${maintenanceConfirm?.tailNumber}?`} : `Отключить режим ТО для устройства ${maintenanceConfirm?.tailNumber}?`}
</Box> </Box>
{maintenanceConfirm?.nextEnabled && (
<Box sx={{ mb: 3 }}>
<TextField
select
fullWidth
label="Длительность"
value={customMaintenanceDuration ? -1 : maintenanceDuration}
disabled={maintenanceConfirmSubmitting}
onChange={(event) => {
const seconds = Number(event.target.value);
if (seconds === -1) {
setCustomMaintenanceDuration(true);
return;
}
setCustomMaintenanceDuration(false);
setMaintenanceDuration(seconds);
}}
helperText="Автоотключение выполняется на устройстве после применения команды."
>
{maintenanceDurations.map((seconds) => (
<MenuItem key={seconds} value={seconds}>
{formatMaintenanceDuration(seconds)}
</MenuItem>
))}
{!maintenanceDurations.includes(savedMaintenanceDuration) && (
<MenuItem value={savedMaintenanceDuration}>
{formatMaintenanceDuration(savedMaintenanceDuration)} (сохранено)
</MenuItem>
)}
<MenuItem value={-1}>Своё время</MenuItem>
</TextField>
{customMaintenanceDuration && (
<Box sx={{ display: "flex", gap: 1.5, mt: 2 }}>
<TextField
type="number"
fullWidth
label="Часы"
value={customMaintenanceHours}
disabled={maintenanceConfirmSubmitting}
inputProps={{ min: 0, max: Math.floor(MAX_MAINTENANCE_DURATION_SECONDS / 3600), step: 1 }}
onChange={(event) => {
const hours = Math.max(0, Math.floor(Number(event.target.value) || 0));
setCustomMaintenanceHours(hours);
setMaintenanceDuration(hours * 3600 + customMaintenanceMinutes * 60);
}}
/>
<TextField
type="number"
fullWidth
label="Минуты"
value={customMaintenanceMinutes}
disabled={maintenanceConfirmSubmitting}
inputProps={{ min: 0, max: 59, step: 1 }}
onChange={(event) => {
const minutes = Math.min(59, Math.max(0, Math.floor(Number(event.target.value) || 0)));
setCustomMaintenanceMinutes(minutes);
setMaintenanceDuration(customMaintenanceHours * 3600 + minutes * 60);
}}
/>
</Box>
)}
{customMaintenanceDuration && (maintenanceDuration < 60 || maintenanceDuration > MAX_MAINTENANCE_DURATION_SECONDS) && (
<Typography color="error" variant="caption" sx={{ display: "block", mt: 1 }}>
{maintenanceDuration < 60
? "Укажите длительность не меньше одной минуты."
: "Указанная длительность превышает допустимый максимум."}
</Typography>
)}
</Box>
)}
<Box sx={{ display: "flex", gap: 1.5 }}> <Box sx={{ display: "flex", gap: 1.5 }}>
<Button <Button
fullWidth fullWidth
@@ -1047,10 +1197,15 @@ export const DevicesTable = observer(() => {
<Button <Button
fullWidth fullWidth
variant="contained" variant="contained"
disabled={maintenanceConfirmSubmitting} disabled={
maintenanceConfirmSubmitting ||
(Boolean(maintenanceConfirm?.nextEnabled) &&
customMaintenanceDuration &&
(maintenanceDuration < 60 || maintenanceDuration > MAX_MAINTENANCE_DURATION_SECONDS))
}
onClick={handleConfirmMaintenanceToggle} onClick={handleConfirmMaintenanceToggle}
> >
Подтвердить {maintenanceConfirmSubmitting ? "Сохранение…" : maintenanceConfirm?.nextEnabled ? "Включить" : "Отключить"}
</Button> </Button>
</Box> </Box>
</Modal> </Modal>