479 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			479 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| 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>
 | ||
|     </>
 | ||
|   );
 | ||
| });
 |