feat: role system

This commit is contained in:
2026-03-18 20:11:07 +03:00
parent 73070fe233
commit c3127b8d47
47 changed files with 2425 additions and 768 deletions

View File

@@ -1,11 +1,13 @@
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,
@@ -22,6 +24,7 @@ import {
RotateCcw,
ScrollText,
Trash2,
Wrench,
X,
} from "lucide-react";
import {
@@ -35,6 +38,7 @@ 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;
@@ -77,6 +81,13 @@ type RowData = {
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 {
@@ -109,11 +120,13 @@ const transformToRows = (vehicles: Vehicle[]): RowData[] => {
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,
@@ -123,6 +136,7 @@ export const DevicesTable = observer(() => {
} = devicesStore;
const { snapshots, getSnapshots } = snapshotStore;
const { routes, getRoutes } = routeStore;
const {
getVehicles,
vehicles,
@@ -137,6 +151,12 @@ export const DevicesTable = observer(() => {
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>
@@ -144,6 +164,14 @@ export const DevicesTable = observer(() => {
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(),
);
@@ -223,65 +251,108 @@ export const DevicesTable = observer(() => {
.map((r) => r.device_uuid as string);
}, [rows, selectedIds]);
const handleToggleMaintenanceMode = async (row: RowData) => {
if (!row.device_uuid) return;
const nextEnabled = !row.maintenance_mode_on;
const applyMaintenanceMode = async (toggle: PendingModeToggle) => {
setMaintenanceLoadingUuids((prev) => {
const next = new Set(prev);
next.add(row.device_uuid!);
next.add(toggle.deviceUuid);
return next;
});
try {
await setMaintenanceMode(row.device_uuid, nextEnabled);
await setMaintenanceMode(toggle.deviceUuid, toggle.nextEnabled);
await getVehicles();
await getDevices();
toast.success(
nextEnabled ? "Устройство отправлено на ТО" : "Режим ТО отключен",
toggle.nextEnabled
? "Устройство отправлено на ТО"
: "Режим ТО отключен",
);
} catch (error) {
console.error(
`Error toggling maintenance mode for ${row.device_uuid}:`,
`Error toggling maintenance mode for ${toggle.deviceUuid}:`,
error,
);
toast.error("Не удалось изменить режим ТО");
} finally {
setMaintenanceLoadingUuids((prev) => {
const next = new Set(prev);
next.delete(row.device_uuid!);
next.delete(toggle.deviceUuid);
return next;
});
}
};
const handleToggleDemoMode = async (row: RowData) => {
if (!row.device_uuid) return;
const nextEnabled = !row.demo_mode_enabled;
const applyDemoMode = async (toggle: PendingModeToggle) => {
setDemoLoadingUuids((prev) => {
const next = new Set(prev);
next.add(row.device_uuid!);
next.add(toggle.deviceUuid);
return next;
});
try {
await setDemoMode(row.device_uuid, nextEnabled);
await setDemoMode(toggle.deviceUuid, toggle.nextEnabled);
await getVehicles();
await getDevices();
toast.success(nextEnabled ? "Демо-режим включен" : "Демо-режим отключен");
toast.success(
toggle.nextEnabled ? "Демо-режим включен" : "Демо-режим отключен",
);
} catch (error) {
console.error(`Error toggling demo mode for ${row.device_uuid}:`, error);
console.error(
`Error toggling demo mode for ${toggle.deviceUuid}:`,
error,
);
toast.error("Не удалось изменить демо-режим");
} finally {
setDemoLoadingUuids((prev) => {
const next = new Set(prev);
next.delete(row.device_uuid!);
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(
() => [
{
@@ -375,10 +446,14 @@ export const DevicesTable = observer(() => {
>
<Checkbox
checked={rowData.maintenance_mode_on}
disabled={!rowData.device_uuid || isMaintenanceLoading}
disabled={
!rowData.device_uuid ||
isMaintenanceLoading ||
maintenanceConfirmSubmitting
}
size="small"
sx={{ p: 0 }}
onChange={() => handleToggleMaintenanceMode(rowData)}
onChange={() => openMaintenanceConfirm(rowData)}
/>
</Box>
);
@@ -404,10 +479,12 @@ export const DevicesTable = observer(() => {
>
<Checkbox
checked={rowData.demo_mode_enabled}
disabled={!rowData.device_uuid || isDemoLoading}
disabled={
!rowData.device_uuid || isDemoLoading || demoConfirmSubmitting
}
size="small"
sx={{ p: 0 }}
onChange={() => handleToggleDemoMode(rowData)}
onChange={() => openDemoConfirm(rowData)}
/>
</Box>
);
@@ -436,6 +513,20 @@ export const DevicesTable = observer(() => {
return snapshot?.Name ?? uuid;
},
},
{
field: "current_route",
headerName: "Текущий маршрут",
flex: 1,
minWidth: 140,
filterable: true,
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_number || "—";
},
},
{
field: "gps",
headerName: "GPS",
@@ -496,15 +587,17 @@ export const DevicesTable = observer(() => {
justifyContent: "center",
}}
>
<button
onClick={(e) => {
e.stopPropagation();
navigate(`/vehicle/${row.vehicle_id}/edit`);
}}
title="Редактировать транспорт"
>
<Pencil size={16} />
</button>
{canWriteDevices && (
<button
onClick={(e) => {
e.stopPropagation();
navigate(`/vehicle/${row.vehicle_id}/edit`);
}}
title="Редактировать транспорт"
>
<Pencil size={16} />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
@@ -529,6 +622,17 @@ export const DevicesTable = observer(() => {
>
<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();
@@ -557,35 +661,44 @@ export const DevicesTable = observer(() => {
setLogsModalOpen,
maintenanceLoadingUuids,
demoLoadingUuids,
setMaintenanceMode,
setDemoMode,
handleToggleMaintenanceMode,
handleToggleDemoMode,
openMaintenanceConfirm,
openDemoConfirm,
maintenanceConfirmSubmitting,
demoConfirmSubmitting,
routes,
canWriteDevices,
],
);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
await getVehicles();
await getDevices();
await getSnapshots();
await Promise.all([
getVehicles(),
getDevices(),
getSnapshots(),
getRoutes(),
]);
setIsLoading(false);
};
fetchData();
}, [getDevices, getSnapshots, getVehicles]);
}, [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 =
@@ -658,14 +771,16 @@ export const DevicesTable = observer(() => {
<>
<div className="w-full">
<div className="flex justify-end mb-5 gap-2 flex-wrap">
<Button
variant="contained"
color="primary"
size="small"
onClick={() => navigate("/vehicle/create")}
>
Добавить устройство
</Button>
{canWriteDevices && (
<Button
variant="contained"
color="primary"
size="small"
onClick={() => navigate("/vehicle/create")}
>
Добавить устройство
</Button>
)}
{selectedIds.length > 0 && (
<Button
variant="contained"
@@ -677,18 +792,21 @@ export const DevicesTable = observer(() => {
Удалить ({selectedIds.length})
</Button>
)}
<Button
variant="contained"
color="primary"
disabled={selectedDeviceUuidsAllowed.length === 0}
onClick={handleOpenSendSnapshotModal}
size="small"
>
Обновление ПО ({selectedDeviceUuidsAllowed.length}
{selectedDeviceUuids.length !== selectedDeviceUuidsAllowed.length &&
`/${selectedDeviceUuids.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 ? (
@@ -787,52 +905,59 @@ export const DevicesTable = observer(() => {
)}
</div>
<Modal open={sendSnapshotModalOpen} onClose={toggleSendSnapshotModal}>
<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
{canWriteDevices && (
<Modal
open={sendSnapshotModalOpen}
onClose={toggleSendSnapshotModal}
sx={{ width: "min(760px, 94vw)", p: 3 }}
>
Отмена
</Button>
</Modal>
<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}
@@ -840,6 +965,78 @@ export const DevicesTable = observer(() => {
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}
@@ -848,6 +1045,17 @@ export const DevicesTable = observer(() => {
setLogsModalDeviceUuid(null);
}}
/>
<VehicleSessionsModal
open={sessionsModalOpen}
vehicleId={sessionsModalVehicleId}
tailNumber={sessionsModalVehicleTailNumber}
onClose={() => {
setSessionsModalOpen(false);
setSessionsModalVehicleId(null);
setSessionsModalVehicleTailNumber(null);
}}
/>
</>
);
});