Files
WhiteNightsAdminPanel/src/widgets/DevicesTable/index.tsx

1103 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import {
authStore,
authInstance,
devicesStore,
Modal,
snapshotStore,
vehicleStore,
routeStore,
Vehicle,
carrierStore,
selectedCityStore,
menuStore,
VEHICLE_TYPES,
} from "@shared";
import { useEffect, useMemo, useState } from "react";
import { observer } from "mobx-react-lite";
import {
Check,
ChevronDown,
ChevronRight,
Copy,
Pencil,
RotateCcw,
ScrollText,
Trash2,
Wrench,
X,
} from "lucide-react";
import {
Button,
Box,
CircularProgress,
Typography,
Checkbox,
Tooltip,
} 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";
export type ConnectedDevice = string;
interface Snapshot {
ID: string;
Name: string;
}
const formatDate = (dateString: string | null) => {
if (!dateString) return "Нет данных";
try {
const date = new Date(dateString);
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(date);
} catch {
return "Некорректная дата";
}
};
type RowData = {
id: number;
vehicle_id: number;
type: string;
model: string;
tail_number: string;
online: boolean;
lastUpdate: string | null;
gps: boolean;
media: boolean;
connection: boolean;
device_uuid: string | null;
current_snapshot_uuid: string | null;
snapshot_update_blocked: boolean;
maintenance_mode_on: boolean;
demo_mode_enabled: boolean;
current_route_id: number | null;
};
type PendingModeToggle = {
deviceUuid: string;
nextEnabled: boolean;
tailNumber: string;
};
function getVehicleTypeLabel(vehicle: Vehicle): string {
return (
VEHICLE_TYPES.find((t) => t.value === vehicle.vehicle.type)?.label ?? "—"
);
}
const transformToRows = (vehicles: Vehicle[]): RowData[] => {
return vehicles.map((vehicle) => {
const uuid = vehicle.vehicle.uuid;
const model =
vehicle.vehicle.model != null && vehicle.vehicle.model !== ""
? vehicle.vehicle.model
: "—";
const type = getVehicleTypeLabel(vehicle);
return {
id: vehicle.vehicle.id,
vehicle_id: vehicle.vehicle.id,
type,
model,
tail_number: vehicle.vehicle.tail_number,
online: uuid ? (vehicle.device_status?.online ?? false) : false,
lastUpdate: vehicle.device_status?.last_update ?? null,
gps: uuid ? (vehicle.device_status?.gps_ok ?? false) : false,
media: uuid ? (vehicle.device_status?.media_service_ok ?? false) : false,
connection: uuid ? (vehicle.device_status?.is_connected ?? false) : false,
device_uuid: uuid ?? null,
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,
demo_mode_enabled: vehicle.vehicle.demo_mode_enabled ?? false,
current_route_id: vehicle.device_status?.current_route_id ?? null,
};
});
};
export const DevicesTable = observer(() => {
const canWriteDevices = authStore.canWrite("devices");
const {
getDevices,
setSelectedDevice,
sendSnapshotModalOpen,
toggleSendSnapshotModal,
devices,
} = devicesStore;
const { snapshots, getSnapshots } = snapshotStore;
const { routes, getRoutes } = routeStore;
const {
getVehicles,
vehicles,
deleteVehicle,
setMaintenanceMode,
setDemoMode,
} = vehicleStore;
const navigate = useNavigate();
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [logsModalOpen, setLogsModalOpen] = useState(false);
const [logsModalDeviceUuid, setLogsModalDeviceUuid] = useState<string | null>(
null,
);
const [sessionsModalOpen, setSessionsModalOpen] = useState(false);
const [sessionsModalVehicleId, setSessionsModalVehicleId] = useState<
number | null
>(null);
const [sessionsModalVehicleTailNumber, setSessionsModalVehicleTailNumber] =
useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [maintenanceLoadingUuids, setMaintenanceLoadingUuids] = useState<
Set<string>
>(new Set());
const [demoLoadingUuids, setDemoLoadingUuids] = useState<Set<string>>(
new Set(),
);
const [maintenanceConfirm, setMaintenanceConfirm] =
useState<PendingModeToggle | null>(null);
const [demoConfirm, setDemoConfirm] = useState<PendingModeToggle | null>(
null,
);
const [maintenanceConfirmSubmitting, setMaintenanceConfirmSubmitting] =
useState(false);
const [demoConfirmSubmitting, setDemoConfirmSubmitting] = useState(false);
const [collapsedModels, setCollapsedModels] = useState<Set<string>>(
new Set(),
);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const filterVehiclesBySelectedCity = (vehiclesList: Vehicle[]): Vehicle[] => {
const selectedCityId = selectedCityStore.selectedCityId;
if (!selectedCityId) {
return vehiclesList;
}
const carriersInSelectedCityIds = new Set(
carrierStore.carriers.ru.data
.filter((carrier) => carrier.city_id === selectedCityId)
.map((carrier) => carrier.id),
);
if (carriersInSelectedCityIds.size === 0) {
return [];
}
return vehiclesList.filter((vehicle) =>
carriersInSelectedCityIds.has(vehicle.vehicle.carrier_id),
);
};
const filteredVehicles = filterVehiclesBySelectedCity(
vehicles.data as Vehicle[],
);
const rows = useMemo(
() => transformToRows(filteredVehicles),
[filteredVehicles],
);
const groupsByModel = useMemo(() => {
const map = new Map<string, RowData[]>();
for (const row of rows) {
const list = map.get(row.model) ?? [];
list.push(row);
map.set(row.model, list);
}
return Array.from(map.entries()).map(([model, groupRows]) => ({
model,
rows: groupRows,
}));
}, [rows]);
const toggleModelCollapsed = (model: string) => {
setCollapsedModels((prev) => {
const next = new Set(prev);
if (next.has(model)) next.delete(model);
else next.add(model);
return next;
});
};
const selectedDeviceUuids = useMemo(() => {
return rows
.filter((r) => selectedIds.includes(r.id))
.map((r) => r.device_uuid)
.filter((u): u is string => u != null);
}, [rows, selectedIds]);
const selectedDeviceUuidsAllowed = useMemo(() => {
return rows
.filter(
(r) =>
selectedIds.includes(r.id) &&
r.device_uuid != null &&
!r.snapshot_update_blocked,
)
.map((r) => r.device_uuid as string);
}, [rows, selectedIds]);
const applyMaintenanceMode = async (toggle: PendingModeToggle) => {
setMaintenanceLoadingUuids((prev) => {
const next = new Set(prev);
next.add(toggle.deviceUuid);
return next;
});
try {
await setMaintenanceMode(toggle.deviceUuid, toggle.nextEnabled);
await getVehicles();
await getDevices();
toast.success(
toggle.nextEnabled
? "Устройство отправлено на ТО"
: "Режим ТО отключен",
);
} catch (error) {
console.error(
`Error toggling maintenance mode for ${toggle.deviceUuid}:`,
error,
);
toast.error("Не удалось изменить режим ТО");
} finally {
setMaintenanceLoadingUuids((prev) => {
const next = new Set(prev);
next.delete(toggle.deviceUuid);
return next;
});
}
};
const applyDemoMode = async (toggle: PendingModeToggle) => {
setDemoLoadingUuids((prev) => {
const next = new Set(prev);
next.add(toggle.deviceUuid);
return next;
});
try {
await setDemoMode(toggle.deviceUuid, toggle.nextEnabled);
await getVehicles();
await getDevices();
toast.success(
toggle.nextEnabled ? "Демо-режим включен" : "Демо-режим отключен",
);
} catch (error) {
console.error(
`Error toggling demo mode for ${toggle.deviceUuid}:`,
error,
);
toast.error("Не удалось изменить демо-режим");
} finally {
setDemoLoadingUuids((prev) => {
const next = new Set(prev);
next.delete(toggle.deviceUuid);
return next;
});
}
};
const openMaintenanceConfirm = (row: RowData) => {
if (!row.device_uuid) return;
setMaintenanceConfirm({
deviceUuid: row.device_uuid,
nextEnabled: !row.maintenance_mode_on,
tailNumber: row.tail_number,
});
};
const openDemoConfirm = (row: RowData) => {
if (!row.device_uuid) return;
setDemoConfirm({
deviceUuid: row.device_uuid,
nextEnabled: !row.demo_mode_enabled,
tailNumber: row.tail_number,
});
};
const handleConfirmMaintenanceToggle = async () => {
if (!maintenanceConfirm) return;
setMaintenanceConfirmSubmitting(true);
try {
await applyMaintenanceMode(maintenanceConfirm);
setMaintenanceConfirm(null);
} finally {
setMaintenanceConfirmSubmitting(false);
}
};
const handleConfirmDemoToggle = async () => {
if (!demoConfirm) return;
setDemoConfirmSubmitting(true);
try {
await applyDemoMode(demoConfirm);
setDemoConfirm(null);
} finally {
setDemoConfirmSubmitting(false);
}
};
const columns: GridColDef[] = useMemo(
() => [
{
field: "model",
headerName: "Модель",
flex: 1,
minWidth: 120,
filterable: true,
},
{
field: "tail_number",
headerName: "Бортовой номер",
flex: 1,
minWidth: 90,
filterable: true,
},
{
field: "snapshot_update_blocked",
headerName: "Запрет",
width: 90,
align: "center",
headerAlign: "center",
sortable: false,
disableColumnMenu: true,
renderHeader: (params) => (
<Tooltip title="При активации, на выбранные устройства не будут поступать обновления">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
renderCell: (params: GridRenderCellParams) => {
const rowData = params.row as RowData;
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "100%",
height: "100%",
pointerEvents: "none",
}}
>
<Checkbox
checked={rowData.snapshot_update_blocked}
disabled
size="small"
sx={{ p: 0 }}
/>
</Box>
);
},
},
{
field: "online",
headerName: "Онлайн",
width: menuStore.isOpen ? 90 : undefined,
flex: menuStore.isOpen ? undefined : 1,
align: "center",
headerAlign: "center",
type: "boolean",
filterable: true,
renderHeader: (params) => (
<Tooltip title="Устройство в сети">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
renderCell: (params: GridRenderCellParams) => (
<Box
sx={{ display: "flex", justifyContent: "center", width: "100%" }}
>
{params.value ? (
<Check size={18} className="text-green-600" />
) : (
<X size={18} className="text-red-600" />
)}
</Box>
),
},
{
field: "maintenance_mode_on",
headerName: "Режим ТО",
width: 90,
align: "center",
headerAlign: "center",
type: "boolean",
filterable: true,
renderHeader: (params) => (
<Tooltip title="Режим технического обслуживания экрана для предотвращения возможного наложения изображения">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
renderCell: (params: GridRenderCellParams) => {
const rowData = params.row as RowData;
const isMaintenanceLoading =
!!rowData.device_uuid &&
maintenanceLoadingUuids.has(rowData.device_uuid);
return (
<Box
sx={{ display: "flex", justifyContent: "center", width: "100%" }}
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={rowData.maintenance_mode_on}
disabled={
!rowData.device_uuid ||
isMaintenanceLoading ||
maintenanceConfirmSubmitting
}
size="small"
sx={{ p: 0 }}
onChange={() => openMaintenanceConfirm(rowData)}
/>
</Box>
);
},
},
{
field: "demo_mode_enabled",
headerName: "Режим Демо",
width: 120,
align: "center",
headerAlign: "center",
type: "boolean",
filterable: true,
renderHeader: (params) => (
<Tooltip title="Отправка фиктивных координат на устройство для демонстрации работы">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
renderCell: (params: GridRenderCellParams) => {
const rowData = params.row as RowData;
const isDemoLoading =
!!rowData.device_uuid && demoLoadingUuids.has(rowData.device_uuid);
return (
<Box
sx={{ display: "flex", justifyContent: "center", width: "100%" }}
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={rowData.demo_mode_enabled}
disabled={
!rowData.device_uuid || isDemoLoading || demoConfirmSubmitting
}
size="small"
sx={{ p: 0 }}
onChange={() => openDemoConfirm(rowData)}
/>
</Box>
);
},
},
{
field: "lastUpdate",
headerName: "Дата последнего статуса",
flex: 1,
minWidth: 200,
filterable: true,
renderHeader: (params) => (
<Tooltip title="Дата получения последнего статуса от устройства">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
renderCell: (params: GridRenderCellParams) =>
formatDate(params.value as string | null),
},
{
field: "snapshot_name",
headerName: "Экспорт на устройстве",
flex: menuStore.isOpen ? 1 : 2,
minWidth: 140,
filterable: true,
renderHeader: (params) => (
<Tooltip title="Название загруженного экспорта медиа">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
valueGetter: (_value, row, _apiRef) => {
const rowData = row as RowData;
const uuid = rowData.current_snapshot_uuid;
if (!uuid) return "—";
const snapshot = (snapshots as Snapshot[]).find((s) => s.ID === uuid);
return snapshot?.Name ?? uuid;
},
},
{
field: "current_route",
headerName: "Текущий маршрут",
flex: 1,
minWidth: 140,
filterable: true,
renderHeader: (params) => (
<Tooltip title="Номер и буквенный код отображаемого на экране маршрута">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
valueGetter: (_value, row) => {
const rowData = row as RowData;
const routeId = rowData.current_route_id;
if (!routeId) return "—";
const route = routes.data.find((r) => r.id === routeId);
return route?.route_sys_number || "—";
},
},
{
field: "gps",
headerName: "GPS",
width: menuStore.isOpen ? 70 : undefined,
flex: menuStore.isOpen ? undefined : 1,
align: "center",
headerAlign: "center",
type: "boolean",
filterable: true,
renderHeader: (params) => (
<Tooltip title="Получение GPS координат">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
renderCell: (params: GridRenderCellParams) => (
<Box
sx={{ display: "flex", justifyContent: "center", width: "100%" }}
>
{params.value ? (
<Check size={18} className="text-green-600" />
) : (
<X size={18} className="text-red-600" />
)}
</Box>
),
},
{
field: "actions",
headerName: "Действия",
width: 160,
align: "center",
headerAlign: "center",
sortable: false,
filterable: false,
renderCell: (params: GridRenderCellParams) => {
const row = params.row as RowData;
const handleReloadStatus = async () => {
if (!row.device_uuid) return;
setSelectedDevice(row.device_uuid);
try {
await authInstance.post(
`/devices/${row.device_uuid}/request-status`,
);
await getVehicles();
await getDevices();
toast.success("Статус устройства обновлен");
} catch (error) {
console.error(
`Error requesting status for device ${row.device_uuid}:`,
error,
);
toast.error("Ошибка сервера");
}
};
return (
<Box
sx={{
display: "flex",
gap: "8px",
height: "100%",
alignItems: "center",
justifyContent: "center",
}}
>
{canWriteDevices && (
<button
onClick={(e) => {
e.stopPropagation();
navigate(`/vehicle/${row.vehicle_id}/edit`);
}}
title="Редактировать транспорт"
>
<Pencil size={16} />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
handleReloadStatus();
}}
title="Перезапросить статус"
disabled={
!row.device_uuid || !devices.includes(row.device_uuid)
}
>
<RotateCcw size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (row.device_uuid) {
navigator.clipboard.writeText(row.device_uuid);
toast.success("UUID скопирован");
}
}}
title="Копировать UUID"
>
<Copy size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setSessionsModalVehicleId(row.vehicle_id);
setSessionsModalVehicleTailNumber(row.tail_number);
setSessionsModalOpen(true);
}}
title="Сессии ТО"
>
<Wrench size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (row.device_uuid) {
setLogsModalDeviceUuid(row.device_uuid);
setLogsModalOpen(true);
}
}}
title="Логи устройства"
>
<ScrollText size={16} />
</button>
</Box>
);
},
},
],
[
devices,
getDevices,
getVehicles,
navigate,
setSelectedDevice,
snapshots,
setLogsModalDeviceUuid,
setLogsModalOpen,
maintenanceLoadingUuids,
demoLoadingUuids,
openMaintenanceConfirm,
openDemoConfirm,
maintenanceConfirmSubmitting,
demoConfirmSubmitting,
routes,
canWriteDevices,
],
);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
await Promise.all([
getVehicles(),
getDevices(),
getSnapshots(),
getRoutes(),
]);
setIsLoading(false);
};
fetchData();
}, [getDevices, getSnapshots, getVehicles, getRoutes]);
useEffect(() => {
carrierStore.getCarriers("ru");
}, []);
const handleOpenSendSnapshotModal = () => {
if (!canWriteDevices) {
return;
}
if (selectedDeviceUuidsAllowed.length > 0) {
toggleSendSnapshotModal();
}
};
const handleSendSnapshotAction = async (snapshotId: string) => {
if (!canWriteDevices) return;
if (selectedDeviceUuidsAllowed.length === 0) return;
const blockedCount =
selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length;
if (blockedCount > 0) {
toast.info(
`Экспорт медиа не отправлен на ${blockedCount} устройств (блокировка)`,
);
}
const send = async (deviceUuid: string) => {
try {
await authInstance.post(
`/devices/${deviceUuid}/force-snapshot-update`,
{ snapshot_id: snapshotId },
);
toast.success("Экспорт медиа отправлен на устройство");
} catch (error) {
console.error(`Error sending snapshot to device ${deviceUuid}:`, error);
toast.error("Не удалось отправить экспорт медиа на устройство");
}
};
await Promise.allSettled(selectedDeviceUuidsAllowed.map(send));
await getDevices();
setSelectedIds([]);
toggleSendSnapshotModal();
};
const handleDeleteVehicles = async () => {
if (selectedIds.length === 0) return;
try {
await Promise.all(selectedIds.map((id) => deleteVehicle(id)));
await getVehicles();
await getDevices();
setSelectedIds([]);
setIsDeleteModalOpen(false);
toast.success(`Удалено устройств: ${selectedIds.length}`);
} catch (error) {
console.error("Error deleting vehicles:", error);
toast.error("Ошибка при удалении устройств");
}
};
const createSelectionHandler = (groupRowIds: number[]) => {
const groupIdSet = new Set(groupRowIds);
return (newSelection: { ids: Set<number | string> } | number[]) => {
let newIds: number[] = [];
if (Array.isArray(newSelection)) {
newIds = newSelection.map((id) => Number(id));
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<number | string>;
newIds = Array.from(idsSet)
.map((id) => (typeof id === "string" ? Number.parseInt(id, 10) : id))
.filter((id) => !Number.isNaN(id));
}
setSelectedIds((prev) => {
const fromOtherGroups = prev.filter((id) => !groupIdSet.has(id));
return [...fromOtherGroups, ...newIds];
});
};
};
return (
<>
<div className="w-full">
<div className="flex justify-end mb-5 gap-2 flex-wrap">
{canWriteDevices && (
<Button
variant="contained"
color="primary"
size="small"
onClick={() => navigate("/vehicle/create")}
>
Добавить устройство
</Button>
)}
{canWriteDevices && selectedIds.length > 0 && (
<Button
variant="contained"
color="error"
onClick={() => setIsDeleteModalOpen(true)}
size="small"
startIcon={<Trash2 size={16} />}
>
Удалить ({selectedIds.length})
</Button>
)}
{canWriteDevices && (
<Button
variant="contained"
color="primary"
disabled={selectedDeviceUuidsAllowed.length === 0}
onClick={handleOpenSendSnapshotModal}
size="small"
>
Экспорт медиа ({selectedDeviceUuidsAllowed.length}
{selectedDeviceUuids.length !==
selectedDeviceUuidsAllowed.length &&
`/${selectedDeviceUuids.length}`}
)
</Button>
)}
</div>
{groupsByModel.length === 0 ? (
<Box
sx={{
mt: 5,
py: 4,
textAlign: "center",
color: "text.secondary",
}}
>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет устройств для отображения"
)}
</Box>
) : (
<div className="flex flex-col gap-6 mt-4">
{groupsByModel.map(({ model: groupModel, rows: groupRows }) => {
const isCollapsed = collapsedModels.has(groupModel);
const groupRowIds = groupRows.map((r) => r.id);
const selectedInGroup = selectedIds.filter((id) =>
groupRowIds.includes(id),
);
return (
<div
key={groupModel}
className="border border-gray-200 rounded-lg overflow-hidden"
>
<button
type="button"
onClick={() => toggleModelCollapsed(groupModel)}
className="w-full flex items-center gap-2 px-4 py-3 bg-gray-50 hover:bg-gray-100 text-left border-b border-gray-200"
>
{isCollapsed ? (
<ChevronRight size={20} className="text-gray-600" />
) : (
<ChevronDown size={20} className="text-gray-600" />
)}
<Typography variant="h6" component="span" fontWeight={600}>
{groupModel}
</Typography>
<Typography variant="body2" color="textSecondary">
({groupRows.length}{" "}
{groupRows.length === 1 ? "устройство" : "устройств"})
</Typography>
</button>
{!isCollapsed && (
<Box sx={{ p: 0 }}>
<DataGrid
rows={groupRows}
columns={columns}
checkboxSelection={canWriteDevices}
disableRowSelectionExcludeModel
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
onRowSelectionModelChange={
canWriteDevices
? (createSelectionHandler(groupRowIds) as (
ids: unknown,
) => void)
: undefined
}
rowSelectionModel={{
type: "include",
ids: new Set(selectedInGroup),
}}
localeText={
ruRU.components.MuiDataGrid.defaultProps.localeText
}
autoHeight
slots={{
noRowsOverlay: () => null,
}}
sx={{
border: "none",
"& .MuiDataGrid-columnHeaders": {
borderBottom: 1,
borderColor: "divider",
},
"& .MuiDataGrid-cell": {
borderBottom: 1,
borderColor: "divider",
},
}}
/>
</Box>
)}
</div>
);
})}
</div>
)}
</div>
{canWriteDevices && (
<Modal
open={sendSnapshotModalOpen}
onClose={toggleSendSnapshotModal}
sx={{ width: "min(760px, 94vw)", p: 3 }}
>
<Box component="h2" sx={{ mb: 1, typography: "h6" }}>
Экспорт медиа
</Box>
<Box sx={{ mb: 2 }}>
Выбрано устройств для обновления:{" "}
<strong className="text-blue-600">
{selectedDeviceUuidsAllowed.length}
</strong>
{selectedDeviceUuids.length !==
selectedDeviceUuidsAllowed.length && (
<span className="text-amber-600 ml-1">
(пропущено{" "}
{selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length}{" "}
с блокировкой)
</span>
)}
</Box>
<div className="mt-2 flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2">
{snapshots && (snapshots as Snapshot[]).length > 0 ? (
(snapshots as Snapshot[]).map((snapshot) => (
<Button
variant="outlined"
fullWidth
onClick={() => handleSendSnapshotAction(snapshot.ID)}
key={snapshot.ID}
sx={{ justifyContent: "flex-start" }}
>
{snapshot.Name}
</Button>
))
) : (
<Box sx={{ typography: "body2", color: "text.secondary" }}>
Нет доступных экспортов медиа.
</Box>
)}
</div>
<Button
onClick={toggleSendSnapshotModal}
color="inherit"
variant="outlined"
sx={{ mt: 3 }}
fullWidth
>
Отмена
</Button>
</Modal>
)}
<DeleteModal
open={isDeleteModalOpen}
onDelete={handleDeleteVehicles}
onCancel={() => setIsDeleteModalOpen(false)}
/>
<Modal
open={maintenanceConfirm != null}
onClose={() => {
if (!maintenanceConfirmSubmitting) setMaintenanceConfirm(null);
}}
sx={{ width: "min(640px, 92vw)", p: 3 }}
>
<Box component="h2" sx={{ mb: 1, typography: "h6" }}>
Подтверждение режима ТО
</Box>
<Box sx={{ mb: 3 }}>
{maintenanceConfirm?.nextEnabled
? `Включить режим ТО для устройства ${maintenanceConfirm.tailNumber}?`
: `Отключить режим ТО для устройства ${maintenanceConfirm?.tailNumber}?`}
</Box>
<Box sx={{ display: "flex", gap: 1.5 }}>
<Button
fullWidth
variant="outlined"
color="inherit"
disabled={maintenanceConfirmSubmitting}
onClick={() => setMaintenanceConfirm(null)}
>
Отмена
</Button>
<Button
fullWidth
variant="contained"
disabled={maintenanceConfirmSubmitting}
onClick={handleConfirmMaintenanceToggle}
>
Подтвердить
</Button>
</Box>
</Modal>
<Modal
open={demoConfirm != null}
onClose={() => {
if (!demoConfirmSubmitting) setDemoConfirm(null);
}}
sx={{ width: "min(640px, 92vw)", p: 3 }}
>
<Box component="h2" sx={{ mb: 1, typography: "h6" }}>
Подтверждение демо-режима
</Box>
<Box sx={{ mb: 3 }}>
{demoConfirm?.nextEnabled
? `Включить демо-режим для устройства ${demoConfirm.tailNumber}?`
: `Отключить демо-режим для устройства ${demoConfirm?.tailNumber}?`}
</Box>
<Box sx={{ display: "flex", gap: 1.5 }}>
<Button
fullWidth
variant="outlined"
color="inherit"
disabled={demoConfirmSubmitting}
onClick={() => setDemoConfirm(null)}
>
Отмена
</Button>
<Button
fullWidth
variant="contained"
disabled={demoConfirmSubmitting}
onClick={handleConfirmDemoToggle}
>
Подтвердить
</Button>
</Box>
</Modal>
<DeviceLogsModal
open={logsModalOpen}
deviceUuid={logsModalDeviceUuid}
onClose={() => {
setLogsModalOpen(false);
setLogsModalDeviceUuid(null);
}}
/>
<VehicleSessionsModal
open={sessionsModalOpen}
vehicleId={sessionsModalVehicleId}
tailNumber={sessionsModalVehicleTailNumber}
onClose={() => {
setSessionsModalOpen(false);
setSessionsModalVehicleId(null);
setSessionsModalVehicleTailNumber(null);
}}
/>
</>
);
});