This commit is contained in:
2026-02-28 20:00:21 +03:00
parent 7cf188a55c
commit 73070fe233
3 changed files with 281 additions and 59 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "white-nights", "name": "white-nights",
"private": true, "private": true,
"version": "1.0.2", "version": "1.0.3",
"type": "module", "type": "module",
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {

View File

@@ -1,4 +1,4 @@
import { languageInstance } from "@shared"; import { authInstance, languageInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
export type Vehicle = { export type Vehicle = {
@@ -12,6 +12,9 @@ export type Vehicle = {
model?: string; model?: string;
current_snapshot_uuid?: string; current_snapshot_uuid?: string;
snapshot_update_blocked?: boolean; snapshot_update_blocked?: boolean;
demo_mode_enabled?: boolean;
maintenance_mode_on?: boolean;
city_id?: number;
}; };
device_status?: { device_status?: {
device_uuid: string; device_uuid: string;
@@ -37,11 +40,75 @@ class VehicleStore {
makeAutoObservable(this); makeAutoObservable(this);
} }
private normalizeVehicleItem = (item: any): Vehicle => {
if (item && typeof item === "object" && "vehicle" in item) {
return {
vehicle: item.vehicle ?? {},
device_status: item.device_status,
} as Vehicle;
}
return {
vehicle: item ?? {},
} as Vehicle;
};
private mergeVehicleInCaches = (updatedVehicle: any) => {
if (!updatedVehicle) return;
const updatedId = updatedVehicle.id;
const updatedUuid = updatedVehicle.uuid;
const mergeItem = (item: Vehicle): Vehicle => ({
...item,
vehicle: {
...item.vehicle,
...updatedVehicle,
},
});
this.vehicles.data = this.vehicles.data.map((item) => {
const sameId = updatedId != null && item.vehicle.id === updatedId;
const sameUuid =
updatedUuid != null &&
item.vehicle.uuid != null &&
item.vehicle.uuid === updatedUuid;
if (!sameId && !sameUuid) return item;
return mergeItem(item);
});
if (updatedId != null) {
const existing = this.vehicle[updatedId];
this.vehicle[updatedId] = existing
? mergeItem(existing)
: ({ vehicle: updatedVehicle } as Vehicle);
return;
}
if (updatedUuid != null) {
const entry = Object.entries(this.vehicle).find(
([, item]) => item.vehicle.uuid === updatedUuid
);
if (entry) {
const [key, item] = entry;
this.vehicle[key] = mergeItem(item);
}
}
};
getVehicles = async () => { getVehicles = async () => {
const response = await languageInstance("ru").get(`/vehicle`); const response = await languageInstance("ru").get(`/vehicle`);
const vehiclesList = Array.isArray(response.data)
? response.data
: Array.isArray(response.data?.vehicles)
? response.data.vehicles
: [];
runInAction(() => { runInAction(() => {
this.vehicles.data = response.data; this.vehicles.data = vehiclesList.map(this.normalizeVehicleItem);
this.vehicles.loaded = true; this.vehicles.loaded = true;
}); });
}; };
@@ -58,9 +125,10 @@ class VehicleStore {
getVehicle = async (id: number) => { getVehicle = async (id: number) => {
const response = await languageInstance("ru").get(`/vehicle/${id}`); const response = await languageInstance("ru").get(`/vehicle/${id}`);
const normalizedVehicle = this.normalizeVehicleItem(response.data);
runInAction(() => { runInAction(() => {
this.vehicle[id] = response.data; this.vehicle[id] = normalizedVehicle;
}); });
}; };
@@ -80,19 +148,13 @@ class VehicleStore {
// TODO: когда будет бекенд — добавить model в payload и в ответ // TODO: когда будет бекенд — добавить model в payload и в ответ
if (model != null && model !== "") payload.model = model; if (model != null && model !== "") payload.model = model;
const response = await languageInstance("ru").post("/vehicle", payload); const response = await languageInstance("ru").post("/vehicle", payload);
const normalizedVehicle = this.normalizeVehicleItem(response.data);
runInAction(() => { runInAction(() => {
this.vehicles.data.push({ this.vehicles.data.push(normalizedVehicle);
vehicle: { if (normalizedVehicle.vehicle?.id != null) {
id: response.data.id, this.vehicle[normalizedVehicle.vehicle.id] = normalizedVehicle;
tail_number: response.data.tail_number, }
type: response.data.type,
carrier_id: response.data.carrier_id,
carrier: response.data.carrier,
uuid: response.data.uuid,
model: response.data.model ?? model,
},
});
}); });
}; };
@@ -150,31 +212,51 @@ class VehicleStore {
`/vehicle/${id}`, `/vehicle/${id}`,
payload payload
); );
const normalizedVehicle = this.normalizeVehicleItem(response.data);
const updatedVehiclePayload = {
...normalizedVehicle.vehicle,
model: normalizedVehicle.vehicle.model ?? data.model,
snapshot_update_blocked:
normalizedVehicle.vehicle.snapshot_update_blocked ??
data.snapshot_update_blocked,
};
runInAction(() => { runInAction(() => {
const updated = { this.mergeVehicleInCaches({
...response.data, ...updatedVehiclePayload,
model: response.data.model ?? data.model, id,
snapshot_update_blocked: });
response.data.snapshot_update_blocked ?? data.snapshot_update_blocked, });
}; };
this.vehicle[id] = {
vehicle: { setMaintenanceMode = async (uuid: string, enabled: boolean) => {
...this.vehicle[id].vehicle, const response = await authInstance.post(`/devices/${uuid}/maintenance-mode`, {
...updated, enabled,
}, });
}; const normalizedVehicle = this.normalizeVehicleItem(response.data);
this.vehicles.data = this.vehicles.data.map((vehicle) =>
vehicle.vehicle.id === id runInAction(() => {
? { this.mergeVehicleInCaches({
...vehicle, ...normalizedVehicle.vehicle,
vehicle: { uuid,
...vehicle.vehicle, maintenance_mode_on:
...updated, normalizedVehicle.vehicle.maintenance_mode_on ?? enabled,
}, });
} });
: vehicle };
);
setDemoMode = async (uuid: string, enabled: boolean) => {
const response = await authInstance.post(`/devices/${uuid}/demo-mode`, {
enabled,
});
const normalizedVehicle = this.normalizeVehicleItem(response.data);
runInAction(() => {
this.mergeVehicleInCaches({
...normalizedVehicle.vehicle,
uuid,
demo_mode_enabled: normalizedVehicle.vehicle.demo_mode_enabled ?? enabled,
});
}); });
}; };
} }

View File

@@ -75,6 +75,8 @@ type RowData = {
device_uuid: string | null; device_uuid: string | null;
current_snapshot_uuid: string | null; current_snapshot_uuid: string | null;
snapshot_update_blocked: boolean; snapshot_update_blocked: boolean;
maintenance_mode_on: boolean;
demo_mode_enabled: boolean;
}; };
function getVehicleTypeLabel(vehicle: Vehicle): string { function getVehicleTypeLabel(vehicle: Vehicle): string {
@@ -97,14 +99,16 @@ const transformToRows = (vehicles: Vehicle[]): RowData[] => {
type, type,
model, model,
tail_number: vehicle.vehicle.tail_number, tail_number: vehicle.vehicle.tail_number,
online: uuid ? vehicle.device_status?.online ?? false : false, online: uuid ? (vehicle.device_status?.online ?? false) : false,
lastUpdate: vehicle.device_status?.last_update ?? null, lastUpdate: vehicle.device_status?.last_update ?? null,
gps: uuid ? vehicle.device_status?.gps_ok ?? false : false, gps: uuid ? (vehicle.device_status?.gps_ok ?? false) : false,
media: uuid ? vehicle.device_status?.media_service_ok ?? false : false, media: uuid ? (vehicle.device_status?.media_service_ok ?? false) : false,
connection: uuid ? vehicle.device_status?.is_connected ?? false : false, connection: uuid ? (vehicle.device_status?.is_connected ?? false) : false,
device_uuid: uuid ?? null, device_uuid: uuid ?? null,
current_snapshot_uuid: vehicle.vehicle.current_snapshot_uuid ?? null, current_snapshot_uuid: vehicle.vehicle.current_snapshot_uuid ?? null,
snapshot_update_blocked: vehicle.vehicle.snapshot_update_blocked ?? false, 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,
}; };
}); });
}; };
@@ -119,17 +123,29 @@ export const DevicesTable = observer(() => {
} = devicesStore; } = devicesStore;
const { snapshots, getSnapshots } = snapshotStore; const { snapshots, getSnapshots } = snapshotStore;
const { getVehicles, vehicles, deleteVehicle } = vehicleStore; const {
getVehicles,
vehicles,
deleteVehicle,
setMaintenanceMode,
setDemoMode,
} = vehicleStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedIds, setSelectedIds] = useState<number[]>([]); const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [logsModalOpen, setLogsModalOpen] = useState(false); const [logsModalOpen, setLogsModalOpen] = useState(false);
const [logsModalDeviceUuid, setLogsModalDeviceUuid] = useState<string | null>( const [logsModalDeviceUuid, setLogsModalDeviceUuid] = useState<string | null>(
null null,
); );
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [maintenanceLoadingUuids, setMaintenanceLoadingUuids] = useState<
Set<string>
>(new Set());
const [demoLoadingUuids, setDemoLoadingUuids] = useState<Set<string>>(
new Set(),
);
const [collapsedModels, setCollapsedModels] = useState<Set<string>>( const [collapsedModels, setCollapsedModels] = useState<Set<string>>(
new Set() new Set(),
); );
const [paginationModel, setPaginationModel] = useState({ const [paginationModel, setPaginationModel] = useState({
page: 0, page: 0,
@@ -146,7 +162,7 @@ export const DevicesTable = observer(() => {
const carriersInSelectedCityIds = new Set( const carriersInSelectedCityIds = new Set(
carrierStore.carriers.ru.data carrierStore.carriers.ru.data
.filter((carrier) => carrier.city_id === selectedCityId) .filter((carrier) => carrier.city_id === selectedCityId)
.map((carrier) => carrier.id) .map((carrier) => carrier.id),
); );
if (carriersInSelectedCityIds.size === 0) { if (carriersInSelectedCityIds.size === 0) {
@@ -154,17 +170,17 @@ export const DevicesTable = observer(() => {
} }
return vehiclesList.filter((vehicle) => return vehiclesList.filter((vehicle) =>
carriersInSelectedCityIds.has(vehicle.vehicle.carrier_id) carriersInSelectedCityIds.has(vehicle.vehicle.carrier_id),
); );
}; };
const filteredVehicles = filterVehiclesBySelectedCity( const filteredVehicles = filterVehiclesBySelectedCity(
vehicles.data as Vehicle[] vehicles.data as Vehicle[],
); );
const rows = useMemo( const rows = useMemo(
() => transformToRows(filteredVehicles), () => transformToRows(filteredVehicles),
[filteredVehicles] [filteredVehicles],
); );
const groupsByModel = useMemo(() => { const groupsByModel = useMemo(() => {
@@ -202,11 +218,70 @@ export const DevicesTable = observer(() => {
(r) => (r) =>
selectedIds.includes(r.id) && selectedIds.includes(r.id) &&
r.device_uuid != null && r.device_uuid != null &&
!r.snapshot_update_blocked !r.snapshot_update_blocked,
) )
.map((r) => r.device_uuid as string); .map((r) => r.device_uuid as string);
}, [rows, selectedIds]); }, [rows, selectedIds]);
const handleToggleMaintenanceMode = async (row: RowData) => {
if (!row.device_uuid) return;
const nextEnabled = !row.maintenance_mode_on;
setMaintenanceLoadingUuids((prev) => {
const next = new Set(prev);
next.add(row.device_uuid!);
return next;
});
try {
await setMaintenanceMode(row.device_uuid, nextEnabled);
await getVehicles();
await getDevices();
toast.success(
nextEnabled ? "Устройство отправлено на ТО" : "Режим ТО отключен",
);
} catch (error) {
console.error(
`Error toggling maintenance mode for ${row.device_uuid}:`,
error,
);
toast.error("Не удалось изменить режим ТО");
} finally {
setMaintenanceLoadingUuids((prev) => {
const next = new Set(prev);
next.delete(row.device_uuid!);
return next;
});
}
};
const handleToggleDemoMode = async (row: RowData) => {
if (!row.device_uuid) return;
const nextEnabled = !row.demo_mode_enabled;
setDemoLoadingUuids((prev) => {
const next = new Set(prev);
next.add(row.device_uuid!);
return next;
});
try {
await setDemoMode(row.device_uuid, nextEnabled);
await getVehicles();
await getDevices();
toast.success(nextEnabled ? "Демо-режим включен" : "Демо-режим отключен");
} catch (error) {
console.error(`Error toggling demo mode for ${row.device_uuid}:`, error);
toast.error("Не удалось изменить демо-режим");
} finally {
setDemoLoadingUuids((prev) => {
const next = new Set(prev);
next.delete(row.device_uuid!);
return next;
});
}
};
const columns: GridColDef[] = useMemo( const columns: GridColDef[] = useMemo(
() => [ () => [
{ {
@@ -220,7 +295,7 @@ export const DevicesTable = observer(() => {
field: "tail_number", field: "tail_number",
headerName: "Бортовой номер", headerName: "Бортовой номер",
flex: 1, flex: 1,
minWidth: 120, minWidth: 90,
filterable: true, filterable: true,
}, },
{ {
@@ -279,6 +354,65 @@ export const DevicesTable = observer(() => {
</Box> </Box>
), ),
}, },
{
field: "maintenance_mode_on",
headerName: "Режим ТО",
width: 90,
align: "center",
headerAlign: "center",
type: "boolean",
filterable: true,
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}
size="small"
sx={{ p: 0 }}
onChange={() => handleToggleMaintenanceMode(rowData)}
/>
</Box>
);
},
},
{
field: "demo_mode_enabled",
headerName: "Режим Демо",
width: 120,
align: "center",
headerAlign: "center",
type: "boolean",
filterable: true,
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}
size="small"
sx={{ p: 0 }}
onChange={() => handleToggleDemoMode(rowData)}
/>
</Box>
);
},
},
{ {
field: "lastUpdate", field: "lastUpdate",
headerName: "Обновлено", headerName: "Обновлено",
@@ -338,7 +472,7 @@ export const DevicesTable = observer(() => {
setSelectedDevice(row.device_uuid); setSelectedDevice(row.device_uuid);
try { try {
await authInstance.post( await authInstance.post(
`/devices/${row.device_uuid}/request-status` `/devices/${row.device_uuid}/request-status`,
); );
await getVehicles(); await getVehicles();
await getDevices(); await getDevices();
@@ -346,7 +480,7 @@ export const DevicesTable = observer(() => {
} catch (error) { } catch (error) {
console.error( console.error(
`Error requesting status for device ${row.device_uuid}:`, `Error requesting status for device ${row.device_uuid}:`,
error error,
); );
toast.error("Ошибка сервера"); toast.error("Ошибка сервера");
} }
@@ -356,7 +490,7 @@ export const DevicesTable = observer(() => {
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
gap: "20px", gap: "8px",
height: "100%", height: "100%",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@@ -421,7 +555,13 @@ export const DevicesTable = observer(() => {
snapshots, snapshots,
setLogsModalDeviceUuid, setLogsModalDeviceUuid,
setLogsModalOpen, setLogsModalOpen,
] maintenanceLoadingUuids,
demoLoadingUuids,
setMaintenanceMode,
setDemoMode,
handleToggleMaintenanceMode,
handleToggleDemoMode,
],
); );
useEffect(() => { useEffect(() => {
@@ -452,7 +592,7 @@ export const DevicesTable = observer(() => {
selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length; selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length;
if (blockedCount > 0) { if (blockedCount > 0) {
toast.info( toast.info(
`Обновление ПО не отправлено на ${blockedCount} устройств (блокировка)` `Обновление ПО не отправлено на ${blockedCount} устройств (блокировка)`,
); );
} }
@@ -460,7 +600,7 @@ export const DevicesTable = observer(() => {
try { try {
await authInstance.post( await authInstance.post(
`/devices/${deviceUuid}/force-snapshot-update`, `/devices/${deviceUuid}/force-snapshot-update`,
{ snapshot_id: snapshotId } { snapshot_id: snapshotId },
); );
toast.success("Обновление ПО отправлено на устройство"); toast.success("Обновление ПО отправлено на устройство");
} catch (error) { } catch (error) {
@@ -572,7 +712,7 @@ export const DevicesTable = observer(() => {
const isCollapsed = collapsedModels.has(groupModel); const isCollapsed = collapsedModels.has(groupModel);
const groupRowIds = groupRows.map((r) => r.id); const groupRowIds = groupRows.map((r) => r.id);
const selectedInGroup = selectedIds.filter((id) => const selectedInGroup = selectedIds.filter((id) =>
groupRowIds.includes(id) groupRowIds.includes(id),
); );
return ( return (
@@ -612,7 +752,7 @@ export const DevicesTable = observer(() => {
pageSizeOptions={[50]} pageSizeOptions={[50]}
onRowSelectionModelChange={ onRowSelectionModelChange={
createSelectionHandler(groupRowIds) as ( createSelectionHandler(groupRowIds) as (
ids: unknown ids: unknown,
) => void ) => void
} }
rowSelectionModel={{ rowSelectionModel={{