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([]); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [logsModalOpen, setLogsModalOpen] = useState(false); const [logsModalDeviceUuid, setLogsModalDeviceUuid] = useState( null, ); const [sessionsModalOpen, setSessionsModalOpen] = useState(false); const [sessionsModalVehicleId, setSessionsModalVehicleId] = useState< number | null >(null); const [sessionsModalVehicleTailNumber, setSessionsModalVehicleTailNumber] = useState(null); const [isLoading, setIsLoading] = useState(false); const [maintenanceLoadingUuids, setMaintenanceLoadingUuids] = useState< Set >(new Set()); const [demoLoadingUuids, setDemoLoadingUuids] = useState>( new Set(), ); const [maintenanceConfirm, setMaintenanceConfirm] = useState(null); const [demoConfirm, setDemoConfirm] = useState( null, ); const [maintenanceConfirmSubmitting, setMaintenanceConfirmSubmitting] = useState(false); const [demoConfirmSubmitting, setDemoConfirmSubmitting] = useState(false); const [collapsedModels, setCollapsedModels] = useState>( 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(); 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) => ( {params.colDef.headerName} ), renderCell: (params: GridRenderCellParams) => { const rowData = params.row as RowData; return ( ); }, }, { field: "online", headerName: "Онлайн", width: menuStore.isOpen ? 90 : undefined, flex: menuStore.isOpen ? undefined : 1, align: "center", headerAlign: "center", type: "boolean", filterable: true, renderHeader: (params) => ( {params.colDef.headerName} ), renderCell: (params: GridRenderCellParams) => ( {params.value ? ( ) : ( )} ), }, { field: "maintenance_mode_on", headerName: "Режим ТО", width: 90, align: "center", headerAlign: "center", type: "boolean", filterable: true, renderHeader: (params) => ( {params.colDef.headerName} ), renderCell: (params: GridRenderCellParams) => { const rowData = params.row as RowData; const isMaintenanceLoading = !!rowData.device_uuid && maintenanceLoadingUuids.has(rowData.device_uuid); return ( e.stopPropagation()} > openMaintenanceConfirm(rowData)} /> ); }, }, { field: "demo_mode_enabled", headerName: "Режим Демо", width: 120, align: "center", headerAlign: "center", type: "boolean", filterable: true, renderHeader: (params) => ( {params.colDef.headerName} ), renderCell: (params: GridRenderCellParams) => { const rowData = params.row as RowData; const isDemoLoading = !!rowData.device_uuid && demoLoadingUuids.has(rowData.device_uuid); return ( e.stopPropagation()} > openDemoConfirm(rowData)} /> ); }, }, { field: "lastUpdate", headerName: "Дата последнего статуса", flex: 1, minWidth: 200, filterable: true, renderHeader: (params) => ( {params.colDef.headerName} ), renderCell: (params: GridRenderCellParams) => formatDate(params.value as string | null), }, { field: "snapshot_name", headerName: "Экспорт на устройстве", flex: menuStore.isOpen ? 1 : 2, minWidth: 140, filterable: true, renderHeader: (params) => ( {params.colDef.headerName} ), 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) => ( {params.colDef.headerName} ), 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) => ( {params.colDef.headerName} ), renderCell: (params: GridRenderCellParams) => ( {params.value ? ( ) : ( )} ), }, { 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 ( {canWriteDevices && ( )} ); }, }, ], [ 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[]) => { 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; 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 ( <>
{canWriteDevices && ( )} {canWriteDevices && selectedIds.length > 0 && ( )} {canWriteDevices && ( )}
{groupsByModel.length === 0 ? ( {isLoading ? ( ) : ( "Нет устройств для отображения" )} ) : (
{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 (
{!isCollapsed && ( 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", }, }} /> )}
); })}
)}
{canWriteDevices && ( Экспорт медиа Выбрано устройств для обновления:{" "} {selectedDeviceUuidsAllowed.length} {selectedDeviceUuids.length !== selectedDeviceUuidsAllowed.length && ( (пропущено{" "} {selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length}{" "} с блокировкой) )}
{snapshots && (snapshots as Snapshot[]).length > 0 ? ( (snapshots as Snapshot[]).map((snapshot) => ( )) ) : ( Нет доступных экспортов медиа. )}
)} setIsDeleteModalOpen(false)} /> { if (!maintenanceConfirmSubmitting) setMaintenanceConfirm(null); }} sx={{ width: "min(640px, 92vw)", p: 3 }} > Подтверждение режима ТО {maintenanceConfirm?.nextEnabled ? `Включить режим ТО для устройства ${maintenanceConfirm.tailNumber}?` : `Отключить режим ТО для устройства ${maintenanceConfirm?.tailNumber}?`} { if (!demoConfirmSubmitting) setDemoConfirm(null); }} sx={{ width: "min(640px, 92vw)", p: 3 }} > Подтверждение демо-режима {demoConfirm?.nextEnabled ? `Включить демо-режим для устройства ${demoConfirm.tailNumber}?` : `Отключить демо-режим для устройства ${demoConfirm?.tailNumber}?`} { setLogsModalOpen(false); setLogsModalDeviceUuid(null); }} /> { setSessionsModalOpen(false); setSessionsModalVehicleId(null); setSessionsModalVehicleTailNumber(null); }} /> ); });