feat: role system
This commit is contained in:
@@ -8,16 +8,40 @@ import {
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { cityStore, selectedCityStore } from "@shared";
|
||||
import { authStore, cityStore, selectedCityStore, type City } from "@shared";
|
||||
import { MapPin } from "lucide-react";
|
||||
|
||||
export const CitySelector: React.FC = observer(() => {
|
||||
const { getCities, cities } = cityStore;
|
||||
const { selectedCity, setSelectedCity } = selectedCityStore;
|
||||
const canReadCities = authStore.canRead("cities");
|
||||
|
||||
useEffect(() => {
|
||||
getCities("ru");
|
||||
}, []);
|
||||
if (canReadCities) {
|
||||
cityStore.getCities("ru");
|
||||
return;
|
||||
}
|
||||
authStore.fetchMeCities().catch(() => undefined);
|
||||
}, [canReadCities]);
|
||||
|
||||
const baseCities: City[] = canReadCities
|
||||
? cityStore.cities["ru"].data
|
||||
: authStore.meCities["ru"].map((uc) => ({
|
||||
id: uc.city_id,
|
||||
name: uc.name,
|
||||
country: "",
|
||||
country_code: "",
|
||||
arms: "",
|
||||
}));
|
||||
|
||||
const currentCities: City[] = selectedCity?.id
|
||||
? (() => {
|
||||
const exists = baseCities.some((city) => city.id === selectedCity.id);
|
||||
if (exists) {
|
||||
return baseCities;
|
||||
}
|
||||
return [selectedCity, ...baseCities];
|
||||
})()
|
||||
: baseCities;
|
||||
|
||||
const handleCityChange = (event: SelectChangeEvent<string>) => {
|
||||
const cityId = event.target.value;
|
||||
@@ -26,14 +50,12 @@ export const CitySelector: React.FC = observer(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
const city = cities["ru"].data.find((c) => c.id === Number(cityId));
|
||||
const city = currentCities.find((c) => c.id === Number(cityId));
|
||||
if (city) {
|
||||
setSelectedCity(city);
|
||||
}
|
||||
};
|
||||
|
||||
const currentCities = cities["ru"].data;
|
||||
|
||||
return (
|
||||
<Box className="flex items-center gap-2">
|
||||
<MapPin size={16} className="text-white" />
|
||||
@@ -51,16 +73,13 @@ export const CitySelector: React.FC = observer(() => {
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
|
||||
"&.Mui.focused .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "white",
|
||||
},
|
||||
"& .MuiSvgIcon-root": {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
MenuProps={{
|
||||
PaperProps: {},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<Typography variant="body2">Выберите город</Typography>
|
||||
|
||||
180
src/widgets/DevicesTable/VehicleSessionsModal.tsx
Normal file
180
src/widgets/DevicesTable/VehicleSessionsModal.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Modal, vehicleStore } from "@shared";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface VehicleSessionsModalProps {
|
||||
open: boolean;
|
||||
vehicleId: number | null;
|
||||
tailNumber?: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const formatDateTime = (value: string) => {
|
||||
if (!value) return "-";
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const formatDuration = (durationSeconds: number) => {
|
||||
if (!Number.isFinite(durationSeconds) || durationSeconds < 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const hours = Math.floor(durationSeconds / 3600);
|
||||
const minutes = Math.floor((durationSeconds % 3600) / 60);
|
||||
const seconds = durationSeconds % 60;
|
||||
|
||||
return [hours, minutes, seconds]
|
||||
.map((part) => String(part).padStart(2, "0"))
|
||||
.join(":");
|
||||
};
|
||||
|
||||
export const VehicleSessionsModal = observer(
|
||||
({ open, vehicleId, tailNumber, onClose }: VehicleSessionsModalProps) => {
|
||||
const {
|
||||
vehicleSessions,
|
||||
vehicleSessionsLoading,
|
||||
vehicleSessionsError,
|
||||
getVehicleSessions,
|
||||
} = vehicleStore;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || vehicleId == null) return;
|
||||
|
||||
getVehicleSessions(vehicleId).catch(() => undefined);
|
||||
}, [open, vehicleId, getVehicleSessions]);
|
||||
|
||||
const title =
|
||||
tailNumber && tailNumber !== ""
|
||||
? `Сессии ТО: ${tailNumber}`
|
||||
: "Сессии ТО";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
sx={{ width: "min(1080px, 95vw)", p: 3 }}
|
||||
>
|
||||
<div className="flex flex-col gap-4 max-h-[82vh]">
|
||||
<Typography variant="h6" component="h2">
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ minHeight: 220 }}>
|
||||
{vehicleSessionsLoading && (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: 220,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!vehicleSessionsLoading && vehicleSessionsError && (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: 220,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "error.main",
|
||||
}}
|
||||
>
|
||||
{vehicleSessionsError}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!vehicleSessionsLoading &&
|
||||
!vehicleSessionsError &&
|
||||
vehicleSessions &&
|
||||
vehicleSessions.length === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: 220,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
По этому транспорту нет сессий ТО.
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!vehicleSessionsLoading &&
|
||||
!vehicleSessionsError &&
|
||||
vehicleSessions &&
|
||||
vehicleSessions.length > 0 && (
|
||||
<TableContainer
|
||||
sx={{ maxHeight: "60vh", border: 1, borderColor: "divider" }}
|
||||
>
|
||||
<Table stickyHeader size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Начало</TableCell>
|
||||
<TableCell>Окончание</TableCell>
|
||||
<TableCell align="right">Длительность</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{vehicleSessions.map((session) => (
|
||||
<TableRow key={session.id} hover>
|
||||
<TableCell>{session.id}</TableCell>
|
||||
<TableCell>
|
||||
{formatDateTime(session.started_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDateTime(session.ended_at)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{formatDuration(session.duration_seconds)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={onClose}
|
||||
fullWidth
|
||||
>
|
||||
Закрыть
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AppBar } from "./ui/AppBar";
|
||||
import { Drawer } from "./ui/Drawer";
|
||||
import { DrawerHeader } from "./ui/DrawerHeader";
|
||||
import { NavigationList } from "@features";
|
||||
import { authStore, userStore, menuStore, isMediaIdEmpty } from "@shared";
|
||||
import { authStore, menuStore, isMediaIdEmpty } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect } from "react";
|
||||
import { Typography } from "@mui/material";
|
||||
@@ -27,13 +27,10 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
setIsMenuOpen(open);
|
||||
}, [open]);
|
||||
|
||||
const { getUsers, users } = userStore;
|
||||
const { getMeAction, me } = authStore;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
await getUsers();
|
||||
};
|
||||
fetchUsers();
|
||||
getMeAction();
|
||||
}, []);
|
||||
|
||||
const handleDrawerOpen = () => {
|
||||
@@ -68,17 +65,13 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
{(() => {
|
||||
const currentUser = users?.data?.find(
|
||||
(user) => user.id === (authStore.payload as { user_id?: number })?.user_id,
|
||||
);
|
||||
const hasAvatar =
|
||||
currentUser?.icon && !isMediaIdEmpty(currentUser.icon);
|
||||
const hasAvatar = me?.icon && !isMediaIdEmpty(me.icon);
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-white">{currentUser?.name}</p>
|
||||
<p className="text-white">{me?.name}</p>
|
||||
<div
|
||||
className="text-center text-xs"
|
||||
style={{
|
||||
@@ -88,7 +81,7 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
padding: "2px 10px",
|
||||
}}
|
||||
>
|
||||
{(authStore.payload as { is_admin?: boolean })?.is_admin
|
||||
{me?.roles?.includes("admin")
|
||||
? "Администратор"
|
||||
: "Режим пользователя"}
|
||||
</div>
|
||||
@@ -98,7 +91,7 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
<img
|
||||
src={`${
|
||||
import.meta.env.VITE_KRBL_MEDIA
|
||||
}${currentUser!.icon}/download?token=${token}`}
|
||||
}${me?.icon}/download?token=${token}`}
|
||||
alt="Аватар"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
BackButton,
|
||||
TabPanel,
|
||||
languageStore,
|
||||
authStore,
|
||||
Language,
|
||||
cityStore,
|
||||
isMediaIdEmpty,
|
||||
@@ -40,7 +41,7 @@ import { SaveWithoutCityAgree } from "@widgets";
|
||||
|
||||
export const CreateInformationTab = observer(
|
||||
({ value, index }: { value: number; index: number }) => {
|
||||
const { cities } = cityStore;
|
||||
const canReadCities = authStore.canRead("cities");
|
||||
const [mediaId, setMediaId] = useState<string>("");
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
@@ -64,6 +65,30 @@ export const CreateInformationTab = observer(
|
||||
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
|
||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||
|
||||
const baseCities = canReadCities
|
||||
? cityStore.cities["ru"]?.data ?? []
|
||||
: authStore.meCities["ru"].map((city) => ({
|
||||
id: city.city_id,
|
||||
name: city.name,
|
||||
country: "",
|
||||
country_code: "",
|
||||
arms: "",
|
||||
}));
|
||||
|
||||
const availableCities =
|
||||
sight.city_id && !baseCities.some((city) => city.id === sight.city_id)
|
||||
? [
|
||||
{
|
||||
id: sight.city_id,
|
||||
name: sight.city || `Город ${sight.city_id}`,
|
||||
country: "",
|
||||
country_code: "",
|
||||
arms: "",
|
||||
},
|
||||
...baseCities,
|
||||
]
|
||||
: baseCities;
|
||||
|
||||
useEffect(() => {}, [hardcodeType]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -208,17 +233,16 @@ export const CreateInformationTab = observer(
|
||||
/>
|
||||
|
||||
<Autocomplete
|
||||
options={cities["ru"]?.data ?? []}
|
||||
options={availableCities}
|
||||
value={
|
||||
cities["ru"]?.data?.find(
|
||||
(city) => city.id === sight.city_id
|
||||
) ?? null
|
||||
availableCities.find((city) => city.id === sight.city_id) ?? null
|
||||
}
|
||||
getOptionLabel={(option) => option.name}
|
||||
onChange={(_, value) => {
|
||||
setCity(value?.id ?? 0);
|
||||
handleChange({
|
||||
city_id: value?.id ?? 0,
|
||||
city: value?.name ?? "",
|
||||
});
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
BackButton,
|
||||
TabPanel,
|
||||
languageStore,
|
||||
authStore,
|
||||
Language,
|
||||
cityStore,
|
||||
editSightStore,
|
||||
@@ -62,10 +63,35 @@ export const InformationTab = observer(
|
||||
const [hardcodeType, setHardcodeType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
|
||||
>(null);
|
||||
const { cities } = cityStore;
|
||||
const canReadCities = authStore.canRead("cities");
|
||||
|
||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||
|
||||
const baseCities = canReadCities
|
||||
? cityStore.cities["ru"]?.data ?? []
|
||||
: authStore.meCities["ru"].map((city) => ({
|
||||
id: city.city_id,
|
||||
name: city.name,
|
||||
country: "",
|
||||
country_code: "",
|
||||
arms: "",
|
||||
}));
|
||||
|
||||
const availableCities =
|
||||
sight.common.city_id &&
|
||||
!baseCities.some((city) => city.id === sight.common.city_id)
|
||||
? [
|
||||
{
|
||||
id: sight.common.city_id,
|
||||
name: sight.common.city || `Город ${sight.common.city_id}`,
|
||||
country: "",
|
||||
country_code: "",
|
||||
arms: "",
|
||||
},
|
||||
...baseCities,
|
||||
]
|
||||
: baseCities;
|
||||
|
||||
useEffect(() => {}, [hardcodeType]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -208,11 +234,9 @@ export const InformationTab = observer(
|
||||
/>
|
||||
|
||||
<Autocomplete
|
||||
options={cities["ru"]?.data ?? []}
|
||||
options={availableCities}
|
||||
value={
|
||||
cities["ru"]?.data?.find(
|
||||
(city) => city.id === sight.common.city_id
|
||||
) ?? null
|
||||
availableCities.find((city) => city.id === sight.common.city_id) ?? null
|
||||
}
|
||||
getOptionLabel={(option) => option.name}
|
||||
onChange={(_, value) => {
|
||||
@@ -221,6 +245,7 @@ export const InformationTab = observer(
|
||||
language as Language,
|
||||
{
|
||||
city_id: value?.id ?? 0,
|
||||
city: value?.name ?? "",
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user