Files
WhiteNightsAdminPanel/src/widgets/DevicesTable/index.tsx

479 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Paper from "@mui/material/Paper";
import { Check, Copy, RotateCcw, X } from "lucide-react";
import {
authInstance,
devicesStore,
Modal,
snapshotStore,
vehicleStore, // Not directly used in this component's rendering logic anymore
} from "@shared"; // Assuming @shared exports these
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Button, Checkbox, Typography } from "@mui/material";
import { Vehicle } from "@shared";
import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom";
export type ConnectedDevice = string;
interface Snapshot {
ID: string; // Assuming ID is string based on usage
Name: string;
// Add other snapshot properties if needed
}
// --- HELPER FUNCTIONS ---
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 (error) {
return "Некорректная дата";
}
};
type TableRowData = {
tail_number: number;
online: boolean;
lastUpdate: string | null;
gps: boolean;
media: boolean;
connection: boolean;
device_uuid: string | null;
};
function createData(
tail_number: number,
online: boolean,
lastUpdate: string | null,
gps: boolean,
media: boolean,
connection: boolean,
device_uuid: string | null
): TableRowData {
return {
tail_number,
online,
lastUpdate,
gps,
media,
connection,
device_uuid,
};
}
// This function transforms the raw device data (which includes vehicle and device_status)
// into the format expected by the table. It now filters for devices that have a UUID.
const transformDevicesToRows = (
vehicles: Vehicle[]
// devices: ConnectedDevice[]
): TableRowData[] => {
return vehicles.map((vehicle) => {
const uuid = vehicle.vehicle.uuid;
if (!uuid)
return {
tail_number: vehicle.vehicle.tail_number,
online: false,
lastUpdate: null,
gps: false,
media: false,
connection: false,
device_uuid: null,
};
return createData(
vehicle.vehicle.tail_number,
vehicle.device_status?.online ?? false,
vehicle.device_status?.last_update ?? null,
vehicle.device_status?.gps_ok ?? false,
vehicle.device_status?.media_service_ok ?? false,
vehicle.device_status?.is_connected ?? false,
uuid
);
});
};
export const DevicesTable = observer(() => {
const {
getDevices,
setSelectedDevice,
sendSnapshotModalOpen,
toggleSendSnapshotModal,
} = devicesStore;
const { snapshots, getSnapshots } = snapshotStore;
const { getVehicles, vehicles } = vehicleStore; // Removed as devicesStore.devices should be the source of truth
const { devices } = devicesStore;
const navigate = useNavigate();
const [selectedDeviceUuids, setSelectedDeviceUuids] = useState<string[]>([]);
// Transform the raw devices data into rows suitable for the table
// This will also filter out devices without a UUID, as those cannot be acted upon.
const currentTableRows = transformDevicesToRows(
vehicles.data as Vehicle[]
// devices as ConnectedDevice[]
);
useEffect(() => {
const fetchData = async () => {
await getVehicles(); // Not strictly needed if devicesStore.devices is populated correctly by getDevices
await getDevices(); // This should fetch the combined vehicle/device_status data
await getSnapshots();
};
fetchData();
}, [getDevices, getSnapshots]); // Added dependencies
const isAllSelected =
currentTableRows.length > 0 &&
selectedDeviceUuids.length === currentTableRows.length;
const handleSelectAllDevices = () => {
if (isAllSelected) {
setSelectedDeviceUuids([]);
} else {
// Select all device UUIDs from the *currently visible and selectable* rows
setSelectedDeviceUuids(
currentTableRows.map((row) => row.device_uuid ?? "")
);
}
};
const handleSelectDevice = (
event: React.ChangeEvent<HTMLInputElement>,
deviceUuid: string
) => {
if (event.target.checked) {
setSelectedDeviceUuids((prevSelected) => [...prevSelected, deviceUuid]);
} else {
setSelectedDeviceUuids((prevSelected) =>
prevSelected.filter((uuid) => uuid !== deviceUuid)
);
}
};
const handleOpenSendSnapshotModal = () => {
if (selectedDeviceUuids.length > 0) {
toggleSendSnapshotModal();
}
};
const handleReloadStatus = async (uuid: string) => {
setSelectedDevice(uuid); // Sets the device in the store, useful for context elsewhere
try {
await authInstance.post(`/devices/${uuid}/request-status`);
await getVehicles();
await getDevices(); // Refresh devices to show updated status
} catch (error) {
console.error(`Error requesting status for device ${uuid}:`, error);
// Optionally: show a user-facing error message
}
};
const handleSendSnapshotAction = async (snapshotId: string) => {
if (selectedDeviceUuids.length === 0) return;
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(`Не удалось отправить снапшот на устройство`);
}
};
try {
// Create an array of promises for all snapshot requests
const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => {
return send(deviceUuid);
});
// Wait for all promises to settle (either resolve or reject)
await Promise.allSettled(snapshotPromises);
// After all requests are attempted
await getDevices(); // Refresh the device list
setSelectedDeviceUuids([]); // Clear the selection
toggleSendSnapshotModal(); // Close the modal
} catch (error) {
// This catch block might not be hit if Promise.allSettled is used,
// as it doesn't reject on individual promise failures.
// Individual errors should be handled if needed within the .map or by checking results.
console.error("Error in snapshot sending process:", error);
}
};
return (
<>
<TableContainer component={Paper} sx={{ mt: 2 }}>
<div className="flex justify-end p-3 gap-2 ">
<Button
variant="contained"
color="primary"
size="small"
onClick={() => navigate("/vehicle/create")}
>
Добавить устройство
</Button>
</div>
<div className="flex justify-end p-3 gap-2">
<Button
variant="outlined" // Changed to outlined for distinction
onClick={handleSelectAllDevices}
size="small"
>
{isAllSelected ? "Снять выбор" : "Выбрать все"}
</Button>
<Button
variant="contained"
color="primary"
disabled={selectedDeviceUuids.length === 0}
onClick={handleOpenSendSnapshotModal}
size="small"
>
Отправить снапшот ({selectedDeviceUuids.length})
</Button>
</div>
<Table sx={{ minWidth: 650 }} aria-label="devices table" size="small">
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
indeterminate={
selectedDeviceUuids.length > 0 &&
selectedDeviceUuids.length < currentTableRows.length
}
checked={isAllSelected}
onChange={handleSelectAllDevices}
inputProps={{ "aria-label": "select all devices" }}
size="small"
/>
</TableCell>
<TableCell align="center">Борт. номер</TableCell>
<TableCell align="center">Онлайн</TableCell>
<TableCell align="center">Обновлено</TableCell>
<TableCell align="center">GPS</TableCell>
<TableCell align="center">Медиа</TableCell>
<TableCell align="center">Связь</TableCell>
<TableCell align="center">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{currentTableRows.map((row) => (
<TableRow
key={row.tail_number}
hover
role="checkbox"
aria-checked={selectedDeviceUuids.includes(
row.device_uuid ?? ""
)}
selected={selectedDeviceUuids.includes(row.device_uuid ?? "")}
onClick={(event) => {
// Allow clicking row to toggle checkbox, if not clicking on button
if (
(event.target as HTMLElement).closest("button") === null &&
(event.target as HTMLElement).closest(
'input[type="checkbox"]'
) === null
) {
if (event.shiftKey) {
if (row.device_uuid) {
navigator.clipboard
.writeText(row.device_uuid)
.then(() => {
toast.success(`UUID скопирован`);
})
.catch(() => {
toast.error("Не удалось скопировать UUID");
});
} else {
toast.warning("Устройство не имеет UUID");
}
}
// Only toggle checkbox if Shift key is not pressed
if (!event.shiftKey) {
handleSelectDevice(
{
target: {
checked: !selectedDeviceUuids.includes(
row.device_uuid ?? ""
),
},
} as React.ChangeEvent<HTMLInputElement>, // Simulate event
row.device_uuid ?? ""
);
}
}
}}
sx={{
cursor: "pointer",
"&:last-child td, &:last-child th": { border: 0 },
}}
>
<TableCell padding="checkbox">
<Checkbox
checked={selectedDeviceUuids.includes(
row.device_uuid ?? ""
)}
onChange={(event) =>
handleSelectDevice(event, row.device_uuid ?? "")
}
size="small"
/>
</TableCell>
<TableCell
align="center"
component="th"
scope="row"
id={`device-label-${row.device_uuid}`}
>
{row.tail_number}
</TableCell>
<TableCell align="center">
<div className="flex items-center justify-center">
{row.online ? (
<Check size={18} className="text-green-600" />
) : (
<X size={18} className="text-red-600" />
)}
</div>
</TableCell>
<TableCell align="center">
<div className="flex items-center justify-center">
{formatDate(row.lastUpdate)}
</div>
</TableCell>
<TableCell align="center">
<div className="flex items-center justify-center">
{row.gps ? (
<Check size={18} className="text-green-600" />
) : (
<X size={18} className="text-red-600" />
)}
</div>
</TableCell>
<TableCell align="center">
<div className="flex items-center justify-center">
{row.media ? (
<Check size={18} className="text-green-600" />
) : (
<X size={18} className="text-red-600" />
)}
</div>
</TableCell>
<TableCell align="center">
<div className="flex items-center justify-center">
{row.connection ? (
<Check size={18} className="text-green-600" />
) : (
<X size={18} className="text-red-600" />
)}
</div>
</TableCell>
<TableCell align="center">
<Button
onClick={async (e) => {
e.stopPropagation();
try {
if (
row.device_uuid &&
devices.find((device) => device === row.device_uuid)
) {
await handleReloadStatus(row.device_uuid);
toast.success("Статус устройства обновлен");
} else {
toast.error("Нет связи с устройством");
}
} catch (error) {
toast.error("Ошибка сервера");
}
}}
title="Перезапросить статус"
size="small"
variant="text"
>
<RotateCcw size={16} />
</Button>
<Button
onClick={() => {
navigator.clipboard.writeText(row.device_uuid ?? "");
toast.success("UUID скопирован");
}}
>
<Copy size={16} />
</Button>
</TableCell>
</TableRow>
))}
{currentTableRows.length === 0 && (
<TableRow>
<TableCell colSpan={8} align="center">
Нет устройств для отображения.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<Modal open={sendSnapshotModalOpen} onClose={toggleSendSnapshotModal}>
<Typography variant="h6" component="h2" sx={{ mb: 1 }}>
Отправить снапшот
</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
Выбрано устройств:{" "}
<strong className="text-blue-600">
{selectedDeviceUuids.length}
</strong>
</Typography>
<div className="mt-2 flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2">
{snapshots && (snapshots as Snapshot[]).length > 0 ? ( // Cast snapshots
(snapshots as Snapshot[]).map((snapshot) => (
<Button
variant="outlined"
fullWidth
onClick={() => handleSendSnapshotAction(snapshot.ID)}
key={snapshot.ID}
sx={{ justifyContent: "flex-start" }}
>
{snapshot.Name}
</Button>
))
) : (
<Typography variant="body2" color="textSecondary">
Нет доступных снапшотов.
</Typography>
)}
</div>
<Button
onClick={toggleSendSnapshotModal}
color="inherit"
variant="outlined"
sx={{ mt: 3 }}
fullWidth
>
Отмена
</Button>
</Modal>
</>
);
});