fix: Fix Map
page
This commit is contained in:
@ -16,22 +16,22 @@ import {
|
||||
SnapshotListPage,
|
||||
CarrierListPage,
|
||||
StationListPage,
|
||||
VehicleListPage,
|
||||
// VehicleListPage,
|
||||
ArticleListPage,
|
||||
CityPreviewPage,
|
||||
CountryPreviewPage,
|
||||
VehiclePreviewPage,
|
||||
CarrierPreviewPage,
|
||||
// CountryPreviewPage,
|
||||
// VehiclePreviewPage,
|
||||
// CarrierPreviewPage,
|
||||
SnapshotCreatePage,
|
||||
CountryCreatePage,
|
||||
CityCreatePage,
|
||||
CarrierCreatePage,
|
||||
VehicleCreatePage,
|
||||
// VehicleCreatePage,
|
||||
CountryEditPage,
|
||||
CityEditPage,
|
||||
UserCreatePage,
|
||||
UserEditPage,
|
||||
VehicleEditPage,
|
||||
// VehicleEditPage,
|
||||
CarrierEditPage,
|
||||
StationCreatePage,
|
||||
StationPreviewPage,
|
||||
@ -133,7 +133,7 @@ const router = createBrowserRouter([
|
||||
// Country
|
||||
{ path: "country", element: <CountryListPage /> },
|
||||
{ path: "country/create", element: <CountryCreatePage /> },
|
||||
{ path: "country/:id", element: <CountryPreviewPage /> },
|
||||
// { path: "country/:id", element: <CountryPreviewPage /> },
|
||||
{ path: "country/:id/edit", element: <CountryEditPage /> },
|
||||
// City
|
||||
{ path: "city", element: <CityListPage /> },
|
||||
@ -156,7 +156,7 @@ const router = createBrowserRouter([
|
||||
// Carrier
|
||||
{ path: "carrier", element: <CarrierListPage /> },
|
||||
{ path: "carrier/create", element: <CarrierCreatePage /> },
|
||||
{ path: "carrier/:id", element: <CarrierPreviewPage /> },
|
||||
// { path: "carrier/:id", element: <CarrierPreviewPage /> },
|
||||
{ path: "carrier/:id/edit", element: <CarrierEditPage /> },
|
||||
// Station
|
||||
{ path: "station", element: <StationListPage /> },
|
||||
@ -164,10 +164,10 @@ const router = createBrowserRouter([
|
||||
{ path: "station/:id", element: <StationPreviewPage /> },
|
||||
{ path: "station/:id/edit", element: <StationEditPage /> },
|
||||
// Vehicle
|
||||
{ path: "vehicle", element: <VehicleListPage /> },
|
||||
{ path: "vehicle/create", element: <VehicleCreatePage /> },
|
||||
{ path: "vehicle/:id", element: <VehiclePreviewPage /> },
|
||||
{ path: "vehicle/:id/edit", element: <VehicleEditPage /> },
|
||||
// { path: "vehicle", element: <VehicleListPage /> },
|
||||
// { path: "vehicle/create", element: <VehicleCreatePage /> },
|
||||
// { path: "vehicle/:id", element: <VehiclePreviewPage /> },
|
||||
// { path: "vehicle/:id/edit", element: <VehicleEditPage /> },
|
||||
// Article
|
||||
{ path: "article", element: <ArticleListPage /> },
|
||||
// { path: "article/:id", element: <ArticlePreviewPage /> },
|
||||
|
@ -9,6 +9,7 @@ import ExpandLessIcon from "@mui/icons-material/ExpandLess";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import type { NavigationItem } from "../model";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
interface NavigationItemProps {
|
||||
item: NavigationItem;
|
||||
@ -58,7 +59,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||
justifyContent: "center",
|
||||
},
|
||||
isNested && {
|
||||
pl: 4,
|
||||
pl: open ? 4 : 2.5,
|
||||
},
|
||||
isActive && {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.08)",
|
||||
@ -84,7 +85,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Icon />
|
||||
{Icon ? <Icon /> : <Plus />}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
@ -108,7 +109,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
{item.nestedItems && (
|
||||
<Collapse in={isExpanded && open} timeout="auto" unmountOnExit>
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{item.nestedItems.map((nestedItem) => (
|
||||
<NavigationItemComponent
|
||||
|
28
src/pages/Article/ArticleCreatePage/index.tsx
Normal file
28
src/pages/Article/ArticleCreatePage/index.tsx
Normal 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;
|
44
src/pages/Article/ArticleEditPage/index.tsx
Normal file
44
src/pages/Article/ArticleEditPage/index.tsx
Normal 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;
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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);
|
||||
|
@ -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">
|
||||
|
@ -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="Код страны"
|
||||
|
@ -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="Код страны"
|
||||
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
);
|
||||
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -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="Название"
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
62
src/shared/config/CarrierSvg.tsx
Normal file
62
src/shared/config/CarrierSvg.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
export const CarrierSvg = () => {
|
||||
return (
|
||||
<svg
|
||||
fill="#000000"
|
||||
height="26px"
|
||||
width="26px"
|
||||
version="1.1"
|
||||
id="Capa_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 489.785 489.785"
|
||||
>
|
||||
<g id="XMLID_196_">
|
||||
<path
|
||||
id="XMLID_203_"
|
||||
d="M409.772,379.327l-81.359-124.975c-5.884-9.054-15.925-13.119-25.987-13.119
|
||||
c-2.082,0-6.392,0.05-11.051,0.115c-0.363-0.61-0.742-1.215-1.355-1.627l-20.492-13.609c-2.364-1.569-5.434-1.486-7.701,0.182
|
||||
l-16.948,12.508l-16.959-12.508c-2.285-1.668-5.337-1.751-7.72-0.182l-20.455,13.609c-0.578,0.377-0.945,0.907-1.282,1.461
|
||||
c-4.828,0.031-9.327,0.057-11.222,0.057c-10.016,0-20.011,4.119-25.859,13.113L80.022,379.327
|
||||
c-8.65,13.267-5.149,31.008,7.896,39.992l18.06,12.449c10.887-25.926,28.868-48.094,51.45-64.279l4.657-7.162v3.861
|
||||
c16.364-10.811,34.941-18.477,54.885-22.234c-5.926-13.152-10.899-28.819-14.546-43.586c-4.249-17.232-6.741-33.201-6.741-42.245
|
||||
c0-3.351,0.433-6.579,1.09-9.727l14.8,48.975c0.766,2.565,2.984,4.417,5.641,4.73c0.268,0.03,0.529,0.046,0.784,0.046
|
||||
c2.365,0,4.602-1.25,5.818-3.34l11.538-19.873l3.246,3.235c-7.768,10.276-10.82,39.199-12.005,60.314
|
||||
c5.994-0.734,12.066-1.222,18.254-1.222c6.201,0,12.292,0.497,18.304,1.23c-1.186-21.114-4.237-50.037-12.024-60.322l3.246-3.255
|
||||
l11.574,19.892c1.216,2.09,3.422,3.34,5.805,3.34c0.255,0,0.522-0.016,0.779-0.046c2.655-0.314,4.874-2.166,5.659-4.73
|
||||
l14.791-48.872c0.634,3.116,1.051,6.313,1.051,9.624c0,16.806-8.425,57.342-21.276,85.831
|
||||
c19.981,3.768,38.588,11.453,54.953,22.291v-3.899l4.735,7.256c22.504,16.193,40.436,38.324,51.293,64.206l18.139-12.488
|
||||
C414.919,410.335,418.403,392.594,409.772,379.327z M219.962,276.685l-8.613-28.53l12.388-8.24l12.322,9.088L219.962,276.685z
|
||||
M269.783,276.685l-16.079-27.683l12.31-9.088l12.401,8.24L269.783,276.685z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_202_"
|
||||
d="M202.716,424.721l14.705,19.349c8.151-4.914,17.598-7.607,27.427-7.607c9.848,0,19.313,2.692,27.464,7.615
|
||||
l14.705-19.363c-11.465-10.799-26.346-16.721-42.15-16.721C229.055,407.994,214.156,413.925,202.716,424.721z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_201_"
|
||||
d="M176.693,160.576c0.499,25.456,14.96,47.266,36.03,58.591c9.622,5.18,20.473,8.384,32.174,8.384
|
||||
c11.683,0,22.503-3.198,32.114-8.368c21.063-11.311,35.579-33.117,36.06-58.582c-17.379,12.075-41.896,19.923-68.174,19.923
|
||||
S194.096,172.676,176.693,160.576z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_200_"
|
||||
d="M174.741,100.132l-0.225,20.205c0.037,15.991,31.524,36.82,70.38,36.82
|
||||
c38.855,0,70.314-20.829,70.331-36.82l-0.207-20.195c10.224-2.662,18.158-6.617,23.239-12.301
|
||||
c3.981-4.434,6.267-9.902,6.267-16.783C344.528,39.883,299.879,0,244.897,0c-55.031,0-99.631,39.883-99.631,71.058
|
||||
c0,6.881,2.273,12.34,6.236,16.783C156.585,93.524,164.529,97.479,174.741,100.132z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_197_"
|
||||
d="M244.848,356.925c-73.255,0-132.858,59.605-132.858,132.86h33.47c0-0.048,0-0.114,0-0.161v-0.031
|
||||
c1.088-6.557,6.711-11.334,13.313-11.334c0.115,0,0.243,0.01,0.37,0.01l51.707,1.341c-0.973,3.247-1.648,6.619-1.648,10.176h71.322
|
||||
c0-3.557-0.669-6.929-1.66-10.176l51.724-1.341c0.109,0,0.219-0.01,0.353-0.01c6.595,0,12.243,4.777,13.324,11.334v0.031
|
||||
c0,0.047,0,0.113,0,0.161h33.44C377.706,416.53,318.122,356.925,244.848,356.925z M302.201,433.91l-27.562,36.317
|
||||
c-6.389-9.687-17.325-16.104-29.792-16.104c-12.437,0-23.385,6.411-29.762,16.098l-27.555-36.3
|
||||
c-4.699-6.194-4.11-14.923,1.392-20.424c15.452-15.443,35.689-23.166,55.943-23.166c20.249,0,40.484,7.723,55.961,23.179
|
||||
C306.322,419.007,306.901,427.719,302.201,433.91z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
@ -7,22 +7,24 @@ import {
|
||||
Users,
|
||||
Earth,
|
||||
Landmark,
|
||||
BusFront,
|
||||
GitBranch,
|
||||
Car,
|
||||
Table,
|
||||
Notebook,
|
||||
Split,
|
||||
Newspaper,
|
||||
PersonStanding,
|
||||
Cpu,
|
||||
BookImage,
|
||||
} from "lucide-react";
|
||||
import { CarrierSvg } from "./CarrierSvg";
|
||||
|
||||
export const DRAWER_WIDTH = 300;
|
||||
|
||||
interface NavigationItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
icon?: LucideIcon | React.ReactNode;
|
||||
path?: string;
|
||||
onClick?: () => void;
|
||||
nestedItems?: NavigationItem[];
|
||||
@ -34,43 +36,6 @@ export const NAVIGATION_ITEMS: {
|
||||
secondary: NavigationItem[];
|
||||
} = {
|
||||
primary: [
|
||||
{
|
||||
id: "countries",
|
||||
label: "Страны",
|
||||
icon: Earth,
|
||||
path: "/country",
|
||||
},
|
||||
{
|
||||
id: "cities",
|
||||
label: "Города",
|
||||
icon: Building2,
|
||||
path: "/city",
|
||||
},
|
||||
{
|
||||
id: "carriers",
|
||||
label: "Перевозчики",
|
||||
icon: BusFront,
|
||||
path: "/carrier",
|
||||
},
|
||||
|
||||
{
|
||||
id: "snapshots",
|
||||
label: "Снапшоты",
|
||||
icon: GitBranch,
|
||||
path: "/snapshot",
|
||||
},
|
||||
{
|
||||
id: "map",
|
||||
label: "Карта",
|
||||
icon: Map,
|
||||
path: "/map",
|
||||
},
|
||||
{
|
||||
id: "devices",
|
||||
label: "Устройства",
|
||||
icon: Cpu,
|
||||
path: "/devices",
|
||||
},
|
||||
{
|
||||
id: "all",
|
||||
label: "Все сущности",
|
||||
@ -106,15 +71,58 @@ export const NAVIGATION_ITEMS: {
|
||||
icon: Split,
|
||||
path: "/route",
|
||||
},
|
||||
{
|
||||
id: "reference",
|
||||
label: "Справочник",
|
||||
icon: Notebook,
|
||||
nestedItems: [
|
||||
{
|
||||
id: "countries",
|
||||
label: "Страны",
|
||||
icon: Earth,
|
||||
path: "/country",
|
||||
},
|
||||
{
|
||||
id: "cities",
|
||||
label: "Города",
|
||||
icon: Building2,
|
||||
path: "/city",
|
||||
},
|
||||
{
|
||||
id: "carriers",
|
||||
label: "Перевозчики",
|
||||
// @ts-ignore
|
||||
icon: CarrierSvg,
|
||||
path: "/carrier",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: "vehicles",
|
||||
label: "Транспорт",
|
||||
icon: Car,
|
||||
path: "/vehicle",
|
||||
id: "snapshots",
|
||||
label: "Снапшоты",
|
||||
icon: GitBranch,
|
||||
path: "/snapshot",
|
||||
},
|
||||
{
|
||||
id: "map",
|
||||
label: "Карта",
|
||||
icon: Map,
|
||||
path: "/map",
|
||||
},
|
||||
{
|
||||
id: "devices",
|
||||
label: "Устройства",
|
||||
icon: Cpu,
|
||||
path: "/devices",
|
||||
},
|
||||
// {
|
||||
// id: "vehicles",
|
||||
// label: "Транспорт",
|
||||
// icon: Car,
|
||||
// path: "/vehicle",
|
||||
// },
|
||||
{
|
||||
id: "users",
|
||||
label: "Пользователи",
|
||||
|
@ -120,7 +120,6 @@ export const PreviewMediaDialog = observer(
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Тип медиа"
|
||||
@ -133,7 +132,7 @@ export const PreviewMediaDialog = observer(
|
||||
sx={{ width: "50%" }}
|
||||
/>
|
||||
|
||||
<Box className="flex gap-4 h-full">
|
||||
<Box className="flex gap-4">
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
@ -142,8 +141,8 @@ export const PreviewMediaDialog = observer(
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 400,
|
||||
}}
|
||||
className="max-h-[40vh]"
|
||||
>
|
||||
<MediaViewer
|
||||
media={{
|
||||
@ -151,6 +150,7 @@ export const PreviewMediaDialog = observer(
|
||||
media_type: media.media_type,
|
||||
filename: media.filename,
|
||||
}}
|
||||
fullHeight
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
|
@ -188,7 +188,7 @@ export const UploadMediaDialog = observer(
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Box className="flex gap-4 h-full">
|
||||
<Box className="flex gap-4 h-[40vh]">
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
@ -197,7 +197,7 @@ export const UploadMediaDialog = observer(
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 400,
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{/* <MediaViewer
|
||||
|
@ -1,4 +1,10 @@
|
||||
import { authInstance, editSightStore, Language, languageStore } from "@shared";
|
||||
import {
|
||||
authInstance,
|
||||
editSightStore,
|
||||
Language,
|
||||
languageStore,
|
||||
languageInstance,
|
||||
} from "@shared";
|
||||
import { computed, makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
export type Article = {
|
||||
@ -6,6 +12,18 @@ export type Article = {
|
||||
heading: string;
|
||||
body: string;
|
||||
service_name: string;
|
||||
ru?: {
|
||||
heading: string;
|
||||
body: string;
|
||||
};
|
||||
en?: {
|
||||
heading: string;
|
||||
body: string;
|
||||
};
|
||||
zh?: {
|
||||
heading: string;
|
||||
body: string;
|
||||
};
|
||||
};
|
||||
|
||||
type Media = {
|
||||
@ -99,13 +117,25 @@ class ArticlesStore {
|
||||
this.articleLoading = false;
|
||||
};
|
||||
|
||||
getArticle = async (id: number) => {
|
||||
getArticle = async (id: number, language?: Language) => {
|
||||
this.articleLoading = true;
|
||||
const response = await authInstance.get(`/article/${id}`);
|
||||
|
||||
runInAction(() => {
|
||||
this.articleData = response.data;
|
||||
});
|
||||
if (language) {
|
||||
const response = await languageInstance(language).get(`/article/${id}`);
|
||||
runInAction(() => {
|
||||
if (!this.articleData) {
|
||||
this.articleData = { id, heading: "", body: "", service_name: "" };
|
||||
}
|
||||
this.articleData[language] = {
|
||||
heading: response.data.heading,
|
||||
body: response.data.body,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const response = await authInstance.get(`/article/${id}`);
|
||||
runInAction(() => {
|
||||
this.articleData = response.data;
|
||||
});
|
||||
}
|
||||
this.articleLoading = false;
|
||||
};
|
||||
|
||||
@ -137,6 +167,20 @@ class ArticlesStore {
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
deleteArticles = async (ids: number[]) => {
|
||||
for (const id of ids) {
|
||||
await authInstance.delete(`/article/${id}`);
|
||||
}
|
||||
|
||||
for (const id of ["ru", "en", "zh"] as Language[]) {
|
||||
runInAction(() => {
|
||||
this.articleList[id].data = this.articleList[id].data.filter(
|
||||
(article) => !ids.includes(article.id)
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const articlesStore = new ArticlesStore();
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { authInstance } from "@shared";
|
||||
import { authInstance, cityStore, languageStore } from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
export type Carrier = {
|
||||
@ -9,9 +9,9 @@ export type Carrier = {
|
||||
city: string;
|
||||
city_id: number;
|
||||
logo: string;
|
||||
main_color: string;
|
||||
left_color: string;
|
||||
right_color: string;
|
||||
// main_color: string;
|
||||
// left_color: string;
|
||||
// right_color: string;
|
||||
};
|
||||
|
||||
type Carriers = {
|
||||
@ -68,9 +68,9 @@ class CarrierStore {
|
||||
city: "",
|
||||
city_id: 0,
|
||||
logo: "",
|
||||
main_color: "",
|
||||
left_color: "",
|
||||
right_color: "",
|
||||
// main_color: "",
|
||||
// left_color: "",
|
||||
// right_color: "",
|
||||
};
|
||||
}
|
||||
this.carrier[id] = response.data;
|
||||
@ -81,22 +81,22 @@ class CarrierStore {
|
||||
createCarrier = async (
|
||||
fullName: string,
|
||||
shortName: string,
|
||||
city: string,
|
||||
|
||||
cityId: number,
|
||||
main_color: string,
|
||||
left_color: string,
|
||||
right_color: string,
|
||||
// main_color: string,
|
||||
// left_color: string,
|
||||
// right_color: string,
|
||||
slogan: string,
|
||||
logoId: string
|
||||
) => {
|
||||
const response = await authInstance.post("/carrier", {
|
||||
full_name: fullName,
|
||||
short_name: shortName,
|
||||
city,
|
||||
city: "",
|
||||
city_id: cityId,
|
||||
main_color,
|
||||
left_color,
|
||||
right_color,
|
||||
// main_color,
|
||||
// left_color,
|
||||
// right_color,
|
||||
slogan,
|
||||
logo: logoId,
|
||||
});
|
||||
@ -108,11 +108,11 @@ class CarrierStore {
|
||||
editCarrierData = {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
city: "",
|
||||
|
||||
city_id: 0,
|
||||
main_color: "",
|
||||
left_color: "",
|
||||
right_color: "",
|
||||
// main_color: "",
|
||||
// left_color: "",
|
||||
// right_color: "",
|
||||
slogan: "",
|
||||
logo: "",
|
||||
};
|
||||
@ -120,32 +120,35 @@ class CarrierStore {
|
||||
setEditCarrierData = (
|
||||
fullName: string,
|
||||
shortName: string,
|
||||
city: string,
|
||||
|
||||
cityId: number,
|
||||
main_color: string,
|
||||
left_color: string,
|
||||
right_color: string,
|
||||
// main_color: string,
|
||||
// left_color: string,
|
||||
// right_color: string,
|
||||
slogan: string,
|
||||
logoId: string
|
||||
) => {
|
||||
this.editCarrierData = {
|
||||
full_name: fullName,
|
||||
short_name: shortName,
|
||||
city,
|
||||
|
||||
city_id: cityId,
|
||||
main_color: main_color,
|
||||
left_color: left_color,
|
||||
right_color: right_color,
|
||||
// main_color: main_color,
|
||||
// left_color: left_color,
|
||||
// right_color: right_color,
|
||||
slogan: slogan,
|
||||
logo: logoId,
|
||||
};
|
||||
};
|
||||
|
||||
editCarrier = async (id: number) => {
|
||||
const response = await authInstance.patch(
|
||||
`/carrier/${id}`,
|
||||
this.editCarrierData
|
||||
);
|
||||
const { language } = languageStore;
|
||||
const response = await authInstance.patch(`/carrier/${id}`, {
|
||||
...this.editCarrierData,
|
||||
city: cityStore.cities[language].data.find(
|
||||
(city) => city.id === this.editCarrierData.city_id
|
||||
)?.name,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
this.carriers.data = this.carriers.data.map((carrier) =>
|
||||
|
@ -4,6 +4,7 @@ import {
|
||||
Language,
|
||||
languageStore,
|
||||
countryStore,
|
||||
CashedCountries,
|
||||
} from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
@ -16,9 +17,18 @@ export type City = {
|
||||
};
|
||||
|
||||
export type CashedCities = {
|
||||
ru: City[];
|
||||
en: City[];
|
||||
zh: City[];
|
||||
ru: {
|
||||
data: City[];
|
||||
loaded: boolean;
|
||||
};
|
||||
en: {
|
||||
data: City[];
|
||||
loaded: boolean;
|
||||
};
|
||||
zh: {
|
||||
data: City[];
|
||||
loaded: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type CashedCity = {
|
||||
@ -29,9 +39,18 @@ export type CashedCity = {
|
||||
|
||||
class CityStore {
|
||||
cities: CashedCities = {
|
||||
ru: [],
|
||||
en: [],
|
||||
zh: [],
|
||||
ru: {
|
||||
data: [],
|
||||
loaded: false,
|
||||
},
|
||||
en: {
|
||||
data: [],
|
||||
loaded: false,
|
||||
},
|
||||
zh: {
|
||||
data: [],
|
||||
loaded: false,
|
||||
},
|
||||
};
|
||||
|
||||
city: Record<string, CashedCity> = {};
|
||||
@ -40,25 +59,37 @@ class CityStore {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
ruCities: City[] = [];
|
||||
ruCities: {
|
||||
data: City[];
|
||||
loaded: boolean;
|
||||
} = {
|
||||
data: [],
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
getRuCities = async () => {
|
||||
if (this.ruCities.loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await languageInstance("ru").get(`/city`);
|
||||
|
||||
runInAction(() => {
|
||||
this.ruCities = response.data;
|
||||
this.ruCities.data = response.data;
|
||||
this.ruCities.loaded = true;
|
||||
});
|
||||
};
|
||||
|
||||
getCities = async (language: keyof CashedCities) => {
|
||||
if (this.cities[language] && this.cities[language].length > 0) {
|
||||
if (this.cities[language].loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await authInstance.get(`/city`);
|
||||
|
||||
runInAction(() => {
|
||||
this.cities[language] = response.data;
|
||||
this.cities[language].data = response.data;
|
||||
this.cities[language].loaded = true;
|
||||
});
|
||||
};
|
||||
|
||||
@ -83,19 +114,22 @@ class CityStore {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
deleteCity = async (code: string, language: keyof CashedCities) => {
|
||||
deleteCity = async (code: string) => {
|
||||
await authInstance.delete(`/city/${code}`);
|
||||
|
||||
runInAction(() => {
|
||||
this.cities[language] = this.cities[language].filter(
|
||||
(city) => city.country_code !== code
|
||||
);
|
||||
this.city[code][language] = null;
|
||||
for (const secondaryLanguage of ["ru", "en", "zh"] as Language[]) {
|
||||
this.cities[secondaryLanguage].data = this.cities[
|
||||
secondaryLanguage
|
||||
].data.filter((city) => city.id !== Number(code));
|
||||
if (this.city[code]) {
|
||||
this.city[code][secondaryLanguage] = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
createCityData = {
|
||||
country: "",
|
||||
country_code: "",
|
||||
arms: "",
|
||||
ru: {
|
||||
@ -111,14 +145,12 @@ class CityStore {
|
||||
|
||||
setCreateCityData = (
|
||||
name: string,
|
||||
country: string,
|
||||
country_code: string,
|
||||
arms: string,
|
||||
language: keyof CashedCities
|
||||
) => {
|
||||
this.createCityData = {
|
||||
...this.createCityData,
|
||||
country: country,
|
||||
country_code: country_code,
|
||||
arms: arms,
|
||||
[language]: {
|
||||
@ -127,73 +159,84 @@ class CityStore {
|
||||
};
|
||||
};
|
||||
|
||||
createCity = async () => {
|
||||
const { language } = languageStore;
|
||||
const { country, country_code, arms } = this.createCityData;
|
||||
const { name } = this.createCityData[language as keyof CashedCities];
|
||||
async createCity() {
|
||||
const language = languageStore.language as Language;
|
||||
const { country_code, arms } = this.createCityData;
|
||||
const { name } = this.createCityData[language];
|
||||
|
||||
if (name && country && country_code && arms) {
|
||||
const cityResponse = await languageInstance(language as Language).post(
|
||||
"/city",
|
||||
{
|
||||
name: name,
|
||||
country: country,
|
||||
country_code: country_code,
|
||||
arms: arms,
|
||||
}
|
||||
);
|
||||
if (!name || !country_code) {
|
||||
return;
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.cities[language as keyof CashedCities] = [
|
||||
...this.cities[language as keyof CashedCities],
|
||||
cityResponse.data,
|
||||
];
|
||||
try {
|
||||
// Create city in primary language
|
||||
const cityResponse = await languageInstance(language).post("/city", {
|
||||
name,
|
||||
country:
|
||||
countryStore.countries[language as keyof CashedCountries]?.data.find(
|
||||
(c) => c.code === country_code
|
||||
)?.name || "",
|
||||
country_code,
|
||||
arms: arms || "",
|
||||
});
|
||||
|
||||
for (const secondaryLanguage of ["ru", "en", "zh"].filter(
|
||||
const cityId = cityResponse.data.id;
|
||||
|
||||
// Create/update other language versions
|
||||
for (const secondaryLanguage of (["ru", "en", "zh"] as Language[]).filter(
|
||||
(l) => l !== language
|
||||
)) {
|
||||
const { name } =
|
||||
this.createCityData[secondaryLanguage as keyof CashedCities];
|
||||
const { name: secondaryName } = this.createCityData[secondaryLanguage];
|
||||
|
||||
const patchResponse = await languageInstance(
|
||||
secondaryLanguage as Language
|
||||
).patch(`/city/${cityResponse.data.id}`, {
|
||||
name: name,
|
||||
country: country,
|
||||
country_code: country_code,
|
||||
arms: arms,
|
||||
});
|
||||
// Get country name in secondary language
|
||||
const countryName =
|
||||
countryStore.countries[secondaryLanguage]?.data.find(
|
||||
(c) => c.code === country_code
|
||||
)?.name || "";
|
||||
|
||||
const patchResponse = await languageInstance(secondaryLanguage).patch(
|
||||
`/city/${cityId}`,
|
||||
{
|
||||
name: secondaryName || "",
|
||||
country: countryName,
|
||||
country_code: country_code || "",
|
||||
arms: arms || "",
|
||||
}
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
this.cities[secondaryLanguage as keyof CashedCities] = [
|
||||
...this.cities[secondaryLanguage as keyof CashedCities],
|
||||
this.cities[secondaryLanguage].data = [
|
||||
...this.cities[secondaryLanguage].data,
|
||||
patchResponse.data,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.createCityData = {
|
||||
country: "",
|
||||
country_code: "",
|
||||
arms: "",
|
||||
ru: {
|
||||
name: "",
|
||||
},
|
||||
en: {
|
||||
name: "",
|
||||
},
|
||||
zh: {
|
||||
name: "",
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
// Update primary language data
|
||||
runInAction(() => {
|
||||
this.cities[language].data = [
|
||||
...this.cities[language].data,
|
||||
cityResponse.data,
|
||||
];
|
||||
});
|
||||
|
||||
// Reset form data
|
||||
runInAction(() => {
|
||||
this.createCityData = {
|
||||
country_code: "",
|
||||
arms: "",
|
||||
ru: { name: "" },
|
||||
en: { name: "" },
|
||||
zh: { name: "" },
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating city:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
editCityData = {
|
||||
country: "",
|
||||
country_code: "",
|
||||
arms: "",
|
||||
ru: {
|
||||
@ -209,14 +252,12 @@ class CityStore {
|
||||
|
||||
setEditCityData = (
|
||||
name: string,
|
||||
country: string,
|
||||
country_code: string,
|
||||
arms: string,
|
||||
language: keyof CashedCities
|
||||
) => {
|
||||
this.editCityData = {
|
||||
...this.editCityData,
|
||||
country: country,
|
||||
country_code: country_code,
|
||||
arms: arms,
|
||||
|
||||
@ -232,7 +273,7 @@ class CityStore {
|
||||
const { name } = this.editCityData[language as keyof CashedCities];
|
||||
const { countries } = countryStore;
|
||||
|
||||
const country = countries[language as keyof CashedCities].find(
|
||||
const country = countries[language as keyof CashedCities].data.find(
|
||||
(country) => country.code === country_code
|
||||
);
|
||||
|
||||
@ -255,9 +296,9 @@ class CityStore {
|
||||
}
|
||||
|
||||
if (this.cities[language as keyof CashedCities]) {
|
||||
this.cities[language as keyof CashedCities] = this.cities[
|
||||
this.cities[language as keyof CashedCities].data = this.cities[
|
||||
language as keyof CashedCities
|
||||
].map((city) =>
|
||||
].data.map((city) =>
|
||||
city.id === Number(code)
|
||||
? {
|
||||
id: city.id,
|
||||
|
@ -12,9 +12,18 @@ export type Country = {
|
||||
};
|
||||
|
||||
export type CashedCountries = {
|
||||
ru: Country[];
|
||||
en: Country[];
|
||||
zh: Country[];
|
||||
ru: {
|
||||
data: Country[];
|
||||
loaded: boolean;
|
||||
};
|
||||
en: {
|
||||
data: Country[];
|
||||
loaded: boolean;
|
||||
};
|
||||
zh: {
|
||||
data: Country[];
|
||||
loaded: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type CashedCountry = {
|
||||
@ -25,9 +34,18 @@ export type CashedCountry = {
|
||||
|
||||
class CountryStore {
|
||||
countries: CashedCountries = {
|
||||
ru: [],
|
||||
en: [],
|
||||
zh: [],
|
||||
ru: {
|
||||
data: [],
|
||||
loaded: false,
|
||||
},
|
||||
en: {
|
||||
data: [],
|
||||
loaded: false,
|
||||
},
|
||||
zh: {
|
||||
data: [],
|
||||
loaded: false,
|
||||
},
|
||||
};
|
||||
|
||||
country: Record<string, CashedCountry> = {};
|
||||
@ -37,14 +55,15 @@ class CountryStore {
|
||||
}
|
||||
|
||||
getCountries = async (language: keyof CashedCountries) => {
|
||||
if (this.countries[language] && this.countries[language].length > 0) {
|
||||
if (this.countries[language].loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await authInstance.get(`/country`);
|
||||
const response = await languageInstance(language).get(`/country`);
|
||||
|
||||
runInAction(() => {
|
||||
this.countries[language] = response.data;
|
||||
this.countries[language].data = response.data;
|
||||
this.countries[language].loaded = true;
|
||||
});
|
||||
};
|
||||
|
||||
@ -76,10 +95,15 @@ class CountryStore {
|
||||
await authInstance.delete(`/country/${code}`);
|
||||
|
||||
runInAction(() => {
|
||||
this.countries[language] = this.countries[language].filter(
|
||||
this.countries[language].data = this.countries[language].data.filter(
|
||||
(country) => country.code !== code
|
||||
);
|
||||
this.country[code][language] = null;
|
||||
this.countries[language].loaded = true;
|
||||
this.country[code] = {
|
||||
ru: null,
|
||||
en: null,
|
||||
zh: null,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@ -121,8 +145,8 @@ class CountryStore {
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
this.countries[language as keyof CashedCountries] = [
|
||||
...this.countries[language as keyof CashedCountries],
|
||||
this.countries[language as keyof CashedCountries].data = [
|
||||
...this.countries[language as keyof CashedCountries].data,
|
||||
{ code: code, name: name },
|
||||
];
|
||||
});
|
||||
@ -142,8 +166,8 @@ class CountryStore {
|
||||
);
|
||||
}
|
||||
runInAction(() => {
|
||||
this.countries[secondaryLanguage as keyof CashedCountries] = [
|
||||
...this.countries[secondaryLanguage as keyof CashedCountries],
|
||||
this.countries[secondaryLanguage as keyof CashedCountries].data = [
|
||||
...this.countries[secondaryLanguage as keyof CashedCountries].data,
|
||||
{ code: code, name: name },
|
||||
];
|
||||
});
|
||||
@ -204,11 +228,10 @@ class CountryStore {
|
||||
};
|
||||
}
|
||||
if (this.countries[language as keyof CashedCountries]) {
|
||||
this.countries[language as keyof CashedCountries] = this.countries[
|
||||
language as keyof CashedCountries
|
||||
].map((country) =>
|
||||
country.code === code ? { code, name } : country
|
||||
);
|
||||
this.countries[language as keyof CashedCountries].data =
|
||||
this.countries[language as keyof CashedCountries].data.map(
|
||||
(country) => (country.code === code ? { code, name } : country)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -2,15 +2,20 @@ import { Canvas } from "@react-three/fiber";
|
||||
import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
|
||||
|
||||
type ModelViewerProps = {
|
||||
width?: string;
|
||||
fileUrl: string;
|
||||
height?: string;
|
||||
};
|
||||
|
||||
export const ThreeView = ({ fileUrl, height = "100%" }: ModelViewerProps) => {
|
||||
export const ThreeView = ({
|
||||
fileUrl,
|
||||
height = "100%",
|
||||
width = "100%",
|
||||
}: ModelViewerProps) => {
|
||||
const { scene } = useGLTF(fileUrl);
|
||||
|
||||
return (
|
||||
<Canvas style={{ width: "100%", height: height }}>
|
||||
<Canvas style={{ height: height, width: width }}>
|
||||
<ambientLight />
|
||||
<directionalLight />
|
||||
<Stage environment="city" intensity={0.6}>
|
||||
|
@ -13,16 +13,30 @@ export function MediaViewer({
|
||||
media,
|
||||
className,
|
||||
fullWidth,
|
||||
}: Readonly<{ media?: MediaData; className?: string; fullWidth?: boolean }>) {
|
||||
fullHeight,
|
||||
}: Readonly<{
|
||||
media?: MediaData;
|
||||
className?: string;
|
||||
fullWidth?: boolean;
|
||||
fullHeight?: boolean;
|
||||
}>) {
|
||||
const token = localStorage.getItem("token");
|
||||
return (
|
||||
<Box className={className} width={fullWidth ? "100%" : "auto"}>
|
||||
<Box
|
||||
className={className}
|
||||
width={fullWidth ? "100%" : "auto"}
|
||||
height={fullHeight ? "100%" : "auto"}
|
||||
>
|
||||
{media?.media_type === 1 && (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
alt={media?.filename}
|
||||
style={{
|
||||
height: fullHeight ? "100%" : "auto",
|
||||
width: fullWidth ? "100%" : "auto",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -48,6 +62,10 @@ export function MediaViewer({
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
alt={media?.filename}
|
||||
style={{
|
||||
height: fullHeight ? "100%" : "auto",
|
||||
width: fullWidth ? "100%" : "auto",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{media?.media_type === 4 && (
|
||||
@ -78,6 +96,7 @@ export function MediaViewer({
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
height="100%"
|
||||
width="1000px"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
@ -163,17 +163,22 @@ export const InformationTab = observer(
|
||||
/>
|
||||
|
||||
<Autocomplete
|
||||
options={ruCities ?? []}
|
||||
options={ruCities?.data ?? []}
|
||||
value={
|
||||
ruCities.find((city) => city.id === sight.common.city_id) ??
|
||||
null
|
||||
ruCities?.data?.find(
|
||||
(city) => city.id === sight.common.city_id
|
||||
) ?? null
|
||||
}
|
||||
getOptionLabel={(option) => option.name}
|
||||
onChange={(_, value) => {
|
||||
setCity(value?.id ?? 0);
|
||||
handleChange(language as Language, {
|
||||
city_id: value?.id ?? 0,
|
||||
});
|
||||
handleChange(
|
||||
language as Language,
|
||||
{
|
||||
city_id: value?.id ?? 0,
|
||||
},
|
||||
true
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Город" />
|
||||
|
Reference in New Issue
Block a user