489 lines
16 KiB
TypeScript
489 lines
16 KiB
TypeScript
import {
|
||
Button,
|
||
Paper,
|
||
TextField,
|
||
Select,
|
||
MenuItem,
|
||
FormControl,
|
||
InputLabel,
|
||
Typography,
|
||
Box,
|
||
Dialog,
|
||
DialogTitle,
|
||
DialogContent,
|
||
DialogActions,
|
||
} from "@mui/material";
|
||
import { MediaViewer } from "@widgets";
|
||
import { observer } from "mobx-react-lite";
|
||
import { ArrowLeft, Loader2, Save, Plus } from "lucide-react";
|
||
import { useEffect, useState, useMemo } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { toast } from "react-toastify";
|
||
import { carrierStore } from "../../../shared/store/CarrierStore";
|
||
import { articlesStore } from "../../../shared/store/ArticlesStore";
|
||
import { Route, routeStore } from "../../../shared/store/RouteStore";
|
||
import {
|
||
languageStore,
|
||
SelectArticleModal,
|
||
SelectMediaDialog,
|
||
selectedCityStore,
|
||
} from "@shared";
|
||
|
||
export const RouteCreatePage = observer(() => {
|
||
const navigate = useNavigate();
|
||
const [carrier, setCarrier] = useState<string>("");
|
||
const [routeNumber, setRouteNumber] = useState("");
|
||
const [routeCoords, setRouteCoords] = useState("");
|
||
const [govRouteNumber, setGovRouteNumber] = useState("");
|
||
const [governorAppeal, setGovernorAppeal] = useState<string>("");
|
||
const [direction, setDirection] = useState("backward");
|
||
const [scaleMin, setScaleMin] = useState("");
|
||
const [scaleMax, setScaleMax] = useState("");
|
||
const [turn, setTurn] = useState("");
|
||
const [centerLat, setCenterLat] = useState("");
|
||
const [centerLng, setCenterLng] = useState("");
|
||
const [videoPreview, setVideoPreview] = useState<string>("");
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
|
||
useState(false);
|
||
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||
const { language } = languageStore;
|
||
|
||
useEffect(() => {
|
||
carrierStore.getCarriers(language);
|
||
articlesStore.getArticleList();
|
||
}, [language]);
|
||
|
||
// Фильтруем перевозчиков только из выбранного города
|
||
const filteredCarriers = useMemo(() => {
|
||
const carriers =
|
||
carrierStore.carriers[language as keyof typeof carrierStore.carriers]
|
||
.data || [];
|
||
|
||
if (!selectedCityStore.selectedCityId) {
|
||
return carriers;
|
||
}
|
||
|
||
return carriers.filter(
|
||
(carrier: any) => carrier.city_id === selectedCityStore.selectedCityId
|
||
);
|
||
}, [carrierStore.carriers, language, selectedCityStore.selectedCityId]);
|
||
|
||
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 handleArticleSelect = (articleId: number) => {
|
||
setGovernorAppeal(articleId.toString());
|
||
setIsSelectArticleDialogOpen(false);
|
||
};
|
||
|
||
const handleVideoSelect = (media: {
|
||
id: string;
|
||
filename: string;
|
||
media_name?: string;
|
||
media_type: number;
|
||
}) => {
|
||
setVideoPreview(media.id);
|
||
setIsSelectVideoDialogOpen(false);
|
||
};
|
||
|
||
const handleVideoPreviewClick = () => {
|
||
setIsVideoPreviewOpen(true);
|
||
};
|
||
|
||
const handleCreateRoute = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
// Преобразуем значения в нужные типы
|
||
const carrier_id = Number(carrier);
|
||
const governor_appeal = Number(governorAppeal);
|
||
const scale_min = scaleMin ? Number(scaleMin) : undefined;
|
||
const scale_max = scaleMax ? Number(scaleMax) : undefined;
|
||
const rotate = turn ? Number(turn) : undefined;
|
||
const center_latitude = centerLat ? Number(centerLat) : undefined;
|
||
const center_longitude = centerLng ? Number(centerLng) : undefined;
|
||
const route_direction = direction === "forward";
|
||
|
||
const validationResult = validateCoordinates(routeCoords);
|
||
if (validationResult !== true) {
|
||
toast.error(validationResult);
|
||
return;
|
||
}
|
||
|
||
// Координаты маршрута как массив массивов чисел
|
||
const path = routeCoords
|
||
.trim()
|
||
.split("\n")
|
||
.map((line) => {
|
||
const [lat, lon] = line
|
||
.trim()
|
||
.split(/[\s,]+/)
|
||
.map(Number);
|
||
return [lat, lon];
|
||
});
|
||
|
||
// Собираем объект маршрута
|
||
const newRoute: Partial<Route> = {
|
||
carrier:
|
||
carrierStore.carriers[
|
||
language as keyof typeof carrierStore.carriers
|
||
].data?.find((c: any) => c.id === carrier_id)?.full_name || "",
|
||
carrier_id,
|
||
route_number: routeNumber,
|
||
route_sys_number: govRouteNumber,
|
||
governor_appeal,
|
||
route_direction,
|
||
scale_min,
|
||
scale_max,
|
||
rotate,
|
||
center_latitude,
|
||
center_longitude,
|
||
path,
|
||
video_preview:
|
||
videoPreview && videoPreview !== "" ? videoPreview : undefined,
|
||
};
|
||
|
||
await routeStore.createRoute(newRoute);
|
||
toast.success("Маршрут успешно создан");
|
||
navigate(-1);
|
||
} catch (error) {
|
||
console.error(error);
|
||
toast.error("Произошла ошибка при создании маршрута");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
// Получаем название выбранной статьи для отображения
|
||
const selectedArticle = articlesStore.articleList.ru.data.find(
|
||
(article) => article.id === Number(governorAppeal)
|
||
);
|
||
|
||
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">
|
||
<FormControl fullWidth required>
|
||
<InputLabel>Выберите перевозчика</InputLabel>
|
||
<Select
|
||
value={carrier}
|
||
label="Выберите перевозчика"
|
||
onChange={(e) => setCarrier(e.target.value as string)}
|
||
disabled={filteredCarriers.length === 0}
|
||
>
|
||
<MenuItem value="">Не выбрано</MenuItem>
|
||
{filteredCarriers.map((carrier: any) => (
|
||
<MenuItem key={carrier.id} value={carrier.id}>
|
||
{carrier.full_name}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
</FormControl>
|
||
<TextField
|
||
className="w-full"
|
||
label="Номер маршрута"
|
||
required
|
||
value={routeNumber}
|
||
onChange={(e) => setRouteNumber(e.target.value)}
|
||
/>
|
||
<TextField
|
||
className="w-full"
|
||
label="Координаты маршрута"
|
||
multiline
|
||
minRows={2}
|
||
maxRows={10}
|
||
value={routeCoords}
|
||
onChange={(e) => {
|
||
const newValue = e.target.value;
|
||
setRouteCoords(newValue);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
const lines = routeCoords.split("\n");
|
||
const lastLine = lines[lines.length - 1];
|
||
|
||
// Если мы на последней строке и она не пустая
|
||
if (lastLine && lastLine.trim()) {
|
||
e.preventDefault();
|
||
const newValue = routeCoords + "\n";
|
||
setRouteCoords(newValue);
|
||
}
|
||
}
|
||
}}
|
||
error={validateCoordinates(routeCoords) !== true}
|
||
helperText={
|
||
typeof validateCoordinates(routeCoords) === "string"
|
||
? validateCoordinates(routeCoords)
|
||
: "Формат: широта долгота"
|
||
}
|
||
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={govRouteNumber}
|
||
onChange={(e) => setGovRouteNumber(e.target.value)}
|
||
/>
|
||
|
||
{/* Заменяем Select на кнопку для выбора статьи */}
|
||
<Box className="flex flex-col gap-2">
|
||
<label className="text-sm font-medium text-gray-700">
|
||
Обращение к пассажирам
|
||
</label>
|
||
<Box className="flex gap-2">
|
||
<TextField
|
||
className="flex-1"
|
||
value={selectedArticle?.heading || "Статья не выбрана"}
|
||
placeholder="Выберите статью"
|
||
disabled
|
||
sx={{
|
||
"& .MuiInputBase-input": {
|
||
color: selectedArticle ? "inherit" : "#999",
|
||
},
|
||
}}
|
||
/>
|
||
<Button
|
||
variant="outlined"
|
||
onClick={() => setIsSelectArticleDialogOpen(true)}
|
||
startIcon={<Plus size={16} />}
|
||
sx={{ minWidth: "auto", px: 2 }}
|
||
>
|
||
Выбрать
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* Селектор видеозаставки */}
|
||
<Box className="flex flex-col gap-2">
|
||
<label className="text-sm font-medium text-gray-700">
|
||
Видеозаставка
|
||
</label>
|
||
<Box className="flex gap-2">
|
||
<Box
|
||
className="flex-1"
|
||
onClick={handleVideoPreviewClick}
|
||
sx={{
|
||
cursor:
|
||
videoPreview && videoPreview !== "" ? "pointer" : "default",
|
||
}}
|
||
>
|
||
<Box
|
||
className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4"
|
||
sx={{
|
||
"& .MuiInputBase-input": {
|
||
color:
|
||
videoPreview && videoPreview !== ""
|
||
? "inherit"
|
||
: "#999",
|
||
cursor:
|
||
videoPreview && videoPreview !== ""
|
||
? "pointer"
|
||
: "default",
|
||
},
|
||
}}
|
||
>
|
||
<Typography variant="body1" className="text-sm">
|
||
{videoPreview && videoPreview !== ""
|
||
? "Видео выбрано"
|
||
: "Видео не выбрано"}
|
||
</Typography>
|
||
{videoPreview && videoPreview !== "" && (
|
||
<Box
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setVideoPreview("");
|
||
}}
|
||
sx={{
|
||
cursor: "pointer",
|
||
color: "#999",
|
||
"&:hover": {
|
||
color: "#666",
|
||
},
|
||
}}
|
||
>
|
||
<Typography variant="body1" className="text-lg font-bold">
|
||
×
|
||
</Typography>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
<Button
|
||
variant="outlined"
|
||
onClick={() => setIsSelectVideoDialogOpen(true)}
|
||
startIcon={<Plus size={16} />}
|
||
sx={{ minWidth: "auto", px: 2 }}
|
||
>
|
||
Выбрать
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
|
||
<FormControl fullWidth required>
|
||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||
<Select
|
||
value={direction}
|
||
label="Прямой/обратный маршрут"
|
||
onChange={(e) => setDirection(e.target.value)}
|
||
>
|
||
<MenuItem value="forward">Прямой</MenuItem>
|
||
<MenuItem value="backward">Обратный</MenuItem>
|
||
</Select>
|
||
</FormControl>
|
||
<TextField
|
||
className="w-full"
|
||
label="Масштаб (мин)"
|
||
value={scaleMin}
|
||
onChange={(e) => setScaleMin(e.target.value)}
|
||
/>
|
||
<TextField
|
||
className="w-full"
|
||
label="Масштаб (макс)"
|
||
value={scaleMax}
|
||
onChange={(e) => setScaleMax(e.target.value)}
|
||
/>
|
||
<TextField
|
||
className="w-full"
|
||
label="Поворот"
|
||
value={turn}
|
||
onChange={(e) => setTurn(e.target.value)}
|
||
/>
|
||
<TextField
|
||
className="w-full"
|
||
label="Центр. широта"
|
||
value={centerLat}
|
||
onChange={(e) => setCenterLat(e.target.value)}
|
||
/>
|
||
<TextField
|
||
className="w-full"
|
||
label="Центр. долгота"
|
||
value={centerLng}
|
||
onChange={(e) => setCenterLng(e.target.value)}
|
||
/>
|
||
</Box>
|
||
<div className="flex w-full justify-end">
|
||
<Button
|
||
variant="contained"
|
||
color="primary"
|
||
className="w-min flex gap-2 items-center"
|
||
startIcon={<Save size={20} />}
|
||
onClick={handleCreateRoute}
|
||
disabled={isLoading}
|
||
>
|
||
{isLoading ? (
|
||
<Loader2 size={20} className="animate-spin" />
|
||
) : (
|
||
"Сохранить"
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Модальное окно выбора статьи */}
|
||
<SelectArticleModal
|
||
open={isSelectArticleDialogOpen}
|
||
onClose={() => setIsSelectArticleDialogOpen(false)}
|
||
onSelectArticle={handleArticleSelect}
|
||
/>
|
||
|
||
{/* Модальное окно выбора видео */}
|
||
<SelectMediaDialog
|
||
open={isSelectVideoDialogOpen}
|
||
onClose={() => setIsSelectVideoDialogOpen(false)}
|
||
onSelectMedia={handleVideoSelect}
|
||
mediaType={2}
|
||
/>
|
||
|
||
{/* Модальное окно предпросмотра видео */}
|
||
{videoPreview && videoPreview !== "" && (
|
||
<Dialog
|
||
open={isVideoPreviewOpen}
|
||
onClose={() => setIsVideoPreviewOpen(false)}
|
||
maxWidth="md"
|
||
fullWidth
|
||
>
|
||
<DialogTitle>Предпросмотр видео</DialogTitle>
|
||
<DialogContent>
|
||
<Box className="flex justify-center items-center p-4">
|
||
<MediaViewer
|
||
media={{
|
||
id: videoPreview,
|
||
media_type: 2,
|
||
filename: "video_preview",
|
||
}}
|
||
/>
|
||
</Box>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={() => setIsVideoPreviewOpen(false)}>
|
||
Закрыть
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
)}
|
||
</Paper>
|
||
);
|
||
});
|