fix: Fix Map page

This commit is contained in:
2025-06-12 22:50:43 +03:00
parent 27cb644242
commit 300ff262ce
41 changed files with 2216 additions and 1055 deletions

View File

@ -0,0 +1,28 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { LanguageSwitcher } from "@widgets";
const ArticleCreatePage: React.FC = () => {
const navigate = useNavigate();
return (
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
<h1 className="text-3xl break-words">Создание статьи</h1>
</div>
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
</div>
);
};
export default ArticleCreatePage;

View File

@ -0,0 +1,44 @@
import React, { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { LanguageSwitcher } from "@widgets";
import { articlesStore, languageStore } from "@shared";
import { observer } from "mobx-react-lite";
const ArticleEditPage: React.FC = observer(() => {
const navigate = useNavigate();
const { id } = useParams();
const { language } = languageStore;
const { articleData, getArticle } = articlesStore;
useEffect(() => {
if (id) {
// Fetch data for all languages
getArticle(parseInt(id), "ru");
getArticle(parseInt(id), "en");
getArticle(parseInt(id), "zh");
}
}, [id]);
return (
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
<h1 className="text-3xl break-words">
{articleData?.ru?.heading || "Редактирование статьи"}
</h1>
</div>
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
</div>
);
});
export default ArticleEditPage;

View File

@ -2,16 +2,18 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { articlesStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Trash2, Eye } from "lucide-react";
import { Trash2, Eye, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { DeleteModal, LanguageSwitcher } from "@widgets";
export const ArticleListPage = observer(() => {
const { articleList, getArticleList } = articlesStore;
const { articleList, getArticleList, deleteArticles } = articlesStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null);
const { language } = languageStore;
const [ids, setIds] = useState<number[]>([]);
useEffect(() => {
getArticleList();
@ -22,6 +24,15 @@ export const ArticleListPage = observer(() => {
field: "heading",
headerName: "Название",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return params.value ? (
params.value
) : (
<div className="flex h-full gap-7 items-center">
<Minus size={20} className="text-red-500" />
</div>
);
},
},
{
@ -59,18 +70,42 @@ export const ArticleListPage = observer(() => {
<LanguageSwitcher />
<div className="w-full">
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Статьи</h1>
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<div className="w-full">
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
/>
</div>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await deleteArticles([parseInt(rowId)]);
getArticleList();
}
setIsDeleteModalOpen(false);
@ -81,6 +116,19 @@ export const ArticleListPage = observer(() => {
setRowId(null);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await deleteArticles(ids);
getArticleList();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

View File

@ -12,21 +12,31 @@ import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { carrierStore, cityStore, mediaStore } from "@shared";
import { carrierStore, cityStore, mediaStore, languageStore } from "@shared";
import { useState, useEffect } from "react";
import { MediaViewer } from "@widgets";
import { MediaViewer, ImageUploadCard, LanguageSwitcher } from "@widgets";
import {
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
export const CarrierCreatePage = observer(() => {
const navigate = useNavigate();
const { language } = languageStore;
const [fullName, setFullName] = useState("");
const [shortName, setShortName] = useState("");
const [cityId, setCityId] = useState<number | null>(null);
const [main_color, setMainColor] = useState("#000000");
const [left_color, setLeftColor] = useState("#ffffff");
const [right_color, setRightColor] = useState("#ff0000");
const [slogan, setSlogan] = useState("");
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
>(null);
useEffect(() => {
cityStore.getCities("ru");
@ -39,11 +49,7 @@ export const CarrierCreatePage = observer(() => {
await carrierStore.createCarrier(
fullName,
shortName,
cityStore.cities.ru.find((c) => c.id === cityId)?.name!,
cityId!,
main_color,
left_color,
right_color,
slogan,
selectedMediaId!
);
@ -56,8 +62,22 @@ export const CarrierCreatePage = observer(() => {
}
};
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setSelectedMediaId(media.id);
};
const selectedMedia = selectedMediaId
? mediaStore.media.find((m) => m.id === selectedMediaId)
: null;
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
@ -69,6 +89,10 @@ export const CarrierCreatePage = observer(() => {
</div>
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
<h1 className="text-3xl break-words">Создание перевозчика</h1>
</div>
<FormControl fullWidth>
<InputLabel>Город</InputLabel>
<Select
@ -77,7 +101,7 @@ export const CarrierCreatePage = observer(() => {
required
onChange={(e) => setCityId(e.target.value as number)}
>
{cityStore.cities.ru.map((city) => (
{cityStore.cities.ru.data.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
@ -101,57 +125,6 @@ export const CarrierCreatePage = observer(() => {
onChange={(e) => setShortName(e.target.value)}
/>
<div className="flex gap-4 w-full ">
<TextField
fullWidth
label="Основной цвет"
value={main_color}
className="flex-1 w-full"
onChange={(e) => setMainColor(e.target.value)}
type="color"
sx={{
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
<TextField
fullWidth
label="Цвет левого виджета"
value={left_color}
className="flex-1 w-full"
onChange={(e) => setLeftColor(e.target.value)}
type="color"
sx={{
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
<TextField
fullWidth
label="Цвет правого виджета"
value={right_color}
className="flex-1 w-full"
onChange={(e) => setRightColor(e.target.value)}
type="color"
sx={{
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
</div>
<TextField
fullWidth
label="Слоган"
@ -159,29 +132,28 @@ export const CarrierCreatePage = observer(() => {
onChange={(e) => setSlogan(e.target.value)}
/>
<div className="w-full flex flex-col gap-4">
<FormControl fullWidth>
<InputLabel>Логотип</InputLabel>
<Select
value={selectedMediaId || ""}
label="Логотип"
required
onChange={(e) => setSelectedMediaId(e.target.value as string)}
>
{mediaStore.media
.filter((media) => media.media_type === 3)
.map((media) => (
<MenuItem key={media.id} value={media.id}>
{media.media_name || media.filename}
</MenuItem>
))}
</Select>
</FormControl>
{selectedMediaId && (
<div className="w-32 h-32">
<MediaViewer media={{ id: selectedMediaId, media_type: 1 }} />
</div>
)}
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Логотип перевозчика"
imageKey="thumbnail"
imageUrl={selectedMedia?.id}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(selectedMedia?.id ?? "");
}}
onDeleteImageClick={() => {
setSelectedMediaId(null);
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("thumbnail");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("thumbnail");
}}
/>
</div>
<Button
@ -200,6 +172,26 @@ export const CarrierCreatePage = observer(() => {
)}
</Button>
</div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={3}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
</Paper>
);
});

View File

@ -14,7 +14,12 @@ import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import { carrierStore, cityStore, mediaStore } from "@shared";
import { useState, useEffect } from "react";
import { MediaViewer } from "@widgets";
import { MediaViewer, ImageUploadCard } from "@widgets";
import {
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
export const CarrierEditPage = observer(() => {
const navigate = useNavigate();
@ -23,22 +28,30 @@ export const CarrierEditPage = observer(() => {
carrierStore;
const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
>(null);
useEffect(() => {
(async () => {
await cityStore.getCities("ru");
await cityStore.getCities("en");
await cityStore.getCities("zh");
await getCarrier(Number(id));
setEditCarrierData(
carrier?.[Number(id)]?.full_name as string,
carrier?.[Number(id)]?.short_name as string,
carrier?.[Number(id)]?.city as string,
carrier?.[Number(id)]?.city_id as number,
carrier?.[Number(id)]?.main_color as string,
carrier?.[Number(id)]?.left_color as string,
carrier?.[Number(id)]?.right_color as string,
carrier?.[Number(id)]?.slogan as string,
carrier?.[Number(id)]?.logo as string
);
cityStore.getCities("ru");
mediaStore.getMedia();
})();
}, [id]);
@ -56,6 +69,26 @@ export const CarrierEditPage = observer(() => {
}
};
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setEditCarrierData(
editCarrierData.full_name,
editCarrierData.short_name,
editCarrierData.city_id,
editCarrierData.slogan,
media.id
);
};
const selectedMedia = editCarrierData.logo
? mediaStore.media.find((m) => m.id === editCarrierData.logo)
: null;
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4">
@ -79,17 +112,13 @@ export const CarrierEditPage = observer(() => {
setEditCarrierData(
editCarrierData.full_name,
editCarrierData.short_name,
editCarrierData.city,
Number(e.target.value),
editCarrierData.main_color,
editCarrierData.left_color,
editCarrierData.right_color,
editCarrierData.slogan,
editCarrierData.logo
)
}
>
{cityStore.cities.ru.map((city) => (
{cityStore.cities.ru.data?.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
@ -106,11 +135,8 @@ export const CarrierEditPage = observer(() => {
setEditCarrierData(
e.target.value,
editCarrierData.short_name,
editCarrierData.city,
editCarrierData.city_id,
editCarrierData.main_color,
editCarrierData.left_color,
editCarrierData.right_color,
editCarrierData.slogan,
editCarrierData.logo
)
@ -126,104 +152,14 @@ export const CarrierEditPage = observer(() => {
setEditCarrierData(
editCarrierData.full_name,
e.target.value,
editCarrierData.city,
editCarrierData.city_id,
editCarrierData.main_color,
editCarrierData.left_color,
editCarrierData.right_color,
editCarrierData.slogan,
editCarrierData.logo
)
}
/>
<div className="flex gap-4 w-full">
<TextField
fullWidth
label="Основной цвет"
value={editCarrierData.main_color}
className="flex-1 w-full"
onChange={(e) =>
setEditCarrierData(
editCarrierData.full_name,
editCarrierData.short_name,
editCarrierData.city,
editCarrierData.city_id,
e.target.value,
editCarrierData.left_color,
editCarrierData.right_color,
editCarrierData.slogan,
editCarrierData.logo
)
}
type="color"
sx={{
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
<TextField
fullWidth
label="Цвет левого виджета"
value={editCarrierData.left_color}
className="flex-1 w-full"
onChange={(e) =>
setEditCarrierData(
editCarrierData.full_name,
editCarrierData.short_name,
editCarrierData.city,
editCarrierData.city_id,
editCarrierData.main_color,
e.target.value,
editCarrierData.right_color,
editCarrierData.slogan,
editCarrierData.logo
)
}
type="color"
sx={{
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
<TextField
fullWidth
label="Цвет правого виджета"
value={editCarrierData.right_color}
className="flex-1 w-full"
onChange={(e) =>
setEditCarrierData(
editCarrierData.full_name,
editCarrierData.short_name,
editCarrierData.city,
editCarrierData.city_id,
editCarrierData.main_color,
editCarrierData.left_color,
e.target.value,
editCarrierData.slogan,
editCarrierData.logo
)
}
type="color"
sx={{
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
</div>
<TextField
fullWidth
label="Слоган"
@ -232,54 +168,43 @@ export const CarrierEditPage = observer(() => {
setEditCarrierData(
editCarrierData.full_name,
editCarrierData.short_name,
editCarrierData.city,
editCarrierData.city_id,
editCarrierData.main_color,
editCarrierData.left_color,
editCarrierData.right_color,
e.target.value,
editCarrierData.logo
)
}
/>
<div className="w-full flex flex-col gap-4">
<FormControl fullWidth>
<InputLabel>Логотип</InputLabel>
<Select
value={editCarrierData.logo || ""}
label="Логотип"
required
onChange={(e) =>
setEditCarrierData(
editCarrierData.full_name,
editCarrierData.short_name,
editCarrierData.city,
editCarrierData.city_id,
editCarrierData.main_color,
editCarrierData.left_color,
editCarrierData.right_color,
editCarrierData.slogan,
e.target.value as string
)
}
>
{mediaStore.media
.filter((media) => media.media_type === 3)
.map((media) => (
<MenuItem key={media.id} value={media.id}>
{media.media_name || media.filename}
</MenuItem>
))}
</Select>
</FormControl>
{editCarrierData.logo && (
<div className="w-32 h-32">
<MediaViewer
media={{ id: editCarrierData.logo, media_type: 1 }}
/>
</div>
)}
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Логотип перевозчика"
imageKey="thumbnail"
imageUrl={selectedMedia?.id}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(selectedMedia?.id ?? "");
}}
onDeleteImageClick={() => {
setEditCarrierData(
editCarrierData.full_name,
editCarrierData.short_name,
editCarrierData.city_id,
editCarrierData.slogan,
""
);
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("thumbnail");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("thumbnail");
}}
/>
</div>
<Button
@ -302,6 +227,26 @@ export const CarrierEditPage = observer(() => {
)}
</Button>
</div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={3}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
</Paper>
);
});

View File

@ -2,7 +2,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { carrierStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2 } from "lucide-react";
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets";
@ -10,7 +10,9 @@ export const CarrierListPage = observer(() => {
const { carriers, getCarriers, deleteCarrier } = carrierStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
useEffect(() => {
getCarriers();
@ -20,18 +22,50 @@ export const CarrierListPage = observer(() => {
{
field: "full_name",
headerName: "Полное имя",
width: 300,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "short_name",
headerName: "Короткое имя",
width: 200,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "city",
headerName: "Город",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "actions",
@ -45,9 +79,9 @@ export const CarrierListPage = observer(() => {
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
<button onClick={() => navigate(`/carrier/${params.row.id}`)}>
{/* <button onClick={() => navigate(`/carrier/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
</button> */}
<button
onClick={() => {
setIsDeleteModalOpen(true);
@ -76,10 +110,28 @@ export const CarrierListPage = observer(() => {
<h1 className="text-2xl">Перевозчики</h1>
<CreateButton label="Создать перевозчика" path="/carrier/create" />
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
/>
</div>
@ -98,6 +150,19 @@ export const CarrierListPage = observer(() => {
setRowId(null);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteCarrier(id)));
getCarriers();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

View File

@ -20,9 +20,9 @@ export const CarrierPreviewPage = observer(() => {
carrierResponse?.short_name as string,
carrierResponse?.city as string,
carrierResponse?.city_id as number,
carrierResponse?.main_color as string,
carrierResponse?.left_color as string,
carrierResponse?.right_color as string,
// carrierResponse?.main_color as string,
// carrierResponse?.left_color as string,
// carrierResponse?.right_color as string,
carrierResponse?.slogan as string,
carrierResponse?.logo as string
);
@ -58,7 +58,7 @@ export const CarrierPreviewPage = observer(() => {
<h1 className="text-lg font-bold">Город</h1>
<p>{carrier[Number(id)]?.city}</p>
</div>
<div className="flex flex-col gap-2 ">
{/* <div className="flex flex-col gap-2 ">
<h1 className="text-lg font-bold">Основной цвет</h1>
<div
className="w-min"
@ -90,22 +90,24 @@ export const CarrierPreviewPage = observer(() => {
>
{carrier[Number(id)]?.right_color}
</div>
</div>
</div> */}
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Краткое имя</h1>
<p>{carrier[Number(id)]?.short_name}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Логотип</h1>
{oneMedia && (
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Логотип</h1>
<MediaViewer
media={{
id: oneMedia?.id as string,
media_type: oneMedia?.media_type as number,
filename: oneMedia?.filename,
}}
/>
</div>
<MediaViewer
media={{
id: oneMedia?.id as string,
media_type: oneMedia?.media_type as number,
filename: oneMedia?.filename,
}}
/>
</div>
)}
</div>
</>
)}

View File

@ -9,14 +9,18 @@ import {
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save, ImagePlus } from "lucide-react";
import { ArrowLeft, Save, ImagePlus, Minus } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { cityStore, countryStore, languageStore, mediaStore } from "@shared";
import { useState, useEffect } from "react";
import { LanguageSwitcher, MediaViewer } from "@widgets";
import { SelectMediaDialog } from "@shared";
import { LanguageSwitcher, MediaViewer, ImageUploadCard } from "@widgets";
import {
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
export const CityCreatePage = observer(() => {
const navigate = useNavigate();
@ -24,12 +28,20 @@ export const CityCreatePage = observer(() => {
const { createCityData, setCreateCityData } = cityStore;
const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
>(null);
const { getCountries } = countryStore;
const { getMedia } = mediaStore;
useEffect(() => {
(async () => {
await getCountries(language);
await getCountries("ru");
await getCountries("en");
await getCountries("zh");
await getMedia();
})();
}, [language]);
@ -55,7 +67,6 @@ export const CityCreatePage = observer(() => {
}) => {
setCreateCityData(
createCityData[language].name,
createCityData.country,
createCityData.country_code,
media.id,
language
@ -80,6 +91,9 @@ export const CityCreatePage = observer(() => {
</div>
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
<h1 className="text-3xl break-words">Создание города</h1>
</div>
<TextField
fullWidth
label="Название города"
@ -88,7 +102,6 @@ export const CityCreatePage = observer(() => {
onChange={(e) =>
setCreateCityData(
e.target.value,
createCityData.country,
createCityData.country_code,
createCityData.arms,
language
@ -103,19 +116,15 @@ export const CityCreatePage = observer(() => {
label="Страна"
required
onChange={(e) => {
const selectedCountry = countryStore.countries[language]?.find(
(country) => country.code === e.target.value
);
setCreateCityData(
createCityData[language].name,
selectedCountry?.name || "",
e.target.value,
createCityData.arms,
language
);
}}
>
{countryStore.countries[language].map((country) => (
{countryStore.countries["ru"]?.data?.map((country) => (
<MenuItem key={country.code} value={country.code}>
{country.name}
</MenuItem>
@ -123,44 +132,39 @@ export const CityCreatePage = observer(() => {
</Select>
</FormControl>
<div className="w-full flex flex-col gap-4">
<label className="text-sm text-gray-600">Герб города</label>
<div className="flex items-center gap-4">
<Button
variant="outlined"
onClick={() => setIsSelectMediaOpen(true)}
startIcon={<ImagePlus size={20} />}
>
Выбрать герб
</Button>
{selectedMedia && (
<span className="text-sm text-gray-600">
{selectedMedia.media_name || selectedMedia.filename}
</span>
)}
</div>
{selectedMedia && (
<Box
sx={{
width: "200px",
height: "200px",
border: "1px solid #e0e0e0",
borderRadius: "8px",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<MediaViewer
media={{
id: selectedMedia.id,
media_type: selectedMedia.media_type,
filename: selectedMedia.filename,
}}
/>
</Box>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
{!selectedMedia && (
<div className="flex items-center gap-2 text-red-500">
<Minus size={20} />
<span className="text-sm">Герб города не выбран</span>
</div>
)}
<ImageUploadCard
title="Герб города"
imageKey="thumbnail"
imageUrl={selectedMedia?.id}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(selectedMedia?.id ?? "");
}}
onDeleteImageClick={() => {
setCreateCityData(
createCityData[language].name,
createCityData.country_code,
"",
language
);
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("thumbnail");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("thumbnail");
}}
/>
</div>
<Button
@ -184,6 +188,19 @@ export const CityCreatePage = observer(() => {
onSelectMedia={handleMediaSelect}
mediaType={3} // Тип медиа для иконок
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
</Paper>
);
});

View File

@ -21,13 +21,23 @@ import {
CashedCities,
} from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher, MediaViewer } from "@widgets";
import { SelectMediaDialog } from "@shared";
import { LanguageSwitcher, MediaViewer, ImageUploadCard } from "@widgets";
import {
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
export const CityEditPage = observer(() => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
>(null);
const { language } = languageStore;
const { id } = useParams();
const { editCityData, editCity, getCity, setEditCityData } = cityStore;
@ -49,20 +59,22 @@ export const CityEditPage = observer(() => {
useEffect(() => {
(async () => {
if (id) {
const data = await getCity(id as string, language);
setEditCityData(
data.name,
data.country,
data.country_code,
data.arms,
language
);
await getOneMedia(data.arms as string);
// Fetch data for all languages
const ruData = await getCity(id as string, "ru");
const enData = await getCity(id as string, "en");
const zhData = await getCity(id as string, "zh");
// Set data for each language
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
await getOneMedia(ruData.arms as string);
await getCountries(language);
await getMedia();
}
})();
}, [id, language]);
}, [id]);
const handleMediaSelect = (media: {
id: string;
@ -72,7 +84,6 @@ export const CityEditPage = observer(() => {
}) => {
setEditCityData(
editCityData[language].name,
editCityData.country,
editCityData.country_code,
media.id,
language
@ -97,6 +108,9 @@ export const CityEditPage = observer(() => {
</div>
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
<h1 className="text-3xl break-words">{editCityData.ru.name}</h1>
</div>
<TextField
fullWidth
label="Название"
@ -105,7 +119,6 @@ export const CityEditPage = observer(() => {
onChange={(e) =>
setEditCityData(
e.target.value,
editCityData.country,
editCityData.country_code,
editCityData.arms,
language
@ -120,19 +133,15 @@ export const CityEditPage = observer(() => {
label="Страна"
required
onChange={(e) => {
const selectedCountry = countryStore.countries[language]?.find(
(country) => country.code === e.target.value
);
setEditCityData(
editCityData[language as keyof CashedCities]?.name || "",
selectedCountry?.name || "",
editCityData[language].name,
e.target.value,
editCityData.arms,
language
);
}}
>
{countryStore.countries[language].map((country) => (
{countryStore.countries[language].data.map((country) => (
<MenuItem key={country.code} value={country.code}>
{country.name}
</MenuItem>
@ -140,44 +149,33 @@ export const CityEditPage = observer(() => {
</Select>
</FormControl>
<div className="w-full flex flex-col gap-4">
<label className="text-sm text-gray-600">Герб города</label>
<div className="flex items-center gap-4">
<Button
variant="outlined"
onClick={() => setIsSelectMediaOpen(true)}
startIcon={<ImagePlus size={20} />}
>
Выбрать герб
</Button>
{selectedMedia && (
<span className="text-sm text-gray-600">
{selectedMedia.media_name || selectedMedia.filename}
</span>
)}
</div>
{selectedMedia && (
<Box
sx={{
width: "200px",
height: "200px",
border: "1px solid #e0e0e0",
borderRadius: "8px",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<MediaViewer
media={{
id: selectedMedia.id,
media_type: selectedMedia.media_type,
filename: selectedMedia.filename,
}}
/>
</Box>
)}
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Герб города"
imageKey="thumbnail"
imageUrl={selectedMedia?.id}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(selectedMedia?.id ?? "");
}}
onDeleteImageClick={() => {
setEditCityData(
editCityData[language].name,
editCityData.country_code,
"",
language
);
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("thumbnail");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("thumbnail");
}}
/>
</div>
<Button
@ -201,6 +199,20 @@ export const CityEditPage = observer(() => {
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={3} // Тип медиа для иконок
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
</Paper>
);

View File

@ -2,9 +2,10 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, cityStore, CashedCities } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2 } from "lucide-react";
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { toast } from "react-toastify";
export const CityListPage = observer(() => {
const { cities, getCities, deleteCity } = cityStore;
@ -22,11 +23,33 @@ export const CityListPage = observer(() => {
field: "country",
headerName: "Страна",
width: 150,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "name",
headerName: "Название",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "actions",
@ -58,7 +81,7 @@ export const CityListPage = observer(() => {
},
];
const rows = cities[language].map((city) => ({
const rows = cities[language]?.data?.map((city) => ({
id: city.id,
name: city.name,
country: city.country,
@ -85,7 +108,8 @@ export const CityListPage = observer(() => {
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
deleteCity(rowId.toString(), language as keyof CashedCities);
await deleteCity(rowId.toString());
toast.success("Город успешно удален");
}
setIsDeleteModalOpen(false);
setRowId(null);

View File

@ -16,18 +16,18 @@ export const CityPreviewPage = observer(() => {
useEffect(() => {
(async () => {
if (id) {
const cityResponse = await getCity(id as string, language);
setEditCityData(
cityResponse.name,
cityResponse.country,
cityResponse.country_code,
cityResponse.arms,
language
);
await getOneMedia(cityResponse.arms as string);
const ruData = await getCity(id as string, "ru");
const enData = await getCity(id as string, "en");
const zhData = await getCity(id as string, "zh");
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
await getOneMedia(ruData.arms as string);
}
})();
}, [id, language]);
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">

View File

@ -41,6 +41,9 @@ export const CountryCreatePage = observer(() => {
</div>
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
<h1 className="text-3xl break-words">Создание страны</h1>
</div>
<TextField
fullWidth
label="Код страны"

View File

@ -31,11 +31,18 @@ export const CountryEditPage = observer(() => {
useEffect(() => {
(async () => {
if (id) {
const data = await getCountry(id as string, language);
setEditCountryData(data.name, language);
// Fetch data for all languages
const ruData = await getCountry(id as string, "ru");
const enData = await getCountry(id as string, "en");
const zhData = await getCountry(id as string, "zh");
// Set data for each language
setEditCountryData(ruData.name, "ru");
setEditCountryData(enData.name, "en");
setEditCountryData(zhData.name, "zh");
}
})();
}, [id, language]);
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
@ -51,6 +58,9 @@ export const CountryEditPage = observer(() => {
</div>
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
<h1 className="text-3xl break-words">{editCountryData.ru.name}</h1>
</div>
<TextField
fullWidth
label="Код страны"

View File

@ -2,15 +2,15 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { countryStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2 } from "lucide-react";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
export const CountryListPage = observer(() => {
const { countries, getCountries } = countryStore;
const { countries, getCountries, deleteCountry } = countryStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
const [rowId, setRowId] = useState<string | null>(null);
const { language } = languageStore;
useEffect(() => {
@ -22,6 +22,17 @@ export const CountryListPage = observer(() => {
field: "name",
headerName: "Название",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center ">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "actions",
@ -37,9 +48,9 @@ export const CountryListPage = observer(() => {
>
<Pencil size={20} className="text-blue-500" />
</button>
<button onClick={() => navigate(`/country/${params.row.code}`)}>
{/* <button onClick={() => navigate(`/country/${params.row.code}`)}>
<Eye size={20} className="text-green-500" />
</button>
</button> */}
<button
onClick={() => {
setIsDeleteModalOpen(true);
@ -54,7 +65,7 @@ export const CountryListPage = observer(() => {
},
];
const rows = countries[language]?.map((country) => ({
const rows = countries[language]?.data.map((country) => ({
id: country.code,
code: country.code,
name: country.name,
@ -75,17 +86,14 @@ export const CountryListPage = observer(() => {
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await countryStore.deleteCountry(rowId, language);
getCountries(language); // Refresh the list after deletion
setIsDeleteModalOpen(false);
}
setIsDeleteModalOpen(false);
if (!rowId) return;
await deleteCountry(rowId, language);
setRowId(null);
setIsDeleteModalOpen(false);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
setIsDeleteModalOpen(false);
}}
/>
</>

View File

@ -15,11 +15,16 @@ export const CountryPreviewPage = observer(() => {
useEffect(() => {
(async () => {
if (id) {
const data = await getCountry(id as string, language);
setEditCountryData(data.name, language);
const ruData = await getCountry(id as string, "ru");
const enData = await getCountry(id as string, "en");
const zhData = await getCountry(id as string, "zh");
setEditCountryData(ruData.name, "ru");
setEditCountryData(enData.name, "en");
setEditCountryData(zhData.name, "zh");
}
})();
}, [id, language]);
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
@ -55,7 +60,7 @@ export const CountryPreviewPage = observer(() => {
<div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Название</h1>
<p>{country[id!]?.[language]?.name}</p>
<p>{country[id!]?.ru?.name}</p>
</div>
</div>
)}

View File

@ -38,13 +38,17 @@ export const EditSightPage = observer(() => {
useEffect(() => {
const fetchData = async () => {
if (id) {
await getSightInfo(+id, language);
await getArticles(language);
await getSightInfo(+id, "ru");
await getSightInfo(+id, "en");
await getSightInfo(+id, "zh");
await getArticles("ru");
await getArticles("en");
await getArticles("zh");
await getRuCities();
}
};
fetchData();
}, [id, language]);
}, [id]);
return (
<Box

View File

@ -4,6 +4,7 @@ import React, {
useState,
useCallback,
ReactNode,
useMemo,
} from "react";
import { useNavigate } from "react-router-dom";
import { Map, View, Overlay, MapBrowserEvent } from "ol";
@ -21,7 +22,7 @@ import {
Circle as CircleStyle,
RegularShape,
} from "ol/style";
import { Point, LineString, Geometry } from "ol/geom";
import { Point, LineString, Geometry, Polygon } from "ol/geom";
import { transform } from "ol/proj";
import { GeoJSON } from "ol/format";
import {
@ -34,6 +35,9 @@ import {
Pencil,
Save,
Loader2,
Lasso,
InfoIcon,
X, // --- ИЗМЕНЕНО --- Импортируем иконку крестика
} from "lucide-react";
import { toast } from "react-toastify";
import { singleClick, doubleClick } from "ol/events/condition";
@ -123,6 +127,10 @@ class MapService {
private boundHandlePointerLeave: () => void;
private boundHandleContextMenu: (event: MouseEvent) => void;
private boundHandleKeyDown: (event: KeyboardEvent) => void;
private lassoInteraction: Draw | null = null;
private selectedIds: Set<string | number> = new Set();
private onSelectionChange: ((ids: Set<string | number>) => void) | null =
null;
// Styles
private defaultStyle: Style;
@ -152,7 +160,8 @@ class MapService {
onModeChangeCallback: (mode: string) => void,
onFeaturesChange: (features: Feature<Geometry>[]) => void,
onFeatureSelect: (feature: Feature<Geometry> | null) => void,
tooltipElement: HTMLElement
tooltipElement: HTMLElement,
onSelectionChange?: (ids: Set<string | number>) => void
) {
this.map = null;
this.tooltipElement = tooltipElement;
@ -233,10 +242,10 @@ class MapService {
this.selectedSightIconStyle = new Style({
image: new RegularShape({
fill: new Fill({ color: "rgba(221, 107, 32, 0.9)" }),
stroke: new Stroke({ color: "#ffffff", width: 2.5 }),
stroke: new Stroke({ color: "#ffffff", width: 2 }),
points: 5,
radius: 14,
radius2: 7,
radius: 12,
radius2: 6,
angle: 0,
}),
});
@ -291,6 +300,7 @@ class MapService {
.getArray()
.includes(feature);
const isHovered = this.hoveredFeatureId === fId;
const isLassoSelected = fId !== undefined && this.selectedIds.has(fId);
if (geometryType === "Point") {
const defaultPointStyle =
@ -308,6 +318,28 @@ class MapService {
? this.hoverSightIconStyle
: this.universalHoverStylePoint;
}
if (isLassoSelected) {
let imageStyle;
if (featureType === "sight") {
imageStyle = new RegularShape({
fill: new Fill({ color: "#14b8a6" }),
stroke: new Stroke({ color: "#fff", width: 2 }),
points: 5,
radius: 12,
radius2: 6,
angle: 0,
});
} else {
imageStyle = new CircleStyle({
radius: 10,
fill: new Fill({ color: "#14b8a6" }),
stroke: new Stroke({ color: "#fff", width: 2 }),
});
}
return new Style({ image: imageStyle, zIndex: Infinity });
}
return defaultPointStyle;
} else if (geometryType === "LineString") {
if (isEditSelected) {
@ -316,6 +348,12 @@ class MapService {
if (isHovered) {
return this.universalHoverStyleLine;
}
if (isLassoSelected) {
return new Style({
stroke: new Stroke({ color: "#14b8a6", width: 6 }),
zIndex: Infinity,
});
}
return this.defaultStyle;
}
@ -482,11 +520,43 @@ class MapService {
this.updateFeaturesInReact();
});
this.lassoInteraction = new Draw({
type: "Polygon",
style: new Style({
stroke: new Stroke({ color: "#14b8a6", width: 2 }),
fill: new Fill({ color: "rgba(20, 184, 166, 0.1)" }),
}),
});
this.lassoInteraction.setActive(false);
this.lassoInteraction.on("drawend", (event: DrawEvent) => {
const geometry = event.feature.getGeometry() as Polygon;
const extent = geometry.getExtent();
const selected = new Set<string | number>();
this.vectorSource.forEachFeatureInExtent(extent, (f) => {
const geom = f.getGeometry();
if (geom && geom.getType() === "Point") {
const pointCoords = (geom as Point).getCoordinates();
if (geometry.intersectsCoordinate(pointCoords)) {
if (f.getId() !== undefined) selected.add(f.getId()!);
}
} else if (geom && geom.intersectsExtent(extent)) {
// For lines/polygons
if (f.getId() !== undefined) selected.add(f.getId()!);
}
});
this.setSelectedIds(selected);
this.deactivateLasso();
});
if (this.map) {
this.map.addInteraction(this.modifyInteraction);
this.map.addInteraction(this.selectInteraction);
this.map.addInteraction(this.lassoInteraction);
this.modifyInteraction.setActive(false);
this.selectInteraction.setActive(false);
this.lassoInteraction.setActive(false);
this.selectInteraction.on("select", (e: SelectEvent) => {
if (this.mode === "edit") {
@ -508,6 +578,20 @@ class MapService {
document.addEventListener("keydown", this.boundHandleKeyDown);
this.activateEditMode();
}
if (onSelectionChange) {
this.onSelectionChange = onSelectionChange;
}
}
// --- ИЗМЕНЕНО --- Добавляем новый публичный метод для сброса выделения
public unselect(): void {
// Сбрасываем основное (одиночное) выделение
this.selectInteraction.getFeatures().clear();
this.onFeatureSelect(null); // Оповещаем React
// Сбрасываем множественное выделение
this.setSelectedIds(new Set()); // Это вызовет onSelectionChange и перерисовку
}
public saveMapState(): void {
@ -671,14 +755,7 @@ class MapService {
return;
}
if (event.key === "Escape") {
if (this.mode && this.mode.startsWith("drawing-")) this.finishDrawing();
else if (
this.mode === "edit" &&
this.selectInteraction?.getFeatures().getLength() > 0
) {
this.selectInteraction.getFeatures().clear();
this.onFeatureSelect(null);
}
this.unselect(); // --- ИЗМЕНЕНО --- Esc теперь тоже сбрасывает все выделения
}
}
@ -907,6 +984,38 @@ class MapService {
}
}
public handleMapClick(event: MapBrowserEvent<any>, ctrlKey: boolean): void {
if (!this.map) return;
const pixel = this.map.getEventPixel(event.originalEvent);
const featureAtPixel: Feature<Geometry> | undefined =
this.map.forEachFeatureAtPixel(
pixel,
(f: FeatureLike) => f as Feature<Geometry>,
{ layerFilter: (l) => l === this.vectorLayer, hitTolerance: 5 }
);
if (!featureAtPixel) return;
const featureId = featureAtPixel.getId();
if (featureId === undefined) return;
if (ctrlKey) {
const newSet = new Set(this.selectedIds);
if (newSet.has(featureId)) {
newSet.delete(featureId);
} else {
newSet.add(featureId);
}
this.setSelectedIds(newSet);
this.vectorLayer.changed();
} else {
this.selectFeature(featureId);
const newSet = new Set<string | number>([featureId]);
this.setSelectedIds(newSet);
}
}
public selectFeature(featureId: string | number | undefined): void {
if (!this.map || featureId === undefined) {
this.onFeatureSelect(null);
@ -975,12 +1084,9 @@ class MapService {
}
}
// --- НОВОЕ ---
// Метод для множественного удаления объектов по их ID
public deleteMultipleFeatures(featureIds: (string | number)[]): void {
if (!featureIds || featureIds.length === 0) return;
// Вывод в консоль по требованию
console.log("Запрос на множественное удаление. ID объектов:", featureIds);
const currentState = this.getCurrentStateAsGeoJSON();
@ -994,26 +1100,22 @@ class MapService {
featureIds.forEach((id) => {
const feature = this.vectorSource.getFeatureById(id);
if (feature) {
// Удаление из "бэкенда"/стора для каждого объекта
const recourse = String(id).split("-")[0];
const numericId = String(id).split("-")[1];
if (recourse && numericId) {
mapStore.deleteRecourse(recourse, Number(numericId));
}
// Если удаляемый объект выбран для редактирования, убираем его из выделения
if (selectedFeaturesCollection?.getArray().includes(feature)) {
selectedFeaturesCollection.remove(feature);
}
// Удаляем объект с карты
this.vectorSource.removeFeature(feature);
deletedCount++;
}
});
if (deletedCount > 0) {
// Если основное выделение стало пустым, оповещаем React
if (selectedFeaturesCollection?.getLength() === 0) {
this.onFeatureSelect(null);
}
@ -1075,17 +1177,62 @@ class MapService {
if (!event.feature) return;
this.updateFeaturesInReact();
}
public activateLasso() {
if (this.lassoInteraction && this.map) {
this.lassoInteraction.setActive(true);
this.setMode("lasso");
}
}
public deactivateLasso() {
if (this.lassoInteraction && this.map) {
this.lassoInteraction.setActive(false);
this.setMode("edit");
}
}
public setSelectedIds(ids: Set<string | number>) {
this.selectedIds = new Set(ids);
if (this.onSelectionChange) this.onSelectionChange(this.selectedIds);
this.vectorLayer.changed();
}
public getSelectedIds() {
return new Set(this.selectedIds);
}
public setOnSelectionChange(cb: (ids: Set<string | number>) => void) {
this.onSelectionChange = cb;
}
public toggleLasso() {
if (this.mode === "lasso") {
this.deactivateLasso();
} else {
this.activateLasso();
}
}
public getMap(): Map | null {
return this.map;
}
}
// --- MAP CONTROLS COMPONENT ---
// --- ИЗМЕНЕНО --- Добавляем проп isUnselectDisabled
interface MapControlsProps {
mapService: MapService | null;
activeMode: string;
isLassoActive: boolean;
isUnselectDisabled: boolean;
}
const MapControls: React.FC<MapControlsProps> = ({
mapService,
activeMode,
isLassoActive,
isUnselectDisabled, // --- ИЗМЕНЕНО ---
}) => {
if (!mapService) return null;
@ -1118,24 +1265,53 @@ const MapControls: React.FC<MapControlsProps> = ({
icon: <LineIconSvg />,
action: () => mapService.startDrawingLine(),
},
// {
// mode: "lasso",
// title: "Выделение",
// longTitle: "Выделение области (или зажмите Shift)",
// icon: <Lasso size={16} className="mr-1 sm:mr-2" />,
// action: () => mapService.toggleLasso(),
// isActive: isLassoActive,
// },
// --- ИЗМЕНЕНО --- Добавляем кнопку сброса
{
mode: "unselect",
title: "Сбросить",
longTitle: "Сбросить выделение (Esc)",
icon: <X size={16} className="mr-1 sm:mr-2" />,
action: () => mapService.unselect(),
disabled: isUnselectDisabled,
},
];
return (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex flex-nowrap justify-center p-2 bg-white/90 backdrop-blur-sm rounded-lg shadow-xl space-x-1 sm:space-x-2">
{controls.map((c) => (
<button
key={c.mode}
className={`flex items-center px-3 py-2 rounded-md transition-all duration-200 text-xs sm:text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 focus:ring-opacity-75 ${
activeMode === c.mode
? "bg-blue-600 text-white shadow-md hover:bg-blue-700"
: "bg-gray-100 text-gray-700 hover:bg-blue-100 hover:text-blue-700"
}`}
onClick={c.action}
title={c.longTitle}
>
{c.icon}
<span className="hidden sm:inline ml-0 sm:ml-1">{c.title}</span>
</button>
))}
{controls.map((c) => {
// --- ИЗМЕНЕНО --- Определяем классы в зависимости от состояния
const isActive =
c.isActive !== undefined ? c.isActive : activeMode === c.mode;
const isDisabled = c.disabled;
const buttonClasses = `flex items-center px-3 py-2 rounded-md transition-all duration-200 text-xs sm:text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 focus:ring-opacity-75 ${
isDisabled
? "bg-gray-200 text-gray-400 cursor-not-allowed"
: isActive
? "bg-blue-600 text-white shadow-md hover:bg-blue-700"
: "bg-gray-100 text-gray-700 hover:bg-blue-100 hover:text-blue-700"
}`;
return (
<button
key={c.mode}
className={buttonClasses}
onClick={c.action}
title={c.longTitle}
disabled={isDisabled}
>
{c.icon}
<span className="hidden sm:inline ml-0 sm:ml-1">{c.title}</span>
</button>
);
})}
</div>
);
};
@ -1145,24 +1321,34 @@ interface MapSightbarProps {
mapService: MapService | null;
mapFeatures: Feature<Geometry>[];
selectedFeature: Feature<Geometry> | null;
selectedIds: Set<string | number>;
setSelectedIds: (ids: Set<string | number>) => void;
activeSection: string | null;
setActiveSection: (section: string | null) => void;
}
const MapSightbar: React.FC<MapSightbarProps> = ({
mapService,
mapFeatures,
selectedFeature,
selectedIds,
setSelectedIds,
activeSection,
setActiveSection,
}) => {
const [activeSection, setActiveSection] = useState<string | null>("layers");
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
// --- НОВОЕ ---
// Состояние для хранения ID объектов, выбранных для удаления
const [selectedForDeletion, setSelectedForDeletion] = useState<
Set<string | number>
>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const toggleSection = (id: string) =>
setActiveSection(activeSection === id ? null : id);
const filteredFeatures = useMemo(() => {
if (!searchQuery.trim()) {
return mapFeatures;
}
return mapFeatures.filter((feature) => {
const name = (feature.get("name") as string) || "";
return name.toLowerCase().includes(searchQuery.toLowerCase());
});
}, [mapFeatures, searchQuery]);
const handleFeatureClick = useCallback(
(id: string | number | undefined) => {
@ -1184,39 +1370,33 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
[mapService]
);
// --- НОВОЕ ---
// Обработчик изменения состояния чекбокса
const handleCheckboxChange = useCallback(
(id: string | number | undefined) => {
if (id === undefined) return;
setSelectedForDeletion((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
const newSet = new Set(selectedIds);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
setSelectedIds(newSet);
},
[]
[selectedIds, setSelectedIds]
);
// --- НОВОЕ ---
// Обработчик для запуска множественного удаления
const handleBulkDelete = useCallback(() => {
if (!mapService || selectedForDeletion.size === 0) return;
if (!mapService || selectedIds.size === 0) return;
if (
window.confirm(
`Вы уверены, что хотите удалить ${selectedForDeletion.size} объект(ов)? Это действие нельзя отменить.`
`Вы уверены, что хотите удалить ${selectedIds.size} объект(ов)? Это действие нельзя отменить.`
)
) {
const idsToDelete = Array.from(selectedForDeletion);
const idsToDelete = Array.from(selectedIds);
mapService.deleteMultipleFeatures(idsToDelete);
setSelectedForDeletion(new Set()); // Очищаем выбор после удаления
setSelectedIds(new Set());
}
}, [mapService, selectedForDeletion]);
}, [mapService, selectedIds, setSelectedIds]);
const handleEditFeature = useCallback(
(featureType: string | undefined, fullId: string | number | undefined) => {
@ -1243,51 +1423,83 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
setIsLoading(false);
}, [mapService]);
const stations = mapFeatures.filter(
function sortFeatures(
features: Feature<Geometry>[],
selectedIds: Set<string | number>,
selectedFeature: Feature<Geometry> | null
) {
const selectedId = selectedFeature?.getId();
return features.slice().sort((a, b) => {
const aId = a.getId();
const bId = b.getId();
if (selectedId && aId === selectedId) return -1;
if (selectedId && bId === selectedId) return 1;
const aSelected = selectedIds.has(aId!);
const bSelected = selectedIds.has(bId!);
if (aSelected && !bSelected) return -1;
if (!aSelected && bSelected) return 1;
const aName = (a.get("name") as string) || "";
const bName = (b.get("name") as string) || "";
return aName.localeCompare(bName, "ru");
});
}
const toggleSection = (id: string) =>
setActiveSection(activeSection === id ? null : id);
const stations = (filteredFeatures || []).filter(
(f) =>
f.get("featureType") === "station" ||
(f.getGeometry()?.getType() === "Point" && !f.get("featureType"))
);
const lines = mapFeatures.filter(
const lines = (filteredFeatures || []).filter(
(f) =>
f.get("featureType") === "route" ||
(f.getGeometry()?.getType() === "LineString" && !f.get("featureType"))
);
const sights = mapFeatures.filter((f) => f.get("featureType") === "sight");
const sights = (filteredFeatures || []).filter(
(f) => f.get("featureType") === "sight"
);
const sortedStations = sortFeatures(stations, selectedIds, selectedFeature);
const sortedLines = sortFeatures(lines, selectedIds, selectedFeature);
const sortedSights = sortFeatures(sights, selectedIds, selectedFeature);
interface SidebarSection {
id: string;
title: string;
icon: ReactNode;
content: ReactNode;
count: number;
}
const sections: SidebarSection[] = [
{
id: "layers",
title: `Остановки (${stations.length})`,
title: `Остановки (${sortedStations.length})`,
icon: <Bus size={20} />,
count: sortedStations.length,
content: (
<div className="space-y-1 max-h-[500px] overflow-y-auto pr-1">
{stations.length > 0 ? (
stations.map((s) => {
{sortedStations.length > 0 ? (
sortedStations.map((s) => {
const sId = s.getId();
const sName = (s.get("name") as string) || "Без названия";
const isSelected = selectedFeature?.getId() === sId;
// --- ИЗМЕНЕНИЕ ---
const isCheckedForDeletion =
sId !== undefined && selectedForDeletion.has(sId);
sId !== undefined && selectedIds.has(sId);
return (
<div
key={String(sId)}
data-feature-id={sId}
data-feature-type="station"
className={`flex items-start p-2 rounded-md group transition-colors duration-150 ${
isSelected
? "bg-orange-100 border border-orange-300"
: "hover:bg-blue-50"
}`}
>
{/* --- НОВОЕ: Чекбокс для множественного выбора --- */}
<div className="flex-shrink-0 pr-2 pt-1">
<input
type="checkbox"
@ -1357,17 +1569,18 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
},
{
id: "lines",
title: `Маршруты (${lines.length})`,
title: `Маршруты (${sortedLines.length})`,
icon: <RouteIcon size={20} />,
count: sortedLines.length,
content: (
<div className="space-y-1 max-h-60 overflow-y-auto pr-1">
{lines.length > 0 ? (
lines.map((l) => {
{sortedLines.length > 0 ? (
sortedLines.map((l) => {
const lId = l.getId();
const lName = (l.get("name") as string) || "Без названия";
const isSelected = selectedFeature?.getId() === lId;
const isCheckedForDeletion =
lId !== undefined && selectedForDeletion.has(lId);
lId !== undefined && selectedIds.has(lId);
const lGeom = l.getGeometry();
let lineLengthText: string | null = null;
if (lGeom instanceof LineString) {
@ -1378,6 +1591,8 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
return (
<div
key={String(lId)}
data-feature-id={lId}
data-feature-type="route"
className={`flex items-start p-2 rounded-md group transition-colors duration-150 ${
isSelected
? "bg-orange-100 border border-orange-300"
@ -1457,20 +1672,23 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
},
{
id: "sights",
title: `Достопримечательности (${sights.length})`,
title: `Достопримечательности (${sortedSights.length})`,
icon: <Landmark size={20} />,
count: sortedSights.length,
content: (
<div className="space-y-1 max-h-60 overflow-y-auto pr-1">
{sights.length > 0 ? (
sights.map((s) => {
{sortedSights.length > 0 ? (
sortedSights.map((s) => {
const sId = s.getId();
const sName = (s.get("name") as string) || "Без названия";
const isSelected = selectedFeature?.getId() === sId;
const isCheckedForDeletion =
sId !== undefined && selectedForDeletion.has(sId);
sId !== undefined && selectedIds.has(sId);
return (
<div
key={String(sId)}
data-feature-id={sId}
data-feature-type="sight"
className={`flex items-start p-2 rounded-md group transition-colors duration-150 ${
isSelected
? "bg-orange-100 border border-orange-300"
@ -1555,69 +1773,89 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
}
return (
// --- ИЗМЕНЕНИЕ: Реструктуризация для футера с кнопками ---
<div className="w-72 relative md:w-80 bg-gray-50 shadow-lg flex flex-col border-l border-gray-200 h-[90vh]">
<div className="w-[360px] relative bg-gray-50 shadow-lg flex flex-col border-l border-gray-200 h-[90vh]">
<div className="p-4 bg-gray-700 text-white">
<h2 className="text-lg font-semibold">Панель управления</h2>
</div>
<div className="p-3 border-b border-gray-200 bg-white">
<input
type="text"
placeholder="Поиск по названию..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto">
{sections.map((s) => (
<div
key={s.id}
className="border-b border-gray-200 last:border-b-0"
>
<button
onClick={() => toggleSection(s.id)}
className={`w-full p-3 flex items-center justify-between text-left hover:bg-gray-100 transition-colors duration-150 focus:outline-none focus:bg-gray-100 ${
activeSection === s.id
? "bg-gray-100 text-blue-600"
: "text-gray-700"
}`}
>
<div className="flex items-center space-x-3">
<span
className={
activeSection === s.id ? "text-blue-600" : "text-gray-600"
}
>
{s.icon}
</span>
<span className="font-medium text-sm">{s.title}</span>
</div>
<span
className={`transform transition-transform duration-200 text-gray-500 ${
activeSection === s.id ? "rotate-180" : ""
}`}
>
</span>
</button>
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
activeSection === s.id
? "max-h-[600px] opacity-100"
: "max-h-0 opacity-0"
}`}
>
<div className="p-3 text-sm text-gray-600 bg-white border-t border-gray-100">
{s.content}
</div>
</div>
{filteredFeatures.length === 0 && searchQuery ? (
<div className="p-4 text-center text-gray-500">
Ничего не найдено.
</div>
))}
) : (
sections.map(
(s) =>
(s.count > 0 || !searchQuery) && (
<div
key={s.id}
className="border-b border-gray-200 last:border-b-0"
>
<button
onClick={() => toggleSection(s.id)}
className={`w-full p-3 flex items-center justify-between text-left hover:bg-gray-100 transition-colors duration-150 focus:outline-none focus:bg-gray-100 ${
activeSection === s.id
? "bg-gray-100 text-blue-600"
: "text-gray-700"
}`}
>
<div className="flex items-center space-x-3">
<span
className={
activeSection === s.id
? "text-blue-600"
: "text-gray-600"
}
>
{s.icon}
</span>
<span className="font-medium text-sm">{s.title}</span>
</div>
<span
className={`transform transition-transform duration-200 text-gray-500 ${
activeSection === s.id ? "rotate-180" : ""
}`}
>
</span>
</button>
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
activeSection === s.id
? "max-h-[600px] opacity-100"
: "max-h-0 opacity-0"
}`}
>
<div className="p-3 text-sm text-gray-600 bg-white border-t border-gray-100">
{s.content}
</div>
</div>
</div>
)
)
)}
</div>
</div>
{/* --- НОВОЕ: Футер сайдбара с кнопками действий --- */}
<div className="p-3 border-t border-gray-200 bg-gray-50/95 space-y-2">
{selectedForDeletion.size > 0 && (
{selectedIds.size > 0 && (
<button
onClick={handleBulkDelete}
className="w-full flex items-center justify-center px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
>
<Trash2 size={16} className="mr-2" />
Удалить выбранное ({selectedForDeletion.size})
Удалить выбранное ({selectedIds.size})
</button>
)}
<button
@ -1650,6 +1888,14 @@ export const MapPage: React.FC = () => {
const [mapFeatures, setMapFeatures] = useState<Feature<Geometry>[]>([]);
const [selectedFeatureForSidebar, setSelectedFeatureForSidebar] =
useState<Feature<Geometry> | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string | number>>(
new Set()
);
const [isLassoActive, setIsLassoActive] = useState<boolean>(false);
const [showHelp, setShowHelp] = useState<boolean>(false);
const [activeSectionFromParent, setActiveSectionFromParent] = useState<
string | null
>("layers");
const handleFeaturesChange = useCallback(
(feats: Feature<Geometry>[]) => setMapFeatures([...feats]),
@ -1658,10 +1904,42 @@ export const MapPage: React.FC = () => {
const handleFeatureSelectForSidebar = useCallback(
(feat: Feature<Geometry> | null) => {
setSelectedFeatureForSidebar(feat);
if (feat) {
const featureType = feat.get("featureType");
const sectionId =
featureType === "sight"
? "sights"
: featureType === "route"
? "lines"
: "layers";
setActiveSectionFromParent(sectionId);
setTimeout(() => {
const element = document.querySelector(
`[data-feature-id="${feat.getId()}"]`
);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, 100);
}
},
[]
);
const handleMapClick = useCallback(
(event: any) => {
if (!mapServiceInstance || isLassoActive) return;
const ctrlKey =
event.originalEvent.ctrlKey || event.originalEvent.metaKey;
mapServiceInstance.handleMapClick(event, ctrlKey);
},
[mapServiceInstance, isLassoActive]
);
useEffect(() => {
let service: MapService | null = null;
if (mapRef.current && tooltipRef.current && !mapServiceInstance) {
@ -1698,7 +1976,8 @@ export const MapPage: React.FC = () => {
setCurrentMapMode,
handleFeaturesChange,
handleFeatureSelectForSidebar,
tooltipRef.current
tooltipRef.current,
setSelectedIds
);
setMapServiceInstance(service);
@ -1720,12 +1999,71 @@ export const MapPage: React.FC = () => {
setMapServiceInstance(null);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (mapServiceInstance) {
const olMap = mapServiceInstance.getMap();
if (olMap) {
olMap.on("click", handleMapClick);
return () => {
if (olMap) {
olMap.un("click", handleMapClick);
}
};
}
}
}, [mapServiceInstance, handleMapClick]);
useEffect(() => {
if (mapServiceInstance) {
mapServiceInstance.setOnSelectionChange(setSelectedIds);
}
}, [mapServiceInstance]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Shift" && mapServiceInstance) {
mapServiceInstance.activateLasso();
setIsLassoActive(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === "Shift" && mapServiceInstance) {
mapServiceInstance.deactivateLasso();
setIsLassoActive(false);
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [mapServiceInstance]);
useEffect(() => {
if (mapServiceInstance) {
mapServiceInstance.toggleLasso = function () {
if (currentMapMode === "lasso") {
this.deactivateLasso();
setIsLassoActive(false);
} else {
this.activateLasso();
setIsLassoActive(true);
}
};
}
}, [mapServiceInstance, currentMapMode, setIsLassoActive]);
const showLoader = isMapLoading || isDataLoading;
const showContent = mapServiceInstance && !showLoader && !error;
// --- ИЗМЕНЕНО --- Логика для определения, активна ли кнопка сброса
const isAnythingSelected =
selectedFeatureForSidebar !== null || selectedIds.size > 0;
return (
<div className="flex flex-col md:flex-row font-sans bg-gray-100 h-[90vh] overflow-hidden">
<div className="relative flex-grow flex">
@ -1762,19 +2100,83 @@ export const MapPage: React.FC = () => {
</button>
</div>
)}
{isLassoActive && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-blue-600 text-white py-2 px-4 rounded-full shadow-lg text-sm font-medium z-20">
Режим выделения области. Нарисуйте многоугольник для выбора
объектов.
</div>
)}
</div>
{showContent && (
<MapControls
mapService={mapServiceInstance}
activeMode={currentMapMode}
isLassoActive={isLassoActive}
isUnselectDisabled={!isAnythingSelected} // --- ИЗМЕНЕНО --- Передаем состояние disabled
/>
)}
{/* Help button */}
<button
onClick={() => setShowHelp(!showHelp)}
className="absolute bottom-4 right-4 z-20 p-2 bg-white rounded-full shadow-md hover:bg-gray-100"
title="Помощь по клавишам"
>
<InfoIcon size={20} />
</button>
{/* Help popup */}
{showHelp && (
<div className="absolute bottom-16 right-4 z-20 p-4 bg-white rounded-lg shadow-xl max-w-xs">
<h4 className="font-bold mb-2">Горячие клавиши:</h4>
<ul className="text-sm space-y-2">
<li>
<span className="font-mono bg-gray-100 px-1 rounded">
Shift
</span>{" "}
- Режим выделения области (лассо)
</li>
<li>
<span className="font-mono bg-gray-100 px-1 rounded">
Ctrl + клик
</span>{" "}
- Добавить объект к выбранным
</li>
<li>
<span className="font-mono bg-gray-100 px-1 rounded">Esc</span>{" "}
- Отменить выделение
</li>
<li>
<span className="font-mono bg-gray-100 px-1 rounded">
Ctrl+Z
</span>{" "}
- Отменить последнее действие
</li>
<li>
<span className="font-mono bg-gray-100 px-1 rounded">
Ctrl+Y
</span>{" "}
- Повторить отменённое действие
</li>
</ul>
<button
onClick={() => setShowHelp(false)}
className="mt-3 text-xs text-blue-600 hover:text-blue-800"
>
Закрыть
</button>
</div>
)}
</div>
{showContent && (
<MapSightbar
mapService={mapServiceInstance}
mapFeatures={mapFeatures}
selectedFeature={selectedFeatureForSidebar}
selectedIds={selectedIds}
setSelectedIds={setSelectedIds}
activeSection={activeSectionFromParent}
setActiveSection={setActiveSectionFromParent}
/>
)}
</div>

View File

@ -54,20 +54,15 @@ class MapStore {
sights: ApiSight[] = [];
getRoutes = async () => {
const routes = await languageInstance("ru").get("/route");
const routedIds = routes.data.map((route: any) => route.id);
const mappedRoutes: ApiRoute[] = [];
for (const routeId of routedIds) {
const responseSoloRoute = await languageInstance("ru").get(
`/route/${routeId}`
);
const route = responseSoloRoute.data;
mappedRoutes.push({
id: route.id,
route_number: route.route_number,
path: route.path,
});
}
// ИСПРАВЛЕНО: Проблема N+1.
// Вместо цикла и множества запросов теперь выполняется один.
// Бэкенд по эндпоинту `/route` должен возвращать массив полных объектов маршрутов.
const response = await languageInstance("ru").get("/route");
const mappedRoutes: ApiRoute[] = response.data.map((route: any) => ({
id: route.id,
route_number: route.route_number,
path: route.path,
}));
this.routes = mappedRoutes.sort((a, b) =>
a.route_number.localeCompare(b.route_number)
);

View File

@ -2,7 +2,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2 } from "lucide-react";
import { Eye, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets";
@ -10,7 +10,9 @@ export const MediaListPage = observer(() => {
const { media, getMedia, deleteMedia } = mediaStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<string[]>([]);
const { language } = languageStore;
useEffect(() => {
@ -22,6 +24,17 @@ export const MediaListPage = observer(() => {
field: "media_name",
headerName: "Название",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "media_type",
@ -30,13 +43,15 @@ export const MediaListPage = observer(() => {
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<p>
{
<div className="w-full h-full flex items-center">
{params.value ? (
MEDIA_TYPE_LABELS[
params.row.media_type as keyof typeof MEDIA_TYPE_LABELS
]
}
</p>
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
@ -80,10 +95,28 @@ export const MediaListPage = observer(() => {
<h1 className="text-2xl">Медиа</h1>
<CreateButton label="Создать медиа" path="/media/create" />
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as string[]);
}}
hideFooter
/>
</div>
@ -103,6 +136,19 @@ export const MediaListPage = observer(() => {
setRowId(null);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteMedia(id)));
getMedia();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

View File

@ -9,7 +9,7 @@ import {
Typography,
Box,
} from "@mui/material";
import { LanguageSwitcher } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useEffect, useState } from "react";
@ -93,134 +93,135 @@ export const RouteCreatePage = observer(() => {
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Маршруты / Создать
Назад
</button>
</div>
<Typography variant="h5" fontWeight={700}>
Создать маршрут
</Typography>
<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={carrierStore.carriers.data.length === 0}
<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={carrierStore.carriers.data.length === 0}
>
<MenuItem value="">Не выбрано</MenuItem>
{carrierStore.carriers.data.map(
(c: (typeof carrierStore.carriers.data)[number]) => (
<MenuItem key={c.id} value={c.id}>
{c.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={3}
value={routeCoords}
onChange={(e) => setRouteCoords(e.target.value)}
/>
<TextField
className="w-full"
label="Номер маршрута в Говорящем Городе"
required
value={govRouteNumber}
onChange={(e) => setGovRouteNumber(e.target.value)}
/>
<FormControl fullWidth required>
<InputLabel>Обращение губернатора</InputLabel>
<Select
value={governorAppeal}
label="Обращение губернатора"
onChange={(e) => setGovernorAppeal(e.target.value as string)}
disabled={articlesStore.articleList.ru.data.length === 0}
>
<MenuItem value="">Не выбрано</MenuItem>
{articlesStore.articleList.ru.data.map(
(a: (typeof articlesStore.articleList.ru.data)[number]) => (
<MenuItem key={a.id} value={a.id}>
{a.heading}
</MenuItem>
)
)}
</Select>
</FormControl>
<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}
>
<MenuItem value="">Не выбрано</MenuItem>
{carrierStore.carriers.data.map(
(c: (typeof carrierStore.carriers.data)[number]) => (
<MenuItem key={c.id} value={c.id}>
{c.full_name}
</MenuItem>
)
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Select>
</FormControl>
<TextField
className="w-full"
label="Номер маршрута"
required
value={routeNumber}
onChange={(e) => setRouteNumber(e.target.value)}
/>
<TextField
className="w-full"
label="Координаты маршрута"
multiline
minRows={3}
value={routeCoords}
onChange={(e) => setRouteCoords(e.target.value)}
/>
<TextField
className="w-full"
label="Номер маршрута в Говорящем Городе"
required
value={govRouteNumber}
onChange={(e) => setGovRouteNumber(e.target.value)}
/>
<FormControl fullWidth required>
<InputLabel>Обращение губернатора</InputLabel>
<Select
value={governorAppeal}
label="Обращение губернатора"
onChange={(e) => setGovernorAppeal(e.target.value as string)}
disabled={articlesStore.articleList.ru.data.length === 0}
>
<MenuItem value="">Не выбрано</MenuItem>
{articlesStore.articleList.ru.data.map(
(a: (typeof articlesStore.articleList.ru.data)[number]) => (
<MenuItem key={a.id} value={a.id}>
{a.heading}
</MenuItem>
)
)}
</Select>
</FormControl>
<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>
</Button>
</div>
</div>
</Paper>
);

View File

@ -9,7 +9,7 @@ import {
Typography,
Box,
} from "@mui/material";
import { LanguageSwitcher } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { useEffect, useState } from "react";
@ -45,180 +45,181 @@ export const RouteEditPage = observer(() => {
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Маршруты / Редактировать
Назад
</button>
</div>
<Typography variant="h5" fontWeight={700}>
Редактировать маршрут
</Typography>
<Box className="flex flex-col gap-6 w-full">
<FormControl fullWidth required>
<InputLabel>Выберите перевозчика</InputLabel>
<Select
value={editRouteData.carrier_id}
label="Выберите перевозчика"
<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={editRouteData.carrier_id}
label="Выберите перевозчика"
onChange={(e) =>
routeStore.setEditRouteData({
carrier_id: Number(e.target.value),
carrier:
carrierStore.carriers.data.find(
(c) => c.id === Number(e.target.value)
)?.full_name || "",
})
}
disabled={carrierStore.carriers.data.length === 0}
>
<MenuItem value="">Не выбрано</MenuItem>
{carrierStore.carriers.data.map(
(c: (typeof carrierStore.carriers.data)[number]) => (
<MenuItem key={c.id} value={c.id}>
{c.full_name}
</MenuItem>
)
)}
</Select>
</FormControl>
<TextField
className="w-full"
label="Номер маршрута"
required
value={editRouteData.route_number || ""}
onChange={(e) =>
routeStore.setEditRouteData({
carrier_id: Number(e.target.value),
carrier:
carrierStore.carriers.data.find(
(c) => c.id === Number(e.target.value)
)?.full_name || "",
route_number: e.target.value,
})
}
disabled={carrierStore.carriers.data.length === 0}
>
<MenuItem value="">Не выбрано</MenuItem>
{carrierStore.carriers.data.map(
(c: (typeof carrierStore.carriers.data)[number]) => (
<MenuItem key={c.id} value={c.id}>
{c.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={3}
value={editRouteData.path.map((p) => p.join(" ")).join("\n") || ""}
onChange={(e) =>
routeStore.setEditRouteData({
path: e.target.value
.split("\n")
.map((line) => line.split(" ").map(Number)),
})
}
/>
<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.governor_appeal || ""}
label="Обращение губернатора"
/>
<TextField
className="w-full"
label="Координаты маршрута"
multiline
minRows={3}
value={editRouteData.path.map((p) => p.join(" ")).join("\n") || ""}
onChange={(e) =>
routeStore.setEditRouteData({
governor_appeal: Number(e.target.value),
path: e.target.value
.split("\n")
.map((line) => line.split(" ").map(Number)),
})
}
disabled={articlesStore.articleList.ru.data.length === 0}
>
<MenuItem value="">Не выбрано</MenuItem>
{articlesStore.articleList.ru.data.map(
(a: (typeof articlesStore.articleList.ru.data)[number]) => (
<MenuItem key={a.id} value={a.id}>
{a.heading}
</MenuItem>
)
)}
</Select>
</FormControl>
<FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel>
<Select
value={editRouteData.route_direction ? "forward" : "backward"}
label="Прямой/обратный маршрут"
/>
<TextField
className="w-full"
label="Номер маршрута в Говорящем Городе"
required
value={editRouteData.route_sys_number || ""}
onChange={(e) =>
routeStore.setEditRouteData({
route_direction: e.target.value === "forward",
route_sys_number: e.target.value,
})
}
/>
<FormControl fullWidth required>
<InputLabel>Обращение губернатора</InputLabel>
<Select
value={editRouteData.governor_appeal || ""}
label="Обращение губернатора"
onChange={(e) =>
routeStore.setEditRouteData({
governor_appeal: Number(e.target.value),
})
}
disabled={articlesStore.articleList.ru.data.length === 0}
>
<MenuItem value="">Не выбрано</MenuItem>
{articlesStore.articleList.ru.data.map(
(a: (typeof articlesStore.articleList.ru.data)[number]) => (
<MenuItem key={a.id} value={a.id}>
{a.heading}
</MenuItem>
)
)}
</Select>
</FormControl>
<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="Масштаб (мин)"
value={editRouteData.scale_min || ""}
onChange={(e) =>
routeStore.setEditRouteData({
scale_min: Number(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Масштаб (макс)"
value={editRouteData.scale_max || ""}
onChange={(e) =>
routeStore.setEditRouteData({
scale_max: Number(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Поворот"
value={editRouteData.rotate || ""}
onChange={(e) =>
routeStore.setEditRouteData({
rotate: Number(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Центр. широта"
value={editRouteData.center_latitude || ""}
onChange={(e) =>
routeStore.setEditRouteData({
center_latitude: Number(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Центр. долгота"
value={editRouteData.center_longitude || ""}
onChange={(e) =>
routeStore.setEditRouteData({
center_longitude: Number(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={handleSave}
disabled={isLoading}
>
<MenuItem value="forward">Прямой</MenuItem>
<MenuItem value="backward">Обратный</MenuItem>
</Select>
</FormControl>
<TextField
className="w-full"
label="Масштаб (мин)"
value={editRouteData.scale_min || ""}
onChange={(e) =>
routeStore.setEditRouteData({
scale_min: Number(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Масштаб (макс)"
value={editRouteData.scale_max || ""}
onChange={(e) =>
routeStore.setEditRouteData({
scale_max: Number(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Поворот"
value={editRouteData.rotate || ""}
onChange={(e) =>
routeStore.setEditRouteData({
rotate: Number(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Центр. широта"
value={editRouteData.center_latitude || ""}
onChange={(e) =>
routeStore.setEditRouteData({
center_latitude: Number(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Центр. долгота"
value={editRouteData.center_longitude || ""}
onChange={(e) =>
routeStore.setEditRouteData({
center_longitude: Number(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={handleSave}
disabled={isLoading}
>
Сохранить
</Button>
Сохранить
</Button>
</div>
</div>
</Paper>
);

View File

@ -2,15 +2,18 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, routeStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Map, Pencil, Trash2 } from "lucide-react";
import { Map, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets";
import { LanguageSwitcher } from "@widgets";
export const RouteListPage = observer(() => {
const { routes, getRoutes, deleteRoute } = routeStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const { language } = languageStore;
useEffect(() => {
@ -22,11 +25,33 @@ export const RouteListPage = observer(() => {
field: "carrier",
headerName: "Перевозчик",
width: 250,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "route_number",
headerName: "Номер маршрута",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "route_direction",
@ -87,15 +112,35 @@ export const RouteListPage = observer(() => {
return (
<>
<LanguageSwitcher />
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Маршруты</h1>
<CreateButton label="Создать маршрут" path="/route/create" />
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
/>
</div>
@ -114,6 +159,19 @@ export const RouteListPage = observer(() => {
setRowId(null);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteRoute(id)));
getRoutes();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

View File

@ -2,7 +2,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, sightsStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2 } from "lucide-react";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
@ -10,7 +10,9 @@ export const SightListPage = observer(() => {
const { sights, getSights, deleteListSight } = sightsStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<number[]>([]);
const { language } = languageStore;
useEffect(() => {
@ -22,13 +24,34 @@ export const SightListPage = observer(() => {
field: "name",
headerName: "Имя",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "city",
headerName: "Город",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "actions",
headerName: "Действия",
@ -76,10 +99,28 @@ export const SightListPage = observer(() => {
path="/sight/create"
/>
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
/>
</div>
@ -98,6 +139,19 @@ export const SightListPage = observer(() => {
setRowId(null);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteListSight(id)));
getSights();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

View File

@ -13,6 +13,7 @@ import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { stationsStore } from "@shared";
import { useState } from "react";
import { LanguageSwitcher } from "@widgets";
export const StationCreatePage = observer(() => {
const navigate = useNavigate();
@ -36,7 +37,8 @@ export const StationCreatePage = observer(() => {
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
@ -45,8 +47,10 @@ export const StationCreatePage = observer(() => {
Назад
</button>
</div>
<h1 className="text-2xl font-bold">Создание станции</h1>
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
<h1 className="text-3xl break-words">Создание станции</h1>
</div>
<TextField
className="w-full"
label="Название"

View File

@ -49,11 +49,13 @@ export const StationEditPage = observer(() => {
const stationId = Number(id);
await getEditStation(stationId);
await getCities(language);
await getCities("ru");
await getCities("en");
await getCities("zh");
};
fetchAndSetStationData();
}, [id, language]);
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
@ -69,6 +71,9 @@ export const StationEditPage = observer(() => {
</div>
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
<h1 className="text-3xl break-words">{editStationData.ru.name}</h1>
</div>
<TextField
fullWidth
label="Название"
@ -141,7 +146,7 @@ export const StationEditPage = observer(() => {
value={editStationData.common.city_id || ""}
label="Город"
onChange={(e) => {
const selectedCity = cities[language].find(
const selectedCity = cities[language].data.find(
(city) => city.id === e.target.value
);
setEditCommonData({
@ -150,7 +155,7 @@ export const StationEditPage = observer(() => {
});
}}
>
{cities[language].map((city) => (
{cities[language].data.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>

View File

@ -2,7 +2,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, stationsStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2 } from "lucide-react";
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
@ -10,7 +10,9 @@ export const StationListPage = observer(() => {
const { stationLists, getStationList, deleteStation } = stationsStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const { language } = languageStore;
useEffect(() => {
@ -22,11 +24,33 @@ export const StationListPage = observer(() => {
field: "name",
headerName: "Название",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "system_name",
headerName: "Системное название",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "direction",
@ -88,15 +112,33 @@ export const StationListPage = observer(() => {
<>
<LanguageSwitcher />
<div style={{ width: "100%" }}>
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Станции</h1>
<CreateButton label="Создать станцию" path="/station/create" />
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
/>
</div>
@ -115,6 +157,19 @@ export const StationListPage = observer(() => {
setRowId(null);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteStation(id)));
getStationList();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

View File

@ -2,7 +2,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { userStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2 } from "lucide-react";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets";
@ -11,7 +11,9 @@ export const UserListPage = observer(() => {
const { users, getUsers, deleteUser } = userStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
useEffect(() => {
getUsers();
@ -22,11 +24,33 @@ export const UserListPage = observer(() => {
field: "name",
headerName: "Имя",
width: 400,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "email",
headerName: "Email",
width: 400,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "is_admin",
@ -93,10 +117,28 @@ export const UserListPage = observer(() => {
<h1 className="text-2xl">Пользователи</h1>
<CreateButton label="Создать пользователя" path="/user/create" />
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
/>
</div>
@ -107,7 +149,6 @@ export const UserListPage = observer(() => {
if (rowId) {
await deleteUser(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
@ -116,6 +157,19 @@ export const UserListPage = observer(() => {
setRowId(null);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteUser(id)));
getUsers();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

View File

@ -2,7 +2,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { carrierStore, languageStore, vehicleStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2 } from "lucide-react";
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets";
import { VEHICLE_TYPES } from "@shared";
@ -12,7 +12,9 @@ export const VehicleListPage = observer(() => {
const { carriers, getCarriers } = carrierStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const { language } = languageStore;
useEffect(() => {
@ -25,17 +27,31 @@ export const VehicleListPage = observer(() => {
field: "tail_number",
headerName: "Бортовой номер",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "type",
headerName: "Тип",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 items-center">
{VEHICLE_TYPES.find((type) => type.value === params.row.type)
?.label || params.row.type}
<div className="w-full h-full flex items-center">
{params.value ? (
VEHICLE_TYPES.find((type) => type.value === params.row.type)
?.label || params.row.type
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
@ -44,13 +60,34 @@ export const VehicleListPage = observer(() => {
field: "carrier",
headerName: "Перевозчик",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "city",
headerName: "Город",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "actions",
headerName: "Действия",
@ -101,10 +138,28 @@ export const VehicleListPage = observer(() => {
path="/vehicle/create"
/>
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
/>
</div>
@ -123,6 +178,19 @@ export const VehicleListPage = observer(() => {
setRowId(null);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteVehicle(id)));
getVehicles();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});