604 lines
19 KiB
TypeScript
604 lines
19 KiB
TypeScript
import {
|
||
Button,
|
||
Paper,
|
||
TextField,
|
||
Select,
|
||
MenuItem,
|
||
FormControl,
|
||
InputLabel,
|
||
Typography,
|
||
Box,
|
||
Dialog,
|
||
DialogTitle,
|
||
DialogContent,
|
||
DialogActions,
|
||
} from "@mui/material";
|
||
import { MediaViewer, VideoPreviewCard } from "@widgets";
|
||
import { observer } from "mobx-react-lite";
|
||
import { ArrowLeft, Copy, Save, Plus } from "lucide-react";
|
||
import { useEffect, useState } from "react";
|
||
import { useNavigate, useParams } from "react-router-dom";
|
||
|
||
import { carrierStore } from "../../../shared/store/CarrierStore";
|
||
import { articlesStore } from "../../../shared/store/ArticlesStore";
|
||
import {
|
||
routeStore,
|
||
languageStore,
|
||
ArticleSelectOrCreateDialog,
|
||
SelectMediaDialog,
|
||
UploadMediaDialog,
|
||
} from "@shared";
|
||
import { toast } from "react-toastify";
|
||
import { stationsStore } from "@shared";
|
||
import { LinkedItems } from "../LinekedStations";
|
||
|
||
export const RouteEditPage = observer(() => {
|
||
const navigate = useNavigate();
|
||
const { id } = useParams();
|
||
const { editRouteData, copyRouteAction } = routeStore;
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
|
||
useState(false);
|
||
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
|
||
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||
const { language } = languageStore;
|
||
const [coordinates, setCoordinates] = useState<string>("");
|
||
|
||
useEffect(() => {
|
||
const fetchData = async () => {
|
||
const response = await routeStore.getRoute(Number(id));
|
||
routeStore.setEditRouteData(response);
|
||
languageStore.setLanguage("ru");
|
||
};
|
||
fetchData();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const fetchData = async () => {
|
||
carrierStore.getCarriers(language);
|
||
stationsStore.getStations();
|
||
articlesStore.getArticleList();
|
||
};
|
||
fetchData();
|
||
}, [id, language]);
|
||
|
||
useEffect(() => {
|
||
if (editRouteData.path && editRouteData.path.length > 0) {
|
||
const formattedPath = editRouteData.path
|
||
.map((coords) => coords.join(" "))
|
||
.join("\n");
|
||
setCoordinates(formattedPath);
|
||
}
|
||
}, [editRouteData.path]);
|
||
|
||
const handleSave = async () => {
|
||
// Валидация обязательных полей
|
||
if (!editRouteData.route_name?.trim()) {
|
||
toast.error("Заполните название маршрута");
|
||
return;
|
||
}
|
||
if (!editRouteData.carrier_id) {
|
||
toast.error("Выберите перевозчика");
|
||
return;
|
||
}
|
||
if (!editRouteData.route_number?.trim()) {
|
||
toast.error("Заполните номер маршрута");
|
||
return;
|
||
}
|
||
if (!editRouteData.route_sys_number?.trim()) {
|
||
toast.error("Заполните номер маршрута в Говорящем Городе");
|
||
return;
|
||
}
|
||
if (!editRouteData.governor_appeal) {
|
||
toast.error("Выберите статью для обращения к пассажирам");
|
||
return;
|
||
}
|
||
|
||
const validationResult = validateCoordinates(coordinates);
|
||
if (validationResult !== true) {
|
||
toast.error(validationResult);
|
||
return;
|
||
}
|
||
|
||
// Валидация масштабов
|
||
if (
|
||
editRouteData.scale_min !== null &&
|
||
editRouteData.scale_min !== undefined &&
|
||
editRouteData.scale_max !== null &&
|
||
editRouteData.scale_max !== undefined &&
|
||
editRouteData.scale_min > editRouteData.scale_max
|
||
) {
|
||
toast.error("Максимальный масштаб не может быть меньше минимального");
|
||
return;
|
||
}
|
||
|
||
if (
|
||
editRouteData.scale_min === 0 ||
|
||
editRouteData.scale_max === 0 ||
|
||
editRouteData.scale_min === null ||
|
||
editRouteData.scale_max === null
|
||
) {
|
||
toast.error("Масштабы не могут быть равны 0");
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
|
||
setIsLoading(true);
|
||
try {
|
||
await routeStore.editRoute(Number(id));
|
||
toast.success("Маршрут успешно сохранен");
|
||
} catch (error) {
|
||
console.error(error);
|
||
toast.error("Произошла ошибка при сохранении маршрута");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const validateCoordinates = (value: string) => {
|
||
try {
|
||
const lines = value.trim().split("\n");
|
||
const coordinates = lines.map((line) => {
|
||
const [lat, lon] = line
|
||
.trim()
|
||
.split(/[\s,]+/)
|
||
.map(Number);
|
||
return [lat, lon];
|
||
});
|
||
|
||
if (coordinates.length === 0) {
|
||
return "Введите хотя бы одну пару координат";
|
||
}
|
||
|
||
if (
|
||
!coordinates.every(
|
||
(point) => Array.isArray(point) && point.length === 2
|
||
)
|
||
) {
|
||
return "Каждая строка должна содержать две координаты";
|
||
}
|
||
|
||
if (
|
||
!coordinates.every((point) =>
|
||
point.every((coord) => !isNaN(coord) && typeof coord === "number")
|
||
)
|
||
) {
|
||
return "Координаты должны быть числами";
|
||
}
|
||
|
||
return true;
|
||
} catch {
|
||
return "Неверный формат координат";
|
||
}
|
||
};
|
||
|
||
const handleCopy = async () => {
|
||
await copyRouteAction(Number(id));
|
||
toast.success("Маршрут успешно скопирован");
|
||
};
|
||
|
||
const handleArticleSelect = (articleId: number) => {
|
||
routeStore.setEditRouteData({
|
||
governor_appeal: articleId,
|
||
});
|
||
setIsSelectArticleDialogOpen(false);
|
||
// Обновляем список статей после создания новой
|
||
articlesStore.getArticleList();
|
||
};
|
||
|
||
const handleVideoSelect = (media: {
|
||
id: string;
|
||
filename: string;
|
||
media_name?: string;
|
||
media_type: number;
|
||
}) => {
|
||
routeStore.setEditRouteData({
|
||
video_preview: media.id,
|
||
});
|
||
setIsSelectVideoDialogOpen(false);
|
||
};
|
||
|
||
const handleVideoFileSelect = (file?: File) => {
|
||
if (file) {
|
||
setFileToUpload(file);
|
||
setIsUploadVideoDialogOpen(true);
|
||
} else {
|
||
setIsSelectVideoDialogOpen(true);
|
||
}
|
||
};
|
||
|
||
const handleVideoUpload = (media: {
|
||
id: string;
|
||
filename: string;
|
||
media_name?: string;
|
||
media_type: number;
|
||
}) => {
|
||
routeStore.setEditRouteData({
|
||
video_preview: media.id,
|
||
});
|
||
setIsUploadVideoDialogOpen(false);
|
||
setFileToUpload(null);
|
||
};
|
||
|
||
const handleVideoPreviewClick = () => {
|
||
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
|
||
setIsVideoPreviewOpen(true);
|
||
}
|
||
};
|
||
|
||
// Получаем название выбранной статьи для отображения
|
||
const selectedArticle = articlesStore.articleList.ru.data.find(
|
||
(article) => article.id === editRouteData.governor_appeal
|
||
);
|
||
|
||
return (
|
||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||
<div className="flex items-center gap-4">
|
||
<button
|
||
className="flex items-center gap-2"
|
||
onClick={() => navigate(-1)}
|
||
>
|
||
<ArrowLeft size={20} />
|
||
Назад
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-10 w-full items-end">
|
||
<Box className="flex flex-col gap-6 w-full">
|
||
<TextField
|
||
className="w-full"
|
||
label="Название маршрута"
|
||
required
|
||
value={editRouteData.route_name || ""}
|
||
onChange={(e) =>
|
||
routeStore.setEditRouteData({
|
||
route_name: e.target.value,
|
||
})
|
||
}
|
||
/>
|
||
<FormControl fullWidth required>
|
||
<InputLabel>Выберите перевозчика</InputLabel>
|
||
<Select
|
||
value={editRouteData.carrier_id}
|
||
label="Выберите перевозчика"
|
||
onChange={(e) =>
|
||
routeStore.setEditRouteData({
|
||
carrier_id: Number(e.target.value),
|
||
carrier:
|
||
carrierStore.carriers[
|
||
language as keyof typeof carrierStore.carriers
|
||
].data?.find((c) => c.id === Number(e.target.value))
|
||
?.full_name || "",
|
||
})
|
||
}
|
||
disabled={
|
||
carrierStore.carriers[
|
||
language as keyof typeof carrierStore.carriers
|
||
].data?.length === 0
|
||
}
|
||
>
|
||
<MenuItem value="">Не выбрано</MenuItem>
|
||
{carrierStore.carriers[
|
||
language as keyof typeof carrierStore.carriers
|
||
].data?.map((carrier) => (
|
||
<MenuItem key={carrier.id} value={carrier.id}>
|
||
{carrier.full_name}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
</FormControl>
|
||
<TextField
|
||
className="w-full"
|
||
label="Номер маршрута"
|
||
required
|
||
value={editRouteData.route_number || ""}
|
||
onChange={(e) =>
|
||
routeStore.setEditRouteData({
|
||
route_number: e.target.value,
|
||
})
|
||
}
|
||
/>
|
||
<TextField
|
||
className="w-full"
|
||
label="Координаты маршрута"
|
||
multiline
|
||
minRows={2}
|
||
maxRows={10}
|
||
value={coordinates}
|
||
onChange={(e) => {
|
||
const newValue = e.target.value;
|
||
setCoordinates(newValue);
|
||
|
||
const validationResult = validateCoordinates(newValue);
|
||
if (validationResult === true) {
|
||
const lines = newValue.trim().split("\n");
|
||
const path = lines.map((line) => {
|
||
const [lat, lon] = line
|
||
.trim()
|
||
.split(/[\s,]+/)
|
||
.map(Number);
|
||
return [lat, lon];
|
||
});
|
||
routeStore.setEditRouteData({ path });
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
const lines = coordinates.split("\n");
|
||
const lastLine = lines[lines.length - 1];
|
||
|
||
if (lastLine && lastLine.trim()) {
|
||
e.preventDefault();
|
||
const newValue = coordinates + "\n";
|
||
setCoordinates(newValue);
|
||
}
|
||
}
|
||
}}
|
||
error={validateCoordinates(coordinates) !== true}
|
||
helperText={
|
||
typeof validateCoordinates(coordinates) === "string"
|
||
? validateCoordinates(coordinates)
|
||
: "Формат: широта долгота"
|
||
}
|
||
placeholder="55.7558 37.6173 55.7539 37.6208"
|
||
sx={{
|
||
"& .MuiInputBase-root": {
|
||
maxHeight: "500px",
|
||
overflow: "auto",
|
||
},
|
||
"& .MuiInputBase-input": {
|
||
fontFamily: "monospace",
|
||
fontSize: "0.8rem",
|
||
lineHeight: "1.2",
|
||
padding: "8px 12px",
|
||
},
|
||
"& .MuiFormHelperText-root": {
|
||
fontSize: "0.75rem",
|
||
marginTop: "2px",
|
||
},
|
||
}}
|
||
/>
|
||
<TextField
|
||
className="w-full"
|
||
label="Номер маршрута в Говорящем Городе"
|
||
required
|
||
value={editRouteData.route_sys_number || ""}
|
||
onChange={(e) =>
|
||
routeStore.setEditRouteData({
|
||
route_sys_number: e.target.value,
|
||
})
|
||
}
|
||
/>
|
||
|
||
<FormControl fullWidth required>
|
||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||
<Select
|
||
value={editRouteData.route_direction ? "forward" : "backward"}
|
||
label="Прямой/обратный маршрут"
|
||
onChange={(e) =>
|
||
routeStore.setEditRouteData({
|
||
route_direction: e.target.value === "forward",
|
||
})
|
||
}
|
||
>
|
||
<MenuItem value="forward">Прямой</MenuItem>
|
||
<MenuItem value="backward">Обратный</MenuItem>
|
||
</Select>
|
||
</FormControl>
|
||
<TextField
|
||
className="w-full"
|
||
label="Масштаб (мин)"
|
||
type="number"
|
||
value={editRouteData.scale_min ?? ""}
|
||
onChange={(e) => {
|
||
const value =
|
||
e.target.value === "" ? null : parseFloat(e.target.value);
|
||
routeStore.setEditRouteData({
|
||
scale_min: value,
|
||
});
|
||
// Если максимальный масштаб стал меньше минимального, обновляем его
|
||
if (
|
||
value !== null &&
|
||
editRouteData.scale_max !== null &&
|
||
editRouteData.scale_max !== undefined &&
|
||
value > editRouteData.scale_max
|
||
) {
|
||
routeStore.setEditRouteData({
|
||
scale_max: value,
|
||
});
|
||
}
|
||
}}
|
||
required
|
||
/>
|
||
<TextField
|
||
className="w-full"
|
||
required
|
||
label="Масштаб (макс)"
|
||
type="number"
|
||
value={editRouteData.scale_max ?? ""}
|
||
onChange={(e) =>
|
||
routeStore.setEditRouteData({
|
||
scale_max:
|
||
e.target.value === "" ? null : parseFloat(e.target.value),
|
||
})
|
||
}
|
||
error={
|
||
editRouteData.scale_min !== null &&
|
||
editRouteData.scale_min !== undefined &&
|
||
editRouteData.scale_max !== null &&
|
||
editRouteData.scale_max !== undefined &&
|
||
editRouteData.scale_max < editRouteData.scale_min
|
||
}
|
||
helperText={
|
||
editRouteData.scale_min !== null &&
|
||
editRouteData.scale_min !== undefined &&
|
||
editRouteData.scale_max !== null &&
|
||
editRouteData.scale_max !== undefined &&
|
||
editRouteData.scale_max < editRouteData.scale_min
|
||
? "Максимальный масштаб не может быть меньше минимального"
|
||
: ""
|
||
}
|
||
/>
|
||
<TextField
|
||
className="w-full"
|
||
label="Поворот"
|
||
value={editRouteData.rotate ?? ""}
|
||
onChange={(e) =>
|
||
routeStore.setEditRouteData({
|
||
rotate:
|
||
e.target.value === "" ? null : parseFloat(e.target.value),
|
||
})
|
||
}
|
||
/>
|
||
<TextField
|
||
className="w-full"
|
||
label="Центр. широта"
|
||
value={editRouteData.center_latitude ?? ""}
|
||
type="text"
|
||
onChange={(e) =>
|
||
routeStore.setEditRouteData({
|
||
center_latitude: e.target.value,
|
||
})
|
||
}
|
||
/>
|
||
<TextField
|
||
className="w-full"
|
||
label="Центр. долгота"
|
||
value={editRouteData.center_longitude ?? ""}
|
||
type="text"
|
||
onChange={(e) =>
|
||
routeStore.setEditRouteData({
|
||
center_longitude: e.target.value,
|
||
})
|
||
}
|
||
/>
|
||
|
||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||
Обращение к пассажирам
|
||
</Typography>
|
||
<Box className="flex gap-2">
|
||
<TextField
|
||
className="flex-1"
|
||
value={selectedArticle?.heading || "Статья не выбрана"}
|
||
placeholder="Выберите статью"
|
||
disabled
|
||
fullWidth
|
||
sx={{
|
||
"& .MuiInputBase-input": {
|
||
color: selectedArticle ? "inherit" : "#999",
|
||
},
|
||
}}
|
||
/>
|
||
<Button
|
||
variant="outlined"
|
||
onClick={() => setIsSelectArticleDialogOpen(true)}
|
||
startIcon={<Plus size={16} />}
|
||
sx={{ minWidth: "auto", px: 2 }}
|
||
>
|
||
Выбрать
|
||
</Button>
|
||
</Box>
|
||
|
||
<VideoPreviewCard
|
||
title="Видеозаставка"
|
||
videoId={editRouteData.video_preview}
|
||
onVideoClick={handleVideoPreviewClick}
|
||
onDeleteVideoClick={() => {
|
||
routeStore.setEditRouteData({ video_preview: "" });
|
||
}}
|
||
onSelectVideoClick={handleVideoFileSelect}
|
||
className="w-full"
|
||
/>
|
||
</Box>
|
||
|
||
<LinkedItems
|
||
parentId={id || ""}
|
||
type="edit"
|
||
dragAllowed={true}
|
||
fields={[
|
||
{ label: "Название", data: "name" },
|
||
{ label: "Описание", data: "description" },
|
||
]}
|
||
onUpdate={() => {
|
||
routeStore.getRoute(Number(id));
|
||
}}
|
||
routeDirection={editRouteData.route_direction}
|
||
/>
|
||
|
||
<div className="flex w-full justify-between">
|
||
<Button
|
||
variant="contained"
|
||
color="primary"
|
||
className="w-min flex gap-2 items-center"
|
||
startIcon={<Copy size={20} />}
|
||
onClick={handleCopy}
|
||
disabled={isLoading}
|
||
>
|
||
Скопировать
|
||
</Button>
|
||
|
||
<Button
|
||
variant="contained"
|
||
color="primary"
|
||
className="w-min flex gap-2 items-center"
|
||
startIcon={<Save size={20} />}
|
||
onClick={handleSave}
|
||
disabled={isLoading}
|
||
>
|
||
Сохранить
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<ArticleSelectOrCreateDialog
|
||
open={isSelectArticleDialogOpen}
|
||
onClose={() => setIsSelectArticleDialogOpen(false)}
|
||
onSelectArticle={handleArticleSelect}
|
||
/>
|
||
<SelectMediaDialog
|
||
open={isSelectVideoDialogOpen}
|
||
onClose={() => setIsSelectVideoDialogOpen(false)}
|
||
onSelectMedia={handleVideoSelect}
|
||
mediaType={2}
|
||
/>
|
||
<Dialog
|
||
open={isVideoPreviewOpen}
|
||
onClose={() => setIsVideoPreviewOpen(false)}
|
||
maxWidth="md"
|
||
fullWidth
|
||
>
|
||
<DialogTitle>Предпросмотр видео</DialogTitle>
|
||
<DialogContent>
|
||
<Box className="flex justify-center items-center p-4">
|
||
{editRouteData.video_preview && (
|
||
<MediaViewer
|
||
media={{
|
||
id: editRouteData.video_preview,
|
||
media_type: 2,
|
||
filename: "video_preview",
|
||
}}
|
||
/>
|
||
)}
|
||
</Box>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
<UploadMediaDialog
|
||
open={isUploadVideoDialogOpen}
|
||
onClose={() => {
|
||
setIsUploadVideoDialogOpen(false);
|
||
setFileToUpload(null);
|
||
}}
|
||
hardcodeType="video_preview"
|
||
contextObjectName={editRouteData.route_name || "Маршрут"}
|
||
contextType="sight"
|
||
initialFile={fileToUpload || undefined}
|
||
afterUpload={handleVideoUpload}
|
||
/>
|
||
</Paper>
|
||
);
|
||
});
|