Add Map page with basic logic

This commit is contained in:
2025-06-02 18:02:02 +03:00
parent a8777a974a
commit 5814e65953
15 changed files with 2576 additions and 132 deletions

View File

@ -5,6 +5,11 @@ import {
LoginPage,
MainPage,
SightPage,
MapPage,
MediaListPage,
PreviewMediaPage,
EditMediaPage,
// CreateMediaPage,
} from "@pages";
import { authStore, createSightStore, editSightStore } from "@shared";
import { Layout } from "@widgets";
@ -71,6 +76,11 @@ export const Router = () => {
<Route path="sight/:id" element={<EditSightPage />} />
<Route path="sight/create" element={<CreateSightPage />} />
<Route path="devices" element={<DevicesPage />} />
<Route path="map" element={<MapPage />} />
<Route path="media" element={<MediaListPage />} />
<Route path="media/:id" element={<PreviewMediaPage />} />
<Route path="media/:id/edit" element={<EditMediaPage />} />
{/* <Route path="media/create" element={<CreateMediaPage />} /> */}
</Route>
</Routes>
);

View File

@ -0,0 +1,126 @@
// import { Button, Paper, Typography, Box, Alert, Snackbar } from "@mui/material";
// import { useNavigate } from "react-router-dom";
// import { ArrowLeft, Upload } from "lucide-react";
// import { observer } from "mobx-react-lite";
// import { useState, DragEvent, useRef, useEffect } from "react";
// import { editSightStore, UploadMediaDialog } from "@shared";
// export const CreateMediaPage = observer(() => {
// const navigate = useNavigate();
// const [isDragging, setIsDragging] = useState(false);
// const [error, setError] = useState<string | null>(null);
// const [success, setSuccess] = useState(false);
// const fileInputRef = useRef<HTMLInputElement>(null);
// const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
// const handleDrop = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// e.stopPropagation();
// setIsDragging(false);
// const files = Array.from(e.dataTransfer.files);
// if (files.length > 0) {
// editSightStore.fileToUpload = files[0];
// setUploadDialogOpen(true);
// }
// };
// const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// setIsDragging(true);
// };
// const handleDragLeave = () => {
// setIsDragging(false);
// };
// const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
// const files = e.target.files;
// if (files && files.length > 0) {
// editSightStore.fileToUpload = files[0];
// setUploadDialogOpen(true);
// }
// };
// const handleUploadSuccess = () => {
// setSuccess(true);
// setUploadDialogOpen(false);
// };
// return (
// <div className="w-full h-full p-8">
// <div className="flex items-center gap-4 mb-8">
// <Button
// variant="outlined"
// startIcon={<ArrowLeft size={20} />}
// onClick={() => navigate("/media")}
// >
// Назад
// </Button>
// <Typography variant="h5">Загрузка медиафайла</Typography>
// </div>
// <Paper
// elevation={3}
// className={`w-full h-[60vh] flex flex-col items-center justify-center p-8 transition-colors ${
// isDragging ? "bg-blue-50 border-2 border-blue-500" : "bg-gray-50"
// }`}
// onDrop={handleDrop}
// onDragOver={handleDragOver}
// onDragLeave={handleDragLeave}
// >
// <input
// type="file"
// ref={fileInputRef}
// className="hidden"
// onChange={handleFileSelect}
// accept="image/*,video/*,.glb,.gltf"
// />
// <Box className="flex flex-col items-center gap-4 text-center">
// <Upload size={48} className="text-gray-400" />
// <Typography variant="h6" className="text-gray-600">
// Перетащите файл сюда или
// </Typography>
// <Button
// variant="contained"
// onClick={() => fileInputRef.current?.click()}
// startIcon={<Upload size={20} />}
// >
// Выберите файл
// </Button>
// <Typography variant="body2" className="text-gray-500 mt-4">
// Поддерживаемые форматы: JPG, PNG, GIF, MP4, WebM, GLB, GLTF
// </Typography>
// </Box>
// </Paper>
// <UploadMediaDialog
// open={uploadDialogOpen}
// onClose={() => setUploadDialogOpen(false)}
// afterUpload={handleUploadSuccess}
// />
// <Snackbar
// open={success}
// autoHideDuration={2000}
// onClose={() => setSuccess(false)}
// >
// <Alert severity="success" onClose={() => setSuccess(false)}>
// Медиафайл успешно загружен
// </Alert>
// </Snackbar>
// <Snackbar
// open={!!error}
// autoHideDuration={6000}
// onClose={() => setError(null)}
// >
// <Alert severity="error" onClose={() => setError(null)}>
// {error}
// </Alert>
// </Snackbar>
// </div>
// );
// });

View File

@ -0,0 +1,255 @@
import { useEffect, useState, useRef } from "react";
import { observer } from "mobx-react-lite";
import { useNavigate, useParams } from "react-router-dom";
import {
Box,
Button,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
TextField,
Typography,
Alert,
Snackbar,
} from "@mui/material";
import { Save, ArrowLeft } from "lucide-react";
import { authInstance, mediaStore, MEDIA_TYPE_LABELS } from "@shared";
import { MediaViewer } from "@widgets";
export const EditMediaPage = observer(() => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [newFile, setNewFile] = useState<File | null>(null);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false); // State for the upload dialog
const media = id ? mediaStore.media.find((m) => m.id === id) : null;
const [mediaName, setMediaName] = useState(media?.media_name ?? "");
const [mediaFilename, setMediaFilename] = useState(media?.filename ?? "");
const [mediaType, setMediaType] = useState(media?.media_type ?? 1);
useEffect(() => {
if (id) {
mediaStore.getOneMedia(id);
}
console.log(newFile);
console.log(uploadDialogOpen);
}, [id]);
useEffect(() => {
if (media) {
setMediaName(media.media_name);
setMediaFilename(media.filename);
setMediaType(media.media_type);
}
}, [media]);
// const handleDrop = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// e.stopPropagation();
// setIsDragging(false);
// const files = Array.from(e.dataTransfer.files);
// if (files.length > 0) {
// setNewFile(files[0]);
// setMediaFilename(files[0].name);
// setUploadDialogOpen(true); // Open dialog on file drop
// }
// };
// const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// setIsDragging(true);
// };
// const handleDragLeave = () => {
// setIsDragging(false);
// };
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
setNewFile(files[0]);
setMediaFilename(files[0].name);
setUploadDialogOpen(true); // Open dialog on file selection
}
};
const handleSave = async () => {
if (!id) return;
setIsLoading(true);
setError(null);
try {
await authInstance.patch(`/media/${id}`, {
media_name: mediaName,
filename: mediaFilename,
type: mediaType,
});
// If a new file was selected, the actual file upload will happen
// via the UploadMediaDialog. We just need to make sure the metadata
// is updated correctly before or after.
// Since the dialog handles the actual upload, we don't call updateMediaFile here.
setSuccess(true);
handleUploadSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save media");
} finally {
setIsLoading(false);
}
};
const handleUploadSuccess = () => {
// After successful upload in the dialog, refresh media data if needed
if (id) {
mediaStore.getOneMedia(id);
}
setNewFile(null); // Clear the new file state after successful upload
setUploadDialogOpen(false);
setSuccess(true);
};
if (!media && id) {
// Only show loading if an ID is present and media is not yet loaded
return (
<Box className="flex justify-center items-center h-screen">
<CircularProgress />
</Box>
);
}
return (
<Box className="p-6 max-w-4xl mx-auto">
<Box className="flex items-center gap-4 mb-6">
<Button
variant="outlined"
startIcon={<ArrowLeft size={20} />}
onClick={() => navigate("/media")}
>
Назад
</Button>
<Typography variant="h5">Редактирование медиа</Typography>
</Box>
<Paper className="p-6">
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileSelect}
accept="image/*,video/*,.glb,.gltf"
/>
<Box className="flex flex-col gap-6">
<Box className="flex gap-4">
<TextField
fullWidth
value={mediaName}
onChange={(e) => setMediaName(e.target.value)}
label="Название медиа"
disabled={isLoading}
/>
<TextField
fullWidth
value={mediaFilename}
onChange={(e) => setMediaFilename(e.target.value)}
label="Название файла"
disabled={isLoading}
/>
</Box>
<FormControl fullWidth sx={{ width: "50%" }}>
<InputLabel>Тип медиа</InputLabel>
<Select
value={mediaType}
label="Тип медиа"
onChange={(e) => setMediaType(Number(e.target.value))}
disabled={isLoading}
>
{Object.entries(MEDIA_TYPE_LABELS).map(([type, label]) => (
<MenuItem key={type} value={Number(type)}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
<Box className="flex gap-6">
<Paper
elevation={2}
sx={{
flex: 1,
p: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 400,
}}
>
<MediaViewer
media={{
id: id || "",
media_type: mediaType,
filename: mediaFilename,
}}
/>
</Paper>
<Box className="flex flex-col gap-4 self-end">
<Button
variant="contained"
color="primary"
startIcon={<Save size={20} />}
onClick={handleSave}
disabled={isLoading || (!mediaName && !mediaFilename)}
>
{isLoading ? <CircularProgress size={20} /> : "Сохранить"}
</Button>
{/* Only show "Replace file" button if no new file is currently selected */}
</Box>
</Box>
{error && (
<Typography color="error" className="mt-2">
{error}
</Typography>
)}
{success && (
<Typography color="success.main" className="mt-2">
Медиа успешно сохранено
</Typography>
)}
</Box>
</Paper>
<Snackbar
open={!!error}
autoHideDuration={6000}
onClose={() => setError(null)}
>
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
</Snackbar>
<Snackbar
open={success}
autoHideDuration={2000}
onClose={() => setSuccess(false)}
>
<Alert severity="success" onClose={() => setSuccess(false)}>
Медиа успешно сохранено
</Alert>
</Snackbar>
</Box>
);
});

1594
src/pages/MapPage/index.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,76 @@
import { Button, TableBody } from "@mui/material";
import { TableRow, TableCell } from "@mui/material";
import { Table, TableHead } from "@mui/material";
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
import { useEffect } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
const rows = (media: any[]) => {
return media.map((row) => ({
id: row.id,
media_name: row.media_name,
media_type:
MEDIA_TYPE_LABELS[row.media_type as keyof typeof MEDIA_TYPE_LABELS],
}));
};
export const MediaListPage = observer(() => {
const { media, getMedia, deleteMedia } = mediaStore;
const navigate = useNavigate();
useEffect(() => {
getMedia();
}, []);
const currentRows = rows(media);
return (
<>
<div className="flex justify-end p-3 gap-5">
<Button
variant="contained"
color="primary"
onClick={() => navigate("/sight/create")}
>
Создать
</Button>
</div>
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell align="center">Название</TableCell>
<TableCell align="center">Тип</TableCell>
<TableCell align="center">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{currentRows.map((row) => (
<TableRow
key={row.media_name}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell align="center">{row.media_name}</TableCell>
<TableCell align="center">{row.media_type}</TableCell>
<TableCell align="center">
<div className="flex gap-7 justify-center">
<button onClick={() => navigate(`/media/${row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button onClick={() => navigate(`/media/${row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
<button onClick={() => deleteMedia(row.id)}>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
);
});

View File

@ -0,0 +1,44 @@
import { mediaStore } from "@shared";
import { useEffect } from "react";
import { useParams } from "react-router-dom";
import { MediaViewer } from "../../widgets/MediaViewer/index";
import { observer } from "mobx-react-lite";
import { Download } from "lucide-react";
import { Button } from "@mui/material";
export const PreviewMediaPage = observer(() => {
const { id } = useParams();
const { oneMedia, getOneMedia } = mediaStore;
useEffect(() => {
getOneMedia(id!);
}, []);
return (
<div className="w-full h-[80vh] flex flex-col justify-center items-center gap-4">
<div className="w-full h-full flex justify-center items-center">
<MediaViewer className="w-full h-full" media={oneMedia!} />
</div>
{oneMedia && (
<div className="flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
<p className="text-white text-center">
Чтобы скачать файл, нажмите на кнопку ниже
</p>
<Button
variant="contained"
color="primary"
startIcon={<Download size={16} />}
component="a"
href={`${
import.meta.env.VITE_KRBL_MEDIA
}${id}/download?token=${localStorage.getItem("token")}`}
target="_blank"
>
Скачать
</Button>
</div>
)}
</div>
);
});

View File

@ -4,3 +4,8 @@ export * from "./LoginPage";
export * from "./DevicesPage";
export * from "./SightPage";
export * from "./CreateSightPage";
export * from "./MapPage";
export * from "./MediaListPage";
export * from "./PreviewMediaPage";
export * from "./EditMediaPage";
// export * from "./CreateMediaPage";

View File

@ -1,5 +1,13 @@
import { authStore } from "@shared";
import { Power, LucideIcon, Building2, MonitorSmartphone } from "lucide-react";
import {
Power,
LucideIcon,
Building2,
MonitorSmartphone,
Map,
BookImage,
Newspaper,
} from "lucide-react";
export const DRAWER_WIDTH = 300;
interface NavigationItem {
@ -21,12 +29,30 @@ export const NAVIGATION_ITEMS: {
icon: Building2,
path: "/sight",
},
{
id: "map",
label: "Карта",
icon: Map,
path: "/map",
},
{
id: "devices",
label: "Устройства",
icon: MonitorSmartphone,
path: "/devices",
},
{
id: "media",
label: "Медиа",
icon: BookImage,
path: "/media",
},
{
id: "articles",
label: "Статьи",
icon: Newspaper,
path: "/articles",
},
],
secondary: [
{

View File

@ -10,7 +10,7 @@ type Media = {
class MediaStore {
media: Media[] = [];
oneMedia: Media | null = null;
constructor() {
makeAutoObservable(this);
}
@ -22,6 +22,60 @@ class MediaStore {
this.media = [...response.data];
});
};
deleteMedia = async (id: string) => {
await authInstance.delete(`/media/${id}`);
this.media = this.media.filter((media) => media.id !== id);
};
getOneMedia = async (id: string) => {
this.oneMedia = null;
const response = await authInstance.get(`/media/${id}`);
runInAction(() => {
this.oneMedia = response.data;
});
};
updateMedia = async (id: string, data: Partial<Media>) => {
const response = await authInstance.patch(`/media/${id}`, data);
runInAction(() => {
// Update in media array
const index = this.media.findIndex((m) => m.id === id);
if (index !== -1) {
this.media[index] = { ...this.media[index], ...response.data };
}
// Update oneMedia if it's the current media being viewed
if (this.oneMedia?.id === id) {
this.oneMedia = { ...this.oneMedia, ...response.data };
}
});
return response.data;
};
updateMediaFile = async (id: string, file: File, filename: string) => {
const formData = new FormData();
formData.append("file", file);
formData.append("filename", filename);
const response = await authInstance.patch(`/media/${id}/file`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
runInAction(() => {
// Update in media array
const index = this.media.findIndex((m) => m.id === id);
if (index !== -1) {
this.media[index] = { ...this.media[index], ...response.data };
}
// Update oneMedia if it's the current media being viewed
if (this.oneMedia?.id === id) {
this.oneMedia = { ...this.oneMedia, ...response.data };
}
});
return response.data;
};
}
export const mediaStore = new MediaStore();

View File

@ -1,7 +1,7 @@
import { API_URL, authInstance } from "@shared";
import { makeAutoObservable } from "mobx";
type Vehicle = {
export type Vehicle = {
vehicle: {
id: number;
tail_number: number;

View File

@ -11,15 +11,25 @@ import {
devicesStore,
Modal,
snapshotStore,
vehicleStore,
} from "@shared";
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 Typography for the modal message
import { Button, Checkbox, Typography } from "@mui/material";
import { Vehicle } from "@shared";
import { toast } from "react-toastify";
const formatDate = (dateString: string | undefined) => {
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", {
@ -32,244 +42,368 @@ const formatDate = (dateString: string | undefined) => {
hour12: false,
}).format(date);
} catch (error) {
console.error("Error formatting date:", error);
return "Некорректная дата";
}
};
type TableRowData = {
tail_number: number;
online: boolean;
lastUpdate: string | null;
gps: boolean;
media: boolean;
connection: boolean;
device_uuid: string | null;
};
function createData(
uuid: string,
tail_number: number,
online: boolean,
lastUpdate: string,
lastUpdate: string | null,
gps: boolean,
media: boolean,
connection: boolean
) {
return { uuid, online, lastUpdate, gps, media, connection };
connection: boolean,
device_uuid: string | null
): TableRowData {
return {
tail_number,
online,
lastUpdate,
gps,
media,
connection,
device_uuid,
};
}
// Keep the rows function as you provided it, without additional filters
const rows = (vehicles: any[]) => {
// 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 ?? "1243000", // Using tail_number as UUID, as in your original code
vehicle?.device_status?.online ?? false,
vehicle?.device_status?.last_update,
vehicle?.device_status?.gps_ok,
vehicle?.device_status?.media_service_ok,
vehicle?.device_status?.is_connected
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 {
devices,
getDevices,
// uuid, // This 'uuid' from devicesStore refers to a *single* selected device, not for batch actions.
setSelectedDevice, // Useful for individual device actions like 'Reload Status'
setSelectedDevice,
sendSnapshotModalOpen,
toggleSendSnapshotModal,
} = devicesStore;
const { snapshots, getSnapshots } = snapshotStore;
const { getVehicles } = vehicleStore;
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
// Get the current list of rows displayed in the table
const currentRows = rows(devices);
const { snapshots, getSnapshots } = snapshotStore;
const { getVehicles, vehicles } = vehicleStore; // Removed as devicesStore.devices should be the source of truth
const { devices } = devicesStore;
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 as Vehicle[]
// devices as ConnectedDevice[]
);
useEffect(() => {
const fetchData = async () => {
await getVehicles();
await getDevices();
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
// Determine if all visible devices are selected
const isAllSelected =
currentRows.length > 0 && selectedDevices.length === currentRows.length;
currentTableRows.length > 0 &&
selectedDeviceUuids.length === currentTableRows.length;
const handleSelectAllDevices = () => {
if (isAllSelected) {
// If all are currently selected, deselect all
setSelectedDevices([]);
setSelectedDeviceUuids([]);
} else {
// Otherwise, select all device UUIDs from the current rows
setSelectedDevices(currentRows.map((row) => row.uuid));
// Select all device UUIDs from the *currently visible and selectable* rows
setSelectedDeviceUuids(
currentTableRows.map((row) => row.device_uuid ?? "")
);
}
};
const handleSelectDevice = (event: React.ChangeEvent<HTMLInputElement>) => {
const deviceUuid = event.target.value;
const handleSelectDevice = (
event: React.ChangeEvent<HTMLInputElement>,
deviceUuid: string
) => {
if (event.target.checked) {
setSelectedDevices((prevSelected) => [...prevSelected, deviceUuid]);
setSelectedDeviceUuids((prevSelected) => [...prevSelected, deviceUuid]);
} else {
setSelectedDevices((prevSelected) =>
setSelectedDeviceUuids((prevSelected) =>
prevSelected.filter((uuid) => uuid !== deviceUuid)
);
}
};
// This function now opens the modal for selected devices
const handleOpenSendSnapshotModal = () => {
if (selectedDevices.length > 0) {
if (selectedDeviceUuids.length > 0) {
toggleSendSnapshotModal();
}
};
const handleReloadStatus = async (uuid: string) => {
setSelectedDevice(uuid); // Set the active device in store for context if needed
await authInstance.post(`/devices/${uuid}/request-status`);
await getDevices(); // Refresh devices after status request
setSelectedDevice(uuid); // Sets the device in the store, useful for context elsewhere
try {
await authInstance.post(`/devices/${uuid}/request-status`);
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
}
};
// This function now handles sending snapshots to ALL selected devices
const handleSendSnapshotAction = async (snapshotId: string) => {
if (selectedDeviceUuids.length === 0) return;
try {
for (const deviceUuid of selectedDevices) {
// Create an array of promises for all snapshot requests
const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => {
console.log(`Sending snapshot ${snapshotId} to device ${deviceUuid}`);
// Ensure you are using the correct API endpoint for force-snapshot
await authInstance.post(`/devices/${deviceUuid}/force-snapshot`, {
return authInstance.post(`/devices/${deviceUuid}/force-snapshot`, {
snapshot_id: snapshotId,
});
}
// After all requests are sent
await getDevices(); // Refresh the device list to show updated status
setSelectedDevices([]); // Clear the selection
});
// 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) {
console.error("Error sending snapshots:", error);
// You might want to show an error notification to the user here
// 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}>
<div className="flex justify-end p-3 gap-3">
{" "}
{/* Changed gap to 3 for slightly less space */}
<TableContainer component={Paper} sx={{ mt: 2 }}>
<div className="flex justify-end p-3 gap-2">
<Button
variant="contained"
color="primary"
variant="outlined" // Changed to outlined for distinction
onClick={handleSelectAllDevices}
size="small"
>
{isAllSelected ? "Снять выбор со всех" : "Выбрать все"}
{isAllSelected ? "Снять выбор" : "Выбрать все"}
</Button>
<Button
variant="contained"
color="primary"
disabled={selectedDevices.length === 0}
onClick={handleOpenSendSnapshotModal} // Call the new handler
disabled={selectedDeviceUuids.length === 0}
onClick={handleOpenSendSnapshotModal}
size="small"
>
Отправить снапшот ({selectedDevices.length})
Отправить снапшот ({selectedDeviceUuids.length})
</Button>
</div>
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<Table sx={{ minWidth: 650 }} aria-label="devices table" size="small">
<TableHead>
<TableRow>
<TableCell align="center" padding="checkbox">
{" "}
{/* Added padding="checkbox" */}
<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">Последнее обновление</TableCell>
<TableCell align="center">ГПС</TableCell>
<TableCell align="center">Медиа-данные</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>
{currentRows.map((row) => (
{currentTableRows.map((row) => (
<TableRow
key={row.uuid} // Use row.uuid as key for consistent rendering
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
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
) {
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 align="center" padding="checkbox">
{" "}
{/* Added padding="checkbox" */}
<TableCell padding="checkbox">
<Checkbox
className="h-full"
onChange={handleSelectDevice}
value={row.uuid}
checked={selectedDevices.includes(row.uuid)} // THIS IS THE KEY CHANGE
checked={selectedDeviceUuids.includes(
row.device_uuid ?? ""
)}
onChange={(event) =>
handleSelectDevice(event, row.device_uuid ?? "")
}
size="small"
/>
</TableCell>
<TableCell align="center" component="th" scope="row">
{row.uuid}
<TableCell
align="center"
component="th"
scope="row"
id={`device-label-${row.device_uuid}`}
>
{row.tail_number}
</TableCell>
<TableCell align="center">
{row.online ? (
<Check className="m-auto text-green-500" />
) : (
<X className="m-auto text-red-500" />
)}
<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">
{formatDate(row.lastUpdate)}
<div className="flex items-center justify-center">
{formatDate(row.lastUpdate)}
</div>
</TableCell>
<TableCell align="center">
{row.gps ? (
<Check className="m-auto text-green-500" />
) : (
<X className="m-auto text-red-500" />
)}
<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">
{row.media ? (
<Check className="m-auto text-green-500" />
) : (
<X className="m-auto text-red-500" />
)}
<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">
{row.connection ? (
<Check className="m-auto text-green-500" />
) : (
<X className="m-auto text-red-500" />
)}
<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={() => handleReloadStatus(row.uuid)}>
<RotateCcw className="m-auto" />
</button>
<Button
onClick={async (e) => {
e.stopPropagation();
try {
if (
row.device_uuid &&
devices.find((device) => device === row.device_uuid)
) {
await handleReloadStatus(row.device_uuid);
await getDevices();
toast.success("Статус устройства обновлен");
} else {
toast.error("Нет связи с устройством");
}
} catch (error) {
toast.error("Ошибка сервера");
}
}}
title="Перезапросить статус"
size="small"
variant="text"
>
<RotateCcw 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="p" sx={{ mb: 2 }}>
Выбрать снапшот для{" "}
<strong className="text-blue-600">{selectedDevices.length}</strong>{" "}
устройств
<Typography variant="h6" component="h2" sx={{ mb: 1 }}>
Отправить снапшот
</Typography>
<div className="mt-5 flex flex-col gap-2 max-h-[300px] overflow-y-auto">
{snapshots && snapshots.length > 0 ? (
snapshots.map((snapshot) => (
<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)}
sx={{
p: 1.5, // Adjust padding
borderRadius: 2, // Adjust border radius
backgroundColor: "white", // Ensure background is white
"&:hover": {
backgroundColor: "grey.100", // Light hover effect
},
}}
key={snapshot.ID}
sx={{ justifyContent: "flex-start" }}
>
{snapshot.Name}
</Button>
@ -280,6 +414,15 @@ export const DevicesTable = observer(() => {
</Typography>
)}
</div>
<Button
onClick={toggleSendSnapshotModal}
color="inherit"
variant="outlined"
sx={{ mt: 3 }}
fullWidth
>
Отмена
</Button>
</Modal>
</>
);

View File

@ -9,7 +9,10 @@ export interface MediaData {
filename?: string;
}
export function MediaViewer({ media }: Readonly<{ media?: MediaData }>) {
export function MediaViewer({
media,
className,
}: Readonly<{ media?: MediaData; className?: string }>) {
const token = localStorage.getItem("token");
return (
<Box
@ -22,6 +25,7 @@ export function MediaViewer({ media }: Readonly<{ media?: MediaData }>) {
justifyContent: "center",
margin: "0 auto",
}}
className={className}
>
{media?.media_type === 1 && (
<img