1103 lines
34 KiB
TypeScript
1103 lines
34 KiB
TypeScript
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);
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
});
|