555 lines
17 KiB
TypeScript
555 lines
17 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, 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,
|
||
ArticleSelectOrCreateDialog,
|
||
SelectMediaDialog,
|
||
selectedCityStore,
|
||
UploadMediaDialog,
|
||
} 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("10");
|
||
const [scaleMax, setScaleMax] = useState("100");
|
||
const [routeName, setRouteName] = 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 [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
|
||
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||
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);
|
||
articlesStore.getArticleList();
|
||
};
|
||
|
||
const handleVideoSelect = (media: {
|
||
id: string;
|
||
filename: string;
|
||
media_name?: string;
|
||
media_type: number;
|
||
}) => {
|
||
setVideoPreview(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;
|
||
}) => {
|
||
setVideoPreview(media.id);
|
||
setIsUploadVideoDialogOpen(false);
|
||
setFileToUpload(null);
|
||
};
|
||
|
||
const handleVideoPreviewClick = () => {
|
||
setIsVideoPreviewOpen(true);
|
||
};
|
||
|
||
const handleCreateRoute = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
|
||
if (!routeName.trim()) {
|
||
toast.error("Заполните название маршрута");
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
if (!carrier) {
|
||
toast.error("Выберите перевозчика");
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
if (!routeNumber.trim()) {
|
||
toast.error("Заполните номер маршрута");
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
if (!govRouteNumber.trim()) {
|
||
toast.error("Заполните номер маршрута в Говорящем Городе");
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
if (!governorAppeal) {
|
||
toast.error("Выберите статью для обращения к пассажирам");
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
|
||
const validationResult = validateCoordinates(routeCoords);
|
||
if (validationResult !== true) {
|
||
toast.error(validationResult);
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
|
||
const scale_min = scaleMin ? Number(scaleMin) : null;
|
||
const scale_max = scaleMax ? Number(scaleMax) : null;
|
||
|
||
if (
|
||
scale_min === 0 ||
|
||
scale_max === 0 ||
|
||
scale_min === null ||
|
||
scale_max === null
|
||
) {
|
||
toast.error("Масштабы не могут быть равны 0");
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
|
||
if (
|
||
scale_min !== null &&
|
||
scale_max !== null &&
|
||
scale_max !== undefined &&
|
||
scale_min > scale_max
|
||
) {
|
||
toast.error("Максимальный масштаб не может быть меньше минимального");
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
|
||
const carrier_id = Number(carrier);
|
||
const governor_appeal = Number(governorAppeal);
|
||
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 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_name: routeName,
|
||
route_direction,
|
||
scale_min: scale_min !== null ? scale_min : 0,
|
||
scale_max: scale_max !== null ? scale_max : 0,
|
||
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">
|
||
<TextField
|
||
className="w-full"
|
||
label="Название маршрута"
|
||
required
|
||
value={routeName}
|
||
onChange={(e) => setRouteName(e.target.value)}
|
||
/>
|
||
<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)}
|
||
/>
|
||
|
||
<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={videoPreview}
|
||
onVideoClick={handleVideoPreviewClick}
|
||
onDeleteVideoClick={() => {
|
||
setVideoPreview("");
|
||
}}
|
||
onSelectVideoClick={handleVideoFileSelect}
|
||
className="w-full"
|
||
/>
|
||
|
||
<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="Масштаб (мин)"
|
||
type="number"
|
||
value={scaleMin}
|
||
onChange={(e) => {
|
||
let value = e.target.value;
|
||
if (Number(value) > 297) {
|
||
value = "297";
|
||
}
|
||
|
||
if (Number(value) < 10) {
|
||
value = "10";
|
||
}
|
||
|
||
setScaleMin(value);
|
||
if (value && scaleMax && Number(value) > Number(scaleMax)) {
|
||
setScaleMax(value);
|
||
}
|
||
}}
|
||
error={
|
||
scaleMin !== "" &&
|
||
scaleMax !== "" &&
|
||
Number(scaleMin) > Number(scaleMax)
|
||
}
|
||
required
|
||
helperText={
|
||
scaleMin !== "" &&
|
||
scaleMax !== "" &&
|
||
Number(scaleMin) > Number(scaleMax)
|
||
? "Минимальный масштаб не может быть больше максимального"
|
||
: ""
|
||
}
|
||
/>
|
||
<TextField
|
||
className="w-full"
|
||
label="Масштаб (макс)"
|
||
type="number"
|
||
value={scaleMax}
|
||
required
|
||
onChange={(e) => {
|
||
if (Number(e.target.value) > 300) {
|
||
e.target.value = "300";
|
||
}
|
||
|
||
const value = e.target.value;
|
||
setScaleMax(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>
|
||
<ArticleSelectOrCreateDialog
|
||
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>
|
||
)}
|
||
<UploadMediaDialog
|
||
open={isUploadVideoDialogOpen}
|
||
onClose={() => {
|
||
setIsUploadVideoDialogOpen(false);
|
||
setFileToUpload(null);
|
||
}}
|
||
hardcodeType="video_preview"
|
||
contextObjectName={routeName || "Маршрут"}
|
||
contextType="sight"
|
||
initialFile={fileToUpload || undefined}
|
||
afterUpload={handleVideoUpload}
|
||
/>
|
||
</Paper>
|
||
);
|
||
});
|