diff --git a/package.json b/package.json index 6a75280..6f85d53 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "white-nights", "private": true, - "version": "1.0.2", + "version": "1.0.3", "type": "module", "license": "UNLICENSED", "scripts": { diff --git a/src/shared/store/VehicleStore/index.ts b/src/shared/store/VehicleStore/index.ts index 39a2a8a..0c9b7cb 100644 --- a/src/shared/store/VehicleStore/index.ts +++ b/src/shared/store/VehicleStore/index.ts @@ -1,4 +1,4 @@ -import { languageInstance } from "@shared"; +import { authInstance, languageInstance } from "@shared"; import { makeAutoObservable, runInAction } from "mobx"; export type Vehicle = { @@ -12,6 +12,9 @@ export type Vehicle = { model?: string; current_snapshot_uuid?: string; snapshot_update_blocked?: boolean; + demo_mode_enabled?: boolean; + maintenance_mode_on?: boolean; + city_id?: number; }; device_status?: { device_uuid: string; @@ -37,11 +40,75 @@ class VehicleStore { 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 () => { const response = await languageInstance("ru").get(`/vehicle`); + const vehiclesList = Array.isArray(response.data) + ? response.data + : Array.isArray(response.data?.vehicles) + ? response.data.vehicles + : []; runInAction(() => { - this.vehicles.data = response.data; + this.vehicles.data = vehiclesList.map(this.normalizeVehicleItem); this.vehicles.loaded = true; }); }; @@ -58,9 +125,10 @@ class VehicleStore { getVehicle = async (id: number) => { const response = await languageInstance("ru").get(`/vehicle/${id}`); + const normalizedVehicle = this.normalizeVehicleItem(response.data); runInAction(() => { - this.vehicle[id] = response.data; + this.vehicle[id] = normalizedVehicle; }); }; @@ -80,19 +148,13 @@ class VehicleStore { // TODO: когда будет бекенд — добавить model в payload и в ответ if (model != null && model !== "") payload.model = model; const response = await languageInstance("ru").post("/vehicle", payload); + const normalizedVehicle = this.normalizeVehicleItem(response.data); runInAction(() => { - this.vehicles.data.push({ - vehicle: { - id: response.data.id, - 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, - }, - }); + this.vehicles.data.push(normalizedVehicle); + if (normalizedVehicle.vehicle?.id != null) { + this.vehicle[normalizedVehicle.vehicle.id] = normalizedVehicle; + } }); }; @@ -150,31 +212,51 @@ class VehicleStore { `/vehicle/${id}`, 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(() => { - const updated = { - ...response.data, - model: response.data.model ?? data.model, - snapshot_update_blocked: - response.data.snapshot_update_blocked ?? data.snapshot_update_blocked, - }; - this.vehicle[id] = { - vehicle: { - ...this.vehicle[id].vehicle, - ...updated, - }, - }; - this.vehicles.data = this.vehicles.data.map((vehicle) => - vehicle.vehicle.id === id - ? { - ...vehicle, - vehicle: { - ...vehicle.vehicle, - ...updated, - }, - } - : vehicle - ); + this.mergeVehicleInCaches({ + ...updatedVehiclePayload, + id, + }); + }); + }; + + setMaintenanceMode = async (uuid: string, enabled: boolean) => { + const response = await authInstance.post(`/devices/${uuid}/maintenance-mode`, { + enabled, + }); + const normalizedVehicle = this.normalizeVehicleItem(response.data); + + runInAction(() => { + this.mergeVehicleInCaches({ + ...normalizedVehicle.vehicle, + uuid, + maintenance_mode_on: + normalizedVehicle.vehicle.maintenance_mode_on ?? enabled, + }); + }); + }; + + 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, + }); }); }; } diff --git a/src/widgets/DevicesTable/index.tsx b/src/widgets/DevicesTable/index.tsx index 5fffc9d..3bf116d 100644 --- a/src/widgets/DevicesTable/index.tsx +++ b/src/widgets/DevicesTable/index.tsx @@ -75,6 +75,8 @@ type RowData = { device_uuid: string | null; current_snapshot_uuid: string | null; snapshot_update_blocked: boolean; + maintenance_mode_on: boolean; + demo_mode_enabled: boolean; }; function getVehicleTypeLabel(vehicle: Vehicle): string { @@ -97,14 +99,16 @@ const transformToRows = (vehicles: Vehicle[]): RowData[] => { type, model, 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, - 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, + 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, }; }); }; @@ -119,17 +123,29 @@ export const DevicesTable = observer(() => { } = devicesStore; const { snapshots, getSnapshots } = snapshotStore; - const { getVehicles, vehicles, deleteVehicle } = vehicleStore; + const { + getVehicles, + vehicles, + deleteVehicle, + setMaintenanceMode, + setDemoMode, + } = vehicleStore; const navigate = useNavigate(); const [selectedIds, setSelectedIds] = useState([]); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [logsModalOpen, setLogsModalOpen] = useState(false); const [logsModalDeviceUuid, setLogsModalDeviceUuid] = useState( - null + null, ); const [isLoading, setIsLoading] = useState(false); + const [maintenanceLoadingUuids, setMaintenanceLoadingUuids] = useState< + Set + >(new Set()); + const [demoLoadingUuids, setDemoLoadingUuids] = useState>( + new Set(), + ); const [collapsedModels, setCollapsedModels] = useState>( - new Set() + new Set(), ); const [paginationModel, setPaginationModel] = useState({ page: 0, @@ -146,7 +162,7 @@ export const DevicesTable = observer(() => { const carriersInSelectedCityIds = new Set( carrierStore.carriers.ru.data .filter((carrier) => carrier.city_id === selectedCityId) - .map((carrier) => carrier.id) + .map((carrier) => carrier.id), ); if (carriersInSelectedCityIds.size === 0) { @@ -154,17 +170,17 @@ export const DevicesTable = observer(() => { } return vehiclesList.filter((vehicle) => - carriersInSelectedCityIds.has(vehicle.vehicle.carrier_id) + carriersInSelectedCityIds.has(vehicle.vehicle.carrier_id), ); }; const filteredVehicles = filterVehiclesBySelectedCity( - vehicles.data as Vehicle[] + vehicles.data as Vehicle[], ); const rows = useMemo( () => transformToRows(filteredVehicles), - [filteredVehicles] + [filteredVehicles], ); const groupsByModel = useMemo(() => { @@ -202,11 +218,70 @@ export const DevicesTable = observer(() => { (r) => selectedIds.includes(r.id) && r.device_uuid != null && - !r.snapshot_update_blocked + !r.snapshot_update_blocked, ) .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; + 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( () => [ { @@ -220,7 +295,7 @@ export const DevicesTable = observer(() => { field: "tail_number", headerName: "Бортовой номер", flex: 1, - minWidth: 120, + minWidth: 90, filterable: true, }, { @@ -279,6 +354,65 @@ export const DevicesTable = observer(() => { ), }, + { + 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 ( + e.stopPropagation()} + > + handleToggleMaintenanceMode(rowData)} + /> + + ); + }, + }, + { + 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 ( + e.stopPropagation()} + > + handleToggleDemoMode(rowData)} + /> + + ); + }, + }, { field: "lastUpdate", headerName: "Обновлено", @@ -338,7 +472,7 @@ export const DevicesTable = observer(() => { setSelectedDevice(row.device_uuid); try { await authInstance.post( - `/devices/${row.device_uuid}/request-status` + `/devices/${row.device_uuid}/request-status`, ); await getVehicles(); await getDevices(); @@ -346,7 +480,7 @@ export const DevicesTable = observer(() => { } catch (error) { console.error( `Error requesting status for device ${row.device_uuid}:`, - error + error, ); toast.error("Ошибка сервера"); } @@ -356,7 +490,7 @@ export const DevicesTable = observer(() => { { snapshots, setLogsModalDeviceUuid, setLogsModalOpen, - ] + maintenanceLoadingUuids, + demoLoadingUuids, + setMaintenanceMode, + setDemoMode, + handleToggleMaintenanceMode, + handleToggleDemoMode, + ], ); useEffect(() => { @@ -452,7 +592,7 @@ export const DevicesTable = observer(() => { selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length; if (blockedCount > 0) { toast.info( - `Обновление ПО не отправлено на ${blockedCount} устройств (блокировка)` + `Обновление ПО не отправлено на ${blockedCount} устройств (блокировка)`, ); } @@ -460,7 +600,7 @@ export const DevicesTable = observer(() => { try { await authInstance.post( `/devices/${deviceUuid}/force-snapshot-update`, - { snapshot_id: snapshotId } + { snapshot_id: snapshotId }, ); toast.success("Обновление ПО отправлено на устройство"); } catch (error) { @@ -572,7 +712,7 @@ export const DevicesTable = observer(() => { const isCollapsed = collapsedModels.has(groupModel); const groupRowIds = groupRows.map((r) => r.id); const selectedInGroup = selectedIds.filter((id) => - groupRowIds.includes(id) + groupRowIds.includes(id), ); return ( @@ -612,7 +752,7 @@ export const DevicesTable = observer(() => { pageSizeOptions={[50]} onRowSelectionModelChange={ createSelectionHandler(groupRowIds) as ( - ids: unknown + ids: unknown, ) => void } rowSelectionModel={{