Compare commits

..

3 Commits

Author SHA1 Message Date
2117a6836e feat: Add carriers translation on 3 languages 2025-06-13 11:17:18 +03:00
f49caf3ec8 fix: Map page finish 2025-06-13 09:17:24 +03:00
300ff262ce fix: Fix Map page 2025-06-12 22:50:43 +03:00
55 changed files with 3122 additions and 1640 deletions

View File

@ -16,22 +16,22 @@ import {
SnapshotListPage, SnapshotListPage,
CarrierListPage, CarrierListPage,
StationListPage, StationListPage,
VehicleListPage, // VehicleListPage,
ArticleListPage, ArticleListPage,
CityPreviewPage, CityPreviewPage,
CountryPreviewPage, // CountryPreviewPage,
VehiclePreviewPage, // VehiclePreviewPage,
CarrierPreviewPage, // CarrierPreviewPage,
SnapshotCreatePage, SnapshotCreatePage,
CountryCreatePage, CountryCreatePage,
CityCreatePage, CityCreatePage,
CarrierCreatePage, CarrierCreatePage,
VehicleCreatePage, // VehicleCreatePage,
CountryEditPage, CountryEditPage,
CityEditPage, CityEditPage,
UserCreatePage, UserCreatePage,
UserEditPage, UserEditPage,
VehicleEditPage, // VehicleEditPage,
CarrierEditPage, CarrierEditPage,
StationCreatePage, StationCreatePage,
StationPreviewPage, StationPreviewPage,
@ -39,6 +39,7 @@ import {
RouteCreatePage, RouteCreatePage,
RoutePreview, RoutePreview,
RouteEditPage, RouteEditPage,
ArticlePreviewPage,
} from "@pages"; } from "@pages";
import { authStore, createSightStore, editSightStore } from "@shared"; import { authStore, createSightStore, editSightStore } from "@shared";
import { Layout } from "@widgets"; import { Layout } from "@widgets";
@ -133,7 +134,7 @@ const router = createBrowserRouter([
// Country // Country
{ path: "country", element: <CountryListPage /> }, { path: "country", element: <CountryListPage /> },
{ path: "country/create", element: <CountryCreatePage /> }, { path: "country/create", element: <CountryCreatePage /> },
{ path: "country/:id", element: <CountryPreviewPage /> }, // { path: "country/:id", element: <CountryPreviewPage /> },
{ path: "country/:id/edit", element: <CountryEditPage /> }, { path: "country/:id/edit", element: <CountryEditPage /> },
// City // City
{ path: "city", element: <CityListPage /> }, { path: "city", element: <CityListPage /> },
@ -156,7 +157,7 @@ const router = createBrowserRouter([
// Carrier // Carrier
{ path: "carrier", element: <CarrierListPage /> }, { path: "carrier", element: <CarrierListPage /> },
{ path: "carrier/create", element: <CarrierCreatePage /> }, { path: "carrier/create", element: <CarrierCreatePage /> },
{ path: "carrier/:id", element: <CarrierPreviewPage /> }, // { path: "carrier/:id", element: <CarrierPreviewPage /> },
{ path: "carrier/:id/edit", element: <CarrierEditPage /> }, { path: "carrier/:id/edit", element: <CarrierEditPage /> },
// Station // Station
{ path: "station", element: <StationListPage /> }, { path: "station", element: <StationListPage /> },
@ -164,13 +165,13 @@ const router = createBrowserRouter([
{ path: "station/:id", element: <StationPreviewPage /> }, { path: "station/:id", element: <StationPreviewPage /> },
{ path: "station/:id/edit", element: <StationEditPage /> }, { path: "station/:id/edit", element: <StationEditPage /> },
// Vehicle // Vehicle
{ path: "vehicle", element: <VehicleListPage /> }, // { path: "vehicle", element: <VehicleListPage /> },
{ path: "vehicle/create", element: <VehicleCreatePage /> }, // { path: "vehicle/create", element: <VehicleCreatePage /> },
{ path: "vehicle/:id", element: <VehiclePreviewPage /> }, // { path: "vehicle/:id", element: <VehiclePreviewPage /> },
{ path: "vehicle/:id/edit", element: <VehicleEditPage /> }, // { path: "vehicle/:id/edit", element: <VehicleEditPage /> },
// Article // Article
{ path: "article", element: <ArticleListPage /> }, { path: "article", element: <ArticleListPage /> },
// { path: "article/:id", element: <ArticlePreviewPage /> }, { path: "article/:id", element: <ArticlePreviewPage /> },
// { path: "media/create", element: <CreateMediaPage /> }, // { path: "media/create", element: <CreateMediaPage /> },
], ],
}, },

View File

@ -9,6 +9,7 @@ import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { NavigationItem } from "../model"; import type { NavigationItem } from "../model";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { Plus } from "lucide-react";
interface NavigationItemProps { interface NavigationItemProps {
item: NavigationItem; item: NavigationItem;
@ -58,7 +59,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
justifyContent: "center", justifyContent: "center",
}, },
isNested && { isNested && {
pl: 4, pl: open ? 4 : 2.5,
}, },
isActive && { isActive && {
backgroundColor: "rgba(0, 0, 0, 0.08)", backgroundColor: "rgba(0, 0, 0, 0.08)",
@ -84,7 +85,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
}, },
]} ]}
> >
<Icon /> {Icon ? <Icon /> : <Plus />}
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary={item.label} primary={item.label}
@ -108,7 +109,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
{item.nestedItems && ( {item.nestedItems && (
<Collapse in={isExpanded && open} timeout="auto" unmountOnExit> <Collapse in={isExpanded} timeout="auto" unmountOnExit>
<List component="div" disablePadding> <List component="div" disablePadding>
{item.nestedItems.map((nestedItem) => ( {item.nestedItems.map((nestedItem) => (
<NavigationItemComponent <NavigationItemComponent

View File

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

View File

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

View File

@ -2,16 +2,18 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { articlesStore, languageStore } from "@shared"; import { articlesStore, languageStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; 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 { useNavigate } from "react-router-dom";
import { DeleteModal, LanguageSwitcher } from "@widgets"; import { DeleteModal, LanguageSwitcher } from "@widgets";
export const ArticleListPage = observer(() => { export const ArticleListPage = observer(() => {
const { articleList, getArticleList } = articlesStore; const { articleList, getArticleList, deleteArticles } = articlesStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); 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 { language } = languageStore;
const [ids, setIds] = useState<number[]>([]);
useEffect(() => { useEffect(() => {
getArticleList(); getArticleList();
@ -22,6 +24,15 @@ export const ArticleListPage = observer(() => {
field: "heading", field: "heading",
headerName: "Название", headerName: "Название",
flex: 1, 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 /> <LanguageSwitcher />
<div className="w-full"> <div className="w-full">
<DataGrid <div className="flex justify-between items-center mb-10">
rows={rows} <h1 className="text-2xl">Статьи</h1>
columns={columns} </div>
hideFooterPagination
hideFooter <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> </div>
<DeleteModal <DeleteModal
open={isDeleteModalOpen} open={isDeleteModalOpen}
onDelete={async () => { onDelete={async () => {
if (rowId) { if (rowId) {
await deleteArticles([parseInt(rowId)]);
getArticleList(); getArticleList();
} }
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
@ -81,6 +116,19 @@ export const ArticleListPage = observer(() => {
setRowId(null); setRowId(null);
}} }}
/> />
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await deleteArticles(ids);
getArticleList();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</> </>
); );
}); });

View File

@ -0,0 +1,85 @@
import { Paper, Box, Typography } from "@mui/material";
import { MediaViewer, ReactMarkdownComponent } from "@widgets";
import { articlesStore, languageStore } from "@shared";
import { observer } from "mobx-react-lite";
export const PreviewLeftWidget = observer(() => {
const { articleMedia, articleData } = articlesStore;
const { language } = languageStore;
return (
<Paper
elevation={3}
sx={{
width: "100%",
minWidth: 320,
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
overflowY: "auto",
display: "flex",
flexDirection: "column",
borderRadius: "10px",
}}
>
<Box
sx={{
overflow: "hidden",
width: "100%",
minHeight: 100,
padding: "3px",
display: "flex",
alignItems: "center",
justifyContent: "center",
"& img": {
borderTopLeftRadius: "10px",
borderTopRightRadius: "10px",
width: "100%",
height: "auto",
objectFit: "contain",
},
}}
>
{articleMedia && <MediaViewer media={articleMedia} fullWidth />}
</Box>
<Box
sx={{
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
color: "white",
margin: "5px 0px 5px 0px",
display: "flex",
flexDirection: "column",
gap: 1,
padding: 1,
}}
>
<Typography
variant="h5"
component="h2"
sx={{
wordBreak: "break-word",
fontSize: "24px",
fontWeight: 700,
lineHeight: "120%",
}}
>
{articleData?.[language]?.heading || "Название информации"}
</Typography>
</Box>
{articleData?.[language]?.body && (
<Box
sx={{
padding: 1,
maxHeight: "300px",
overflowY: "scroll",
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
flexGrow: 1,
}}
>
<ReactMarkdownComponent value={articleData?.[language]?.body} />
</Box>
)}
</Paper>
);
});

View File

@ -0,0 +1,139 @@
import { Paper, Box, Typography } from "@mui/material";
import { MediaViewer, ReactMarkdownComponent } from "@widgets";
import { articlesStore, languageStore } from "@shared";
import { observer } from "mobx-react-lite";
import { ImagePlus } from "lucide-react";
export const PreviewRightWidget = observer(() => {
const { articleData, articleMedia } = articlesStore;
const { language } = languageStore;
const article = articleData?.[language];
if (!article) return null;
return (
<Paper
className="flex-1 flex flex-col max-w-[500px]"
sx={{
borderRadius: "10px",
overflow: "hidden",
}}
elevation={2}
>
<Box
className="overflow-hidden"
sx={{
width: "100%",
background: "#877361",
borderColor: "grey.300",
display: "flex",
flexDirection: "column",
}}
>
{articleMedia ? (
<Box
sx={{
overflow: "hidden",
width: "100%",
padding: "2px 2px 0px 2px",
"& img": {
borderTopLeftRadius: "10px",
borderTopRightRadius: "10px",
width: "100%",
height: "auto",
objectFit: "contain",
},
}}
>
<MediaViewer media={articleMedia} fullWidth />
</Box>
) : (
<Box
sx={{
width: "100%",
height: 200,
flexShrink: 0,
backgroundColor: "rgba(0,0,0,0.1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ImagePlus size={48} color="white" />
</Box>
)}
<Box
sx={{
p: 1,
wordBreak: "break-word",
fontSize: "24px",
fontWeight: 700,
lineHeight: "120%",
backdropFilter: "blur(12px)",
borderBottom: "1px solid #A89F90",
boxShadow: "inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
}}
>
<Typography variant="h6" color="white">
{article.heading || "Выберите статью"}
</Typography>
</Box>
<Box
sx={{
padding: 1,
minHeight: "200px",
maxHeight: "300px",
overflowY: "scroll",
background:
"rgba(179, 165, 152, 0.4), linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 100%)",
flexGrow: 1,
}}
>
{article.body ? (
<ReactMarkdownComponent value={article.body} />
) : (
<Typography
color="rgba(255,255,255,0.7)"
sx={{ textAlign: "center", mt: 4 }}
>
Предпросмотр статьи появится здесь
</Typography>
)}
</Box>
{/* @ts-ignore */}
{articleData?.right && articleData?.right.length > 1 && (
<Box
sx={{
p: 2,
display: "flex",
justifyContent: "space-between",
fontSize: "24px",
fontWeight: 700,
lineHeight: "120%",
flexWrap: "wrap",
gap: 1,
backdropFilter: "blur(12px)",
boxShadow: "inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
}}
>
{/* @ts-ignore */}
{articleData.right.map((a, idx) => (
<button
key={idx}
className="inline-block text-left text-xs text-white"
>
{a.heading}
</button>
))}
</Box>
)}
</Box>
</Paper>
);
});

View File

@ -0,0 +1,57 @@
import { useNavigate, useParams } from "react-router-dom";
import { useEffect } from "react";
import { Box } from "@mui/material";
import { PreviewLeftWidget } from "./PreviewLeftWidget";
import { PreviewRightWidget } from "./PreviewRightWidget";
import { articlesStore, languageStore } from "@shared";
import { ArrowLeft } from "lucide-react";
export const ArticlePreviewPage = () => {
const navigate = useNavigate();
const { id } = useParams();
const { getArticle, getArticleMedia, getArticlePreview } = articlesStore;
const { language } = languageStore;
useEffect(() => {
const fetchData = async () => {
if (id) {
await getArticle(Number(id), language);
await getArticleMedia(Number(id));
await getArticlePreview(Number(id));
}
};
fetchData();
}, [id, language]);
return (
<>
<div className="flex items-center gap-4 mb-10">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<Box
sx={{
display: "flex",
gap: 2,
p: 2,
justifyContent: "center",
margin: "0 auto",
}}
>
<Box sx={{ width: "320px" }}>
<PreviewLeftWidget />
</Box>
<Box sx={{ width: "500px" }}>
<PreviewRightWidget />
</Box>
</Box>
</>
);
};

View File

@ -1 +1,2 @@
export * from "./ArticleListPage"; export * from "./ArticleListPage";
export * from "./ArticlePreviewPage";

View File

@ -14,19 +14,29 @@ import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { carrierStore, cityStore, mediaStore } from "@shared"; import { carrierStore, cityStore, mediaStore } from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { MediaViewer } from "@widgets"; import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import {
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
export const CarrierCreatePage = observer(() => { export const CarrierCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [fullName, setFullName] = useState(""); const [fullName, setFullName] = useState("");
const [shortName, setShortName] = useState(""); const [shortName, setShortName] = useState("");
const [cityId, setCityId] = useState<number | null>(null); 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 [slogan, setSlogan] = useState("");
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null); const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); 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(() => { useEffect(() => {
cityStore.getCities("ru"); cityStore.getCities("ru");
@ -39,11 +49,7 @@ export const CarrierCreatePage = observer(() => {
await carrierStore.createCarrier( await carrierStore.createCarrier(
fullName, fullName,
shortName, shortName,
cityStore.cities.ru.find((c) => c.id === cityId)?.name!,
cityId!, cityId!,
main_color,
left_color,
right_color,
slogan, slogan,
selectedMediaId! 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 ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
className="flex items-center gap-2" className="flex items-center gap-2"
@ -69,6 +89,10 @@ export const CarrierCreatePage = observer(() => {
</div> </div>
<div className="flex flex-col gap-10 w-full items-end"> <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> <FormControl fullWidth>
<InputLabel>Город</InputLabel> <InputLabel>Город</InputLabel>
<Select <Select
@ -77,7 +101,7 @@ export const CarrierCreatePage = observer(() => {
required required
onChange={(e) => setCityId(e.target.value as number)} 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}> <MenuItem key={city.id} value={city.id}>
{city.name} {city.name}
</MenuItem> </MenuItem>
@ -101,57 +125,6 @@ export const CarrierCreatePage = observer(() => {
onChange={(e) => setShortName(e.target.value)} 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 <TextField
fullWidth fullWidth
label="Слоган" label="Слоган"
@ -159,29 +132,28 @@ export const CarrierCreatePage = observer(() => {
onChange={(e) => setSlogan(e.target.value)} onChange={(e) => setSlogan(e.target.value)}
/> />
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<FormControl fullWidth> <ImageUploadCard
<InputLabel>Логотип</InputLabel> title="Логотип перевозчика"
<Select imageKey="thumbnail"
value={selectedMediaId || ""} imageUrl={selectedMedia?.id}
label="Логотип" onImageClick={() => {
required setIsPreviewMediaOpen(true);
onChange={(e) => setSelectedMediaId(e.target.value as string)} setMediaId(selectedMedia?.id ?? "");
> }}
{mediaStore.media onDeleteImageClick={() => {
.filter((media) => media.media_type === 3) setSelectedMediaId(null);
.map((media) => ( setActiveMenuType(null);
<MenuItem key={media.id} value={media.id}> }}
{media.media_name || media.filename} onSelectFileClick={() => {
</MenuItem> setActiveMenuType("thumbnail");
))} setIsSelectMediaOpen(true);
</Select> }}
</FormControl> setUploadMediaOpen={() => {
{selectedMediaId && ( setIsUploadMediaOpen(true);
<div className="w-32 h-32"> setActiveMenuType("thumbnail");
<MediaViewer media={{ id: selectedMediaId, media_type: 1 }} /> }}
</div> />
)}
</div> </div>
<Button <Button
@ -200,6 +172,26 @@ export const CarrierCreatePage = observer(() => {
)} )}
</Button> </Button>
</div> </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> </Paper>
); );
}); });

View File

@ -12,33 +12,64 @@ import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { carrierStore, cityStore, mediaStore } from "@shared"; import { carrierStore, cityStore, mediaStore, languageStore } from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { MediaViewer } from "@widgets"; import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import {
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
export const CarrierEditPage = observer(() => { export const CarrierEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();
const { carrier, getCarrier, setEditCarrierData, editCarrierData } = const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore;
carrierStore; const { language } = languageStore;
const [isLoading, setIsLoading] = useState(false); 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(() => { useEffect(() => {
(async () => { (async () => {
await getCarrier(Number(id)); await cityStore.getCities("ru");
setEditCarrierData( await cityStore.getCities("en");
carrier?.[Number(id)]?.full_name as string, await cityStore.getCities("zh");
carrier?.[Number(id)]?.short_name as string, const carrierData = await getCarrier(Number(id));
carrier?.[Number(id)]?.city as string,
carrier?.[Number(id)]?.city_id as number, if (carrierData) {
carrier?.[Number(id)]?.main_color as string, setEditCarrierData(
carrier?.[Number(id)]?.left_color as string, carrierData.ru?.full_name || "",
carrier?.[Number(id)]?.right_color as string, carrierData.ru?.short_name || "",
carrier?.[Number(id)]?.slogan as string, carrierData.ru?.city_id || 0,
carrier?.[Number(id)]?.logo as string carrierData.ru?.slogan || "",
); carrierData.ru?.logo || "",
cityStore.getCities("ru"); "ru"
);
setEditCarrierData(
carrierData.en?.full_name || "",
carrierData.en?.short_name || "",
carrierData.en?.city_id || 0,
carrierData.en?.slogan || "",
carrierData.en?.logo || "",
"en"
);
setEditCarrierData(
carrierData.zh?.full_name || "",
carrierData.zh?.short_name || "",
carrierData.zh?.city_id || 0,
carrierData.zh?.slogan || "",
carrierData.zh?.logo || "",
"zh"
);
}
mediaStore.getMedia(); mediaStore.getMedia();
})(); })();
}, [id]); }, [id]);
@ -56,8 +87,29 @@ export const CarrierEditPage = observer(() => {
} }
}; };
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
media.id,
language
);
};
const selectedMedia = editCarrierData.logo
? mediaStore.media.find((m) => m.id === editCarrierData.logo)
: null;
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
className="flex items-center gap-2" className="flex items-center gap-2"
@ -68,6 +120,9 @@ export const CarrierEditPage = observer(() => {
</button> </button>
</div> </div>
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words">{editCarrierData.ru.full_name}</h1>
</div>
<div className="flex flex-col gap-10 w-full items-end"> <div className="flex flex-col gap-10 w-full items-end">
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>Город</InputLabel> <InputLabel>Город</InputLabel>
@ -77,19 +132,16 @@ export const CarrierEditPage = observer(() => {
required required
onChange={(e) => onChange={(e) =>
setEditCarrierData( setEditCarrierData(
editCarrierData.full_name, editCarrierData[language].full_name,
editCarrierData.short_name, editCarrierData[language].short_name,
editCarrierData.city,
Number(e.target.value), Number(e.target.value),
editCarrierData.main_color, editCarrierData[language].slogan,
editCarrierData.left_color, editCarrierData.logo,
editCarrierData.right_color, language
editCarrierData.slogan,
editCarrierData.logo
) )
} }
> >
{cityStore.cities.ru.map((city) => ( {cityStore.cities[language].data?.map((city) => (
<MenuItem key={city.id} value={city.id}> <MenuItem key={city.id} value={city.id}>
{city.name} {city.name}
</MenuItem> </MenuItem>
@ -100,19 +152,16 @@ export const CarrierEditPage = observer(() => {
<TextField <TextField
fullWidth fullWidth
label="Полное название" label="Полное название"
value={editCarrierData.full_name} value={editCarrierData[language].full_name}
required required
onChange={(e) => onChange={(e) =>
setEditCarrierData( setEditCarrierData(
e.target.value, e.target.value,
editCarrierData.short_name, editCarrierData[language].short_name,
editCarrierData.city,
editCarrierData.city_id, editCarrierData.city_id,
editCarrierData.main_color, editCarrierData[language].slogan,
editCarrierData.left_color, editCarrierData.logo,
editCarrierData.right_color, language
editCarrierData.slogan,
editCarrierData.logo
) )
} }
/> />
@ -120,166 +169,65 @@ export const CarrierEditPage = observer(() => {
<TextField <TextField
fullWidth fullWidth
label="Короткое название" label="Короткое название"
value={editCarrierData.short_name} value={editCarrierData[language].short_name}
required required
onChange={(e) => onChange={(e) =>
setEditCarrierData( setEditCarrierData(
editCarrierData.full_name, editCarrierData[language].full_name,
e.target.value, e.target.value,
editCarrierData.city,
editCarrierData.city_id, editCarrierData.city_id,
editCarrierData.main_color, editCarrierData[language].slogan,
editCarrierData.left_color, editCarrierData.logo,
editCarrierData.right_color, language
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 <TextField
fullWidth fullWidth
label="Слоган" label="Слоган"
value={editCarrierData.slogan} value={editCarrierData[language].slogan}
onChange={(e) => onChange={(e) =>
setEditCarrierData( setEditCarrierData(
editCarrierData.full_name, editCarrierData[language].full_name,
editCarrierData.short_name, editCarrierData[language].short_name,
editCarrierData.city,
editCarrierData.city_id, editCarrierData.city_id,
editCarrierData.main_color,
editCarrierData.left_color,
editCarrierData.right_color,
e.target.value, e.target.value,
editCarrierData.logo editCarrierData.logo,
language
) )
} }
/> />
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<FormControl fullWidth> <ImageUploadCard
<InputLabel>Логотип</InputLabel> title="Логотип перевозчика"
<Select imageKey="thumbnail"
value={editCarrierData.logo || ""} imageUrl={selectedMedia?.id}
label="Логотип" onImageClick={() => {
required setIsPreviewMediaOpen(true);
onChange={(e) => setMediaId(selectedMedia?.id ?? "");
setEditCarrierData( }}
editCarrierData.full_name, onDeleteImageClick={() => {
editCarrierData.short_name, setEditCarrierData(
editCarrierData.city, editCarrierData[language].full_name,
editCarrierData.city_id, editCarrierData[language].short_name,
editCarrierData.main_color, editCarrierData.city_id,
editCarrierData.left_color, editCarrierData[language].slogan,
editCarrierData.right_color, "",
editCarrierData.slogan, language
e.target.value as string );
) setActiveMenuType(null);
} }}
> onSelectFileClick={() => {
{mediaStore.media setActiveMenuType("thumbnail");
.filter((media) => media.media_type === 3) setIsSelectMediaOpen(true);
.map((media) => ( }}
<MenuItem key={media.id} value={media.id}> setUploadMediaOpen={() => {
{media.media_name || media.filename} setIsUploadMediaOpen(true);
</MenuItem> setActiveMenuType("thumbnail");
))} }}
</Select> />
</FormControl>
{editCarrierData.logo && (
<div className="w-32 h-32">
<MediaViewer
media={{ id: editCarrierData.logo, media_type: 1 }}
/>
</div>
)}
</div> </div>
<Button <Button
@ -289,8 +237,8 @@ export const CarrierEditPage = observer(() => {
onClick={handleEdit} onClick={handleEdit}
disabled={ disabled={
isLoading || isLoading ||
!editCarrierData.full_name || !editCarrierData[language].full_name ||
!editCarrierData.short_name || !editCarrierData[language].short_name ||
!editCarrierData.city_id || !editCarrierData.city_id ||
!editCarrierData.logo !editCarrierData.logo
} }
@ -302,6 +250,26 @@ export const CarrierEditPage = observer(() => {
)} )}
</Button> </Button>
</div> </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> </Paper>
); );
}); });

View File

@ -1,37 +1,74 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { carrierStore } from "@shared"; import { carrierStore, languageStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; 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 { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
export const CarrierListPage = observer(() => { export const CarrierListPage = observer(() => {
const { carriers, getCarriers, deleteCarrier } = carrierStore; const { carriers, getCarriers, deleteCarrier } = carrierStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); 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(() => { useEffect(() => {
getCarriers(); (async () => {
}, []); await getCarriers(language);
})();
}, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
field: "full_name", field: "full_name",
headerName: "Полное имя", headerName: "Полное имя",
width: 300, 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", field: "short_name",
headerName: "Короткое имя", headerName: "Короткое имя",
width: 200, 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", field: "city",
headerName: "Город", headerName: "Город",
flex: 1, 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", field: "actions",
@ -45,9 +82,9 @@ export const CarrierListPage = observer(() => {
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}> <button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
<button onClick={() => navigate(`/carrier/${params.row.id}`)}> {/* <button onClick={() => navigate(`/carrier/${params.row.id}`)}>
<Eye size={20} className="text-green-500" /> <Eye size={20} className="text-green-500" />
</button> </button> */}
<button <button
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@ -62,7 +99,7 @@ export const CarrierListPage = observer(() => {
}, },
]; ];
const rows = carriers.data?.map((carrier) => ({ const rows = carriers[language].data?.map((carrier) => ({
id: carrier.id, id: carrier.id,
full_name: carrier.full_name, full_name: carrier.full_name,
short_name: carrier.short_name, short_name: carrier.short_name,
@ -71,15 +108,34 @@ export const CarrierListPage = observer(() => {
return ( return (
<> <>
<LanguageSwitcher />
<div className="w-full"> <div className="w-full">
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Перевозчики</h1> <h1 className="text-2xl">Перевозчики</h1>
<CreateButton label="Создать перевозчика" path="/carrier/create" /> <CreateButton label="Создать перевозчика" path="/carrier/create" />
</div> </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 <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter hideFooter
/> />
</div> </div>
@ -98,6 +154,19 @@ export const CarrierListPage = observer(() => {
setRowId(null); setRowId(null);
}} }}
/> />
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteCarrier(id)));
await getCarriers(language);
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</> </>
); );
}); });

View File

@ -1,114 +0,0 @@
import { Paper } from "@mui/material";
import { carrierStore, mediaStore } from "@shared";
import { MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
export const CarrierPreviewPage = observer(() => {
const { id } = useParams();
const { getCarrier, carrier, setEditCarrierData } = carrierStore;
const { oneMedia, getOneMedia } = mediaStore;
const navigate = useNavigate();
useEffect(() => {
(async () => {
const carrierResponse = await getCarrier(Number(id));
setEditCarrierData(
carrierResponse?.full_name as string,
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?.slogan as string,
carrierResponse?.logo as string
);
console.log(carrierResponse);
await getOneMedia(carrierResponse?.logo as string);
})();
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
{carrier && (
<>
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Полное имя</h1>
<p>{carrier[Number(id)]?.full_name}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Полное имя</h1>
<p>{carrier[Number(id)]?.full_name}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Город</h1>
<p>{carrier[Number(id)]?.city}</p>
</div>
<div className="flex flex-col gap-2 ">
<h1 className="text-lg font-bold">Основной цвет</h1>
<div
className="w-min"
style={{
backgroundColor: `${carrier[Number(id)]?.main_color}90`,
}}
>
{carrier[Number(id)]?.main_color}
</div>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Цвет левого виджета</h1>
<div
className="w-min"
style={{
backgroundColor: `${carrier[Number(id)]?.left_color}90`,
}}
>
{carrier[Number(id)]?.left_color}
</div>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Цвет правого виджета</h1>
<div
className="w-min"
style={{
backgroundColor: `${carrier[Number(id)]?.right_color}90`,
}}
>
{carrier[Number(id)]?.right_color}
</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>
<MediaViewer
media={{
id: oneMedia?.id as string,
media_type: oneMedia?.media_type as number,
filename: oneMedia?.filename,
}}
/>
</div>
</div>
</>
)}
</Paper>
);
});

View File

@ -1,4 +1,4 @@
export * from "./CarrierListPage"; export * from "./CarrierListPage";
export * from "./CarrierPreviewPage";
export * from "./CarrierCreatePage"; export * from "./CarrierCreatePage";
export * from "./CarrierEditPage"; export * from "./CarrierEditPage";

View File

@ -6,17 +6,20 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save, ImagePlus } from "lucide-react"; import { ArrowLeft, Save, Minus } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { cityStore, countryStore, languageStore, mediaStore } from "@shared"; import { cityStore, countryStore, languageStore, mediaStore } from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { LanguageSwitcher, MediaViewer } from "@widgets"; import { LanguageSwitcher, ImageUploadCard } from "@widgets";
import { SelectMediaDialog } from "@shared"; import {
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
export const CityCreatePage = observer(() => { export const CityCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -24,12 +27,20 @@ export const CityCreatePage = observer(() => {
const { createCityData, setCreateCityData } = cityStore; const { createCityData, setCreateCityData } = cityStore;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = 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 { getCountries } = countryStore;
const { getMedia } = mediaStore; const { getMedia } = mediaStore;
useEffect(() => { useEffect(() => {
(async () => { (async () => {
await getCountries(language); await getCountries("ru");
await getCountries("en");
await getCountries("zh");
await getMedia(); await getMedia();
})(); })();
}, [language]); }, [language]);
@ -55,7 +66,6 @@ export const CityCreatePage = observer(() => {
}) => { }) => {
setCreateCityData( setCreateCityData(
createCityData[language].name, createCityData[language].name,
createCityData.country,
createCityData.country_code, createCityData.country_code,
media.id, media.id,
language language
@ -80,6 +90,9 @@ export const CityCreatePage = observer(() => {
</div> </div>
<div className="flex flex-col gap-10 w-full items-end"> <div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words">{createCityData.ru.name}</h1>
</div>
<TextField <TextField
fullWidth fullWidth
label="Название города" label="Название города"
@ -88,7 +101,6 @@ export const CityCreatePage = observer(() => {
onChange={(e) => onChange={(e) =>
setCreateCityData( setCreateCityData(
e.target.value, e.target.value,
createCityData.country,
createCityData.country_code, createCityData.country_code,
createCityData.arms, createCityData.arms,
language language
@ -103,19 +115,15 @@ export const CityCreatePage = observer(() => {
label="Страна" label="Страна"
required required
onChange={(e) => { onChange={(e) => {
const selectedCountry = countryStore.countries[language]?.find(
(country) => country.code === e.target.value
);
setCreateCityData( setCreateCityData(
createCityData[language].name, createCityData[language].name,
selectedCountry?.name || "",
e.target.value, e.target.value,
createCityData.arms, createCityData.arms,
language language
); );
}} }}
> >
{countryStore.countries[language].map((country) => ( {countryStore.countries["ru"]?.data?.map((country) => (
<MenuItem key={country.code} value={country.code}> <MenuItem key={country.code} value={country.code}>
{country.name} {country.name}
</MenuItem> </MenuItem>
@ -123,44 +131,39 @@ export const CityCreatePage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<label className="text-sm text-gray-600">Герб города</label> {!selectedMedia && (
<div className="flex items-center gap-4"> <div className="flex items-center gap-2 text-red-500">
<Button <Minus size={20} />
variant="outlined" <span className="text-sm">Герб города не выбран</span>
onClick={() => setIsSelectMediaOpen(true)} </div>
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>
)} )}
<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> </div>
<Button <Button
@ -184,6 +187,19 @@ export const CityCreatePage = observer(() => {
onSelectMedia={handleMediaSelect} onSelectMedia={handleMediaSelect}
mediaType={3} // Тип медиа для иконок mediaType={3} // Тип медиа для иконок
/> />
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
</Paper> </Paper>
); );
}); });

View File

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

View File

@ -1,10 +1,11 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, cityStore, CashedCities } from "@shared"; import { languageStore, cityStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; 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 { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { toast } from "react-toastify";
export const CityListPage = observer(() => { export const CityListPage = observer(() => {
const { cities, getCities, deleteCity } = cityStore; const { cities, getCities, deleteCity } = cityStore;
@ -22,11 +23,33 @@ export const CityListPage = observer(() => {
field: "country", field: "country",
headerName: "Страна", headerName: "Страна",
width: 150, 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", field: "name",
headerName: "Название", headerName: "Название",
flex: 1, 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", 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, id: city.id,
name: city.name, name: city.name,
country: city.country, country: city.country,
@ -85,7 +108,8 @@ export const CityListPage = observer(() => {
open={isDeleteModalOpen} open={isDeleteModalOpen}
onDelete={async () => { onDelete={async () => {
if (rowId) { if (rowId) {
deleteCity(rowId.toString(), language as keyof CashedCities); await deleteCity(rowId.toString());
toast.success("Город успешно удален");
} }
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
setRowId(null); setRowId(null);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,12 +3,7 @@ import { InformationTab, LeaveAgree, RightWidgetTab } from "@widgets";
import { LeftWidgetTab } from "@widgets"; import { LeftWidgetTab } from "@widgets";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { import { articlesStore, cityStore, editSightStore } from "@shared";
articlesStore,
cityStore,
editSightStore,
languageStore,
} from "@shared";
import { useBlocker, useParams } from "react-router-dom"; import { useBlocker, useParams } from "react-router-dom";
function a11yProps(index: number) { function a11yProps(index: number) {
@ -22,7 +17,7 @@ export const EditSightPage = observer(() => {
const [value, setValue] = useState(0); const [value, setValue] = useState(0);
const { sight, getSightInfo, needLeaveAgree } = editSightStore; const { sight, getSightInfo, needLeaveAgree } = editSightStore;
const { getArticles } = articlesStore; const { getArticles } = articlesStore;
const { language } = languageStore;
const { id } = useParams(); const { id } = useParams();
const { getRuCities } = cityStore; const { getRuCities } = cityStore;
@ -38,13 +33,17 @@ export const EditSightPage = observer(() => {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (id) { if (id) {
await getSightInfo(+id, language); await getSightInfo(+id, "ru");
await getArticles(language); await getSightInfo(+id, "en");
await getSightInfo(+id, "zh");
await getArticles("ru");
await getArticles("en");
await getArticles("zh");
await getRuCities(); await getRuCities();
} }
}; };
fetchData(); fetchData();
}, [id, language]); }, [id]);
return ( return (
<Box <Box

File diff suppressed because it is too large Load Diff

View File

@ -54,20 +54,22 @@ class MapStore {
sights: ApiSight[] = []; sights: ApiSight[] = [];
getRoutes = async () => { getRoutes = async () => {
const routes = await languageInstance("ru").get("/route"); const response = await languageInstance("ru").get("/route");
const routedIds = routes.data.map((route: any) => route.id); console.log(response.data);
const mappedRoutes: ApiRoute[] = []; const routesIds = response.data.map((route: any) => route.id);
for (const routeId of routedIds) { for (const id of routesIds) {
const responseSoloRoute = await languageInstance("ru").get( const route = await languageInstance("ru").get(`/route/${id}`);
`/route/${routeId}` this.routes.push({
); id: route.data.id,
const route = responseSoloRoute.data; route_number: route.data.route_number,
mappedRoutes.push({ path: route.data.path,
id: route.id,
route_number: route.route_number,
path: route.path,
}); });
} }
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) => this.routes = mappedRoutes.sort((a, b) =>
a.route_number.localeCompare(b.route_number) a.route_number.localeCompare(b.route_number)
); );

View File

@ -2,7 +2,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared"; import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; 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 { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets"; import { CreateButton, DeleteModal } from "@widgets";
@ -10,7 +10,9 @@ export const MediaListPage = observer(() => {
const { media, getMedia, deleteMedia } = mediaStore; const { media, getMedia, deleteMedia } = mediaStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); 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; const { language } = languageStore;
useEffect(() => { useEffect(() => {
@ -22,6 +24,17 @@ export const MediaListPage = observer(() => {
field: "media_name", field: "media_name",
headerName: "Название", headerName: "Название",
flex: 1, 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", field: "media_type",
@ -30,13 +43,15 @@ export const MediaListPage = observer(() => {
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( return (
<p> <div className="w-full h-full flex items-center">
{ {params.value ? (
MEDIA_TYPE_LABELS[ MEDIA_TYPE_LABELS[
params.row.media_type as keyof typeof 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> <h1 className="text-2xl">Медиа</h1>
<CreateButton label="Создать медиа" path="/media/create" /> <CreateButton label="Создать медиа" path="/media/create" />
</div> </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 <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as string[]);
}}
hideFooter hideFooter
/> />
</div> </div>
@ -103,6 +136,19 @@ export const MediaListPage = observer(() => {
setRowId(null); setRowId(null);
}} }}
/> />
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteMedia(id)));
getMedia();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</> </>
); );
}); });

View File

@ -6,10 +6,10 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Typography, // Typography,
Box, Box,
} from "@mui/material"; } from "@mui/material";
import { LanguageSwitcher } from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -18,6 +18,7 @@ import { toast } from "react-toastify";
import { carrierStore } from "../../../shared/store/CarrierStore"; import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore"; import { articlesStore } from "../../../shared/store/ArticlesStore";
import { Route, routeStore } from "../../../shared/store/RouteStore"; import { Route, routeStore } from "../../../shared/store/RouteStore";
import { languageStore } from "@shared";
export const RouteCreatePage = observer(() => { export const RouteCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -33,11 +34,11 @@ export const RouteCreatePage = observer(() => {
const [centerLat, setCenterLat] = useState(""); const [centerLat, setCenterLat] = useState("");
const [centerLng, setCenterLng] = useState(""); const [centerLng, setCenterLng] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => { useEffect(() => {
carrierStore.getCarriers(); carrierStore.getCarriers(language);
articlesStore.getArticleList(); articlesStore.getArticleList();
}, []); }, [language]);
const handleCreateRoute = async () => { const handleCreateRoute = async () => {
try { try {
@ -65,8 +66,9 @@ export const RouteCreatePage = observer(() => {
// Собираем объект маршрута // Собираем объект маршрута
const newRoute: Partial<Route> = { const newRoute: Partial<Route> = {
carrier: carrier:
carrierStore.carriers.data.find((c: any) => c.id === carrier_id) carrierStore.carriers[
?.full_name || "", language as keyof typeof carrierStore.carriers
].data?.find((c: any) => c.id === carrier_id)?.full_name || "",
carrier_id, carrier_id,
route_number: routeNumber, route_number: routeNumber,
route_sys_number: govRouteNumber, route_sys_number: govRouteNumber,
@ -93,134 +95,139 @@ export const RouteCreatePage = observer(() => {
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <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 <button
className="flex items-center gap-2" className="flex items-center gap-2"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
<ArrowLeft size={20} /> <ArrowLeft size={20} />
Маршруты / Создать Назад
</button> </button>
</div> </div>
<Typography variant="h5" fontWeight={700}>
Создать маршрут <div className="flex flex-col gap-10 w-full items-end">
</Typography> <Box className="flex flex-col gap-6 w-full">
<Box className="flex flex-col gap-6 w-full"> <FormControl fullWidth required>
<FormControl fullWidth required> <InputLabel>Выберите перевозчика</InputLabel>
<InputLabel>Выберите перевозчика</InputLabel> <Select
<Select value={carrier}
value={carrier} label="Выберите перевозчика"
label="Выберите перевозчика" onChange={(e) => setCarrier(e.target.value as string)}
onChange={(e) => setCarrier(e.target.value as string)} disabled={
disabled={carrierStore.carriers.data.length === 0} carrierStore.carriers[
> language as keyof typeof carrierStore.carriers
<MenuItem value="">Не выбрано</MenuItem> ].data?.length === 0
{carrierStore.carriers.data.map( }
(c: (typeof carrierStore.carriers.data)[number]) => ( >
<MenuItem key={c.id} value={c.id}> <MenuItem value="">Не выбрано</MenuItem>
{c.full_name} {carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.map((carrier) => (
<MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name}
</MenuItem> </MenuItem>
) ))}
)} </Select>
</Select> </FormControl>
</FormControl> <TextField
<TextField className="w-full"
className="w-full" label="Номер маршрута"
label="Номер маршрута" required
required value={routeNumber}
value={routeNumber} onChange={(e) => setRouteNumber(e.target.value)}
onChange={(e) => setRouteNumber(e.target.value)} />
/> <TextField
<TextField className="w-full"
className="w-full" label="Координаты маршрута"
label="Координаты маршрута" multiline
multiline minRows={3}
minRows={3} value={routeCoords}
value={routeCoords} onChange={(e) => setRouteCoords(e.target.value)}
onChange={(e) => setRouteCoords(e.target.value)} />
/> <TextField
<TextField className="w-full"
className="w-full" label="Номер маршрута в Говорящем Городе"
label="Номер маршрута в Говорящем Городе" required
required value={govRouteNumber}
value={govRouteNumber} onChange={(e) => setGovRouteNumber(e.target.value)}
onChange={(e) => setGovRouteNumber(e.target.value)} />
/> <FormControl fullWidth required>
<FormControl fullWidth required> <InputLabel>Обращение губернатора</InputLabel>
<InputLabel>Обращение губернатора</InputLabel> <Select
<Select value={governorAppeal}
value={governorAppeal} label="Обращение губернатора"
label="Обращение губернатора" onChange={(e) => setGovernorAppeal(e.target.value as string)}
onChange={(e) => setGovernorAppeal(e.target.value as string)} disabled={articlesStore.articleList.ru.data.length === 0}
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> {isLoading ? (
{articlesStore.articleList.ru.data.map( <Loader2 size={20} className="animate-spin" />
(a: (typeof articlesStore.articleList.ru.data)[number]) => ( ) : (
<MenuItem key={a.id} value={a.id}> "Сохранить"
{a.heading}
</MenuItem>
)
)} )}
</Select> </Button>
</FormControl> </div>
<FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel>
<Select
value={direction}
label="Прямой/обратный маршрут"
onChange={(e) => setDirection(e.target.value)}
>
<MenuItem value="forward">Прямой</MenuItem>
<MenuItem value="backward">Обратный</MenuItem>
</Select>
</FormControl>
<TextField
className="w-full"
label="Масштаб (мин)"
value={scaleMin}
onChange={(e) => setScaleMin(e.target.value)}
/>
<TextField
className="w-full"
label="Масштаб (макс)"
value={scaleMax}
onChange={(e) => setScaleMax(e.target.value)}
/>
<TextField
className="w-full"
label="Поворот"
value={turn}
onChange={(e) => setTurn(e.target.value)}
/>
<TextField
className="w-full"
label="Центр. широта"
value={centerLat}
onChange={(e) => setCenterLat(e.target.value)}
/>
<TextField
className="w-full"
label="Центр. долгота"
value={centerLng}
onChange={(e) => setCenterLng(e.target.value)}
/>
</Box>
<div className="flex w-full justify-end">
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreateRoute}
disabled={isLoading}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
</div> </div>
</Paper> </Paper>
); );

View File

@ -6,10 +6,10 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Typography, // Typography,
Box, Box,
} from "@mui/material"; } from "@mui/material";
import { LanguageSwitcher } from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -19,22 +19,23 @@ import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore"; import { articlesStore } from "../../../shared/store/ArticlesStore";
import { routeStore } from "../../../shared/store/RouteStore"; import { routeStore } from "../../../shared/store/RouteStore";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { languageStore } from "@shared";
export const RouteEditPage = observer(() => { export const RouteEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();
const { editRouteData } = routeStore; const { editRouteData } = routeStore;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
const response = await routeStore.getRoute(Number(id)); const response = await routeStore.getRoute(Number(id));
routeStore.setEditRouteData(response); routeStore.setEditRouteData(response);
carrierStore.getCarriers(); carrierStore.getCarriers(language);
articlesStore.getArticleList(); articlesStore.getArticleList();
}; };
fetchData(); fetchData();
}, [id]); }, [id, language]);
const handleSave = async () => { const handleSave = async () => {
setIsLoading(true); setIsLoading(true);
@ -45,180 +46,186 @@ export const RouteEditPage = observer(() => {
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <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 <button
className="flex items-center gap-2" className="flex items-center gap-2"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
<ArrowLeft size={20} /> <ArrowLeft size={20} />
Маршруты / Редактировать Назад
</button> </button>
</div> </div>
<Typography variant="h5" fontWeight={700}>
Редактировать маршрут <div className="flex flex-col gap-10 w-full items-end">
</Typography> <Box className="flex flex-col gap-6 w-full">
<Box className="flex flex-col gap-6 w-full"> <FormControl fullWidth required>
<FormControl fullWidth required> <InputLabel>Выберите перевозчика</InputLabel>
<InputLabel>Выберите перевозчика</InputLabel> <Select
<Select value={editRouteData.carrier_id}
value={editRouteData.carrier_id} label="Выберите перевозчика"
label="Выберите перевозчика" onChange={(e) =>
onChange={(e) => routeStore.setEditRouteData({
routeStore.setEditRouteData({ carrier_id: Number(e.target.value),
carrier_id: Number(e.target.value), carrier:
carrier: carrierStore.carriers[
carrierStore.carriers.data.find( language as keyof typeof carrierStore.carriers
(c) => c.id === Number(e.target.value) ].data?.find((c) => c.id === Number(e.target.value))
)?.full_name || "", ?.full_name || "",
}) })
} }
disabled={carrierStore.carriers.data.length === 0} disabled={
> carrierStore.carriers[
<MenuItem value="">Не выбрано</MenuItem> language as keyof typeof carrierStore.carriers
{carrierStore.carriers.data.map( ].data?.length === 0
(c: (typeof carrierStore.carriers.data)[number]) => ( }
<MenuItem key={c.id} value={c.id}> >
{c.full_name} <MenuItem value="">Не выбрано</MenuItem>
{carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.map((carrier) => (
<MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name}
</MenuItem> </MenuItem>
) ))}
)} </Select>
</Select> </FormControl>
</FormControl> <TextField
<TextField className="w-full"
className="w-full" label="Номер маршрута"
label="Номер маршрута" required
required value={editRouteData.route_number || ""}
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="Обращение губернатора"
onChange={(e) => onChange={(e) =>
routeStore.setEditRouteData({ routeStore.setEditRouteData({
governor_appeal: Number(e.target.value), route_number: e.target.value,
}) })
} }
disabled={articlesStore.articleList.ru.data.length === 0} />
> <TextField
<MenuItem value="">Не выбрано</MenuItem> className="w-full"
{articlesStore.articleList.ru.data.map( label="Координаты маршрута"
(a: (typeof articlesStore.articleList.ru.data)[number]) => ( multiline
<MenuItem key={a.id} value={a.id}> minRows={3}
{a.heading} value={editRouteData.path.map((p) => p.join(" ")).join("\n") || ""}
</MenuItem>
)
)}
</Select>
</FormControl>
<FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel>
<Select
value={editRouteData.route_direction ? "forward" : "backward"}
label="Прямой/обратный маршрут"
onChange={(e) => onChange={(e) =>
routeStore.setEditRouteData({ routeStore.setEditRouteData({
route_direction: e.target.value === "forward", 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="Обращение губернатора"
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> </Button>
</Select> </div>
</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>
</div> </div>
</Paper> </Paper>
); );

View File

@ -2,15 +2,18 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, routeStore } from "@shared"; import { languageStore, routeStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; 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 { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets"; import { CreateButton, DeleteModal } from "@widgets";
import { LanguageSwitcher } from "@widgets";
export const RouteListPage = observer(() => { export const RouteListPage = observer(() => {
const { routes, getRoutes, deleteRoute } = routeStore; const { routes, getRoutes, deleteRoute } = routeStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); 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; const { language } = languageStore;
useEffect(() => { useEffect(() => {
@ -22,11 +25,33 @@ export const RouteListPage = observer(() => {
field: "carrier", field: "carrier",
headerName: "Перевозчик", headerName: "Перевозчик",
width: 250, 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", field: "route_number",
headerName: "Номер маршрута", headerName: "Номер маршрута",
flex: 1, 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", field: "route_direction",
@ -87,15 +112,35 @@ export const RouteListPage = observer(() => {
return ( return (
<> <>
<LanguageSwitcher />
<div style={{ width: "100%" }}> <div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Маршруты</h1> <h1 className="text-2xl">Маршруты</h1>
<CreateButton label="Создать маршрут" path="/route/create" /> <CreateButton label="Создать маршрут" path="/route/create" />
</div> </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 <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter hideFooter
/> />
</div> </div>
@ -114,6 +159,19 @@ export const RouteListPage = observer(() => {
setRowId(null); setRowId(null);
}} }}
/> />
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteRoute(id)));
getRoutes();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</> </>
); );
}); });

View File

@ -2,7 +2,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, sightsStore } from "@shared"; import { languageStore, sightsStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; 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 { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
@ -10,7 +10,9 @@ export const SightListPage = observer(() => {
const { sights, getSights, deleteListSight } = sightsStore; const { sights, getSights, deleteListSight } = sightsStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); 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; const { language } = languageStore;
useEffect(() => { useEffect(() => {
@ -22,13 +24,34 @@ export const SightListPage = observer(() => {
field: "name", field: "name",
headerName: "Имя", headerName: "Имя",
flex: 1, 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", field: "city",
headerName: "Город", headerName: "Город",
flex: 1, 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", field: "actions",
headerName: "Действия", headerName: "Действия",
@ -76,10 +99,28 @@ export const SightListPage = observer(() => {
path="/sight/create" path="/sight/create"
/> />
</div> </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 <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter hideFooter
/> />
</div> </div>
@ -98,6 +139,19 @@ export const SightListPage = observer(() => {
setRowId(null); setRowId(null);
}} }}
/> />
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteListSight(id)));
getSights();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</> </>
); );
}); });

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, stationsStore } from "@shared"; import { languageStore, stationsStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; 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 { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
@ -10,7 +10,9 @@ export const StationListPage = observer(() => {
const { stationLists, getStationList, deleteStation } = stationsStore; const { stationLists, getStationList, deleteStation } = stationsStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); 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; const { language } = languageStore;
useEffect(() => { useEffect(() => {
@ -22,11 +24,33 @@ export const StationListPage = observer(() => {
field: "name", field: "name",
headerName: "Название", headerName: "Название",
flex: 1, 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", field: "system_name",
headerName: "Системное название", headerName: "Системное название",
flex: 1, 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", field: "direction",
@ -88,15 +112,33 @@ export const StationListPage = observer(() => {
<> <>
<LanguageSwitcher /> <LanguageSwitcher />
<div style={{ width: "100%" }}> <div className="w-full">
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Станции</h1> <h1 className="text-2xl">Станции</h1>
<CreateButton label="Создать станцию" path="/station/create" /> <CreateButton label="Создать станцию" path="/station/create" />
</div> </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 <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter hideFooter
/> />
</div> </div>
@ -115,6 +157,19 @@ export const StationListPage = observer(() => {
setRowId(null); setRowId(null);
}} }}
/> />
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteStation(id)));
getStationList();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</> </>
); );
}); });

View File

@ -2,7 +2,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { userStore } from "@shared"; import { userStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; 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 { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets"; import { CreateButton, DeleteModal } from "@widgets";
@ -11,7 +11,9 @@ export const UserListPage = observer(() => {
const { users, getUsers, deleteUser } = userStore; const { users, getUsers, deleteUser } = userStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); 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(() => { useEffect(() => {
getUsers(); getUsers();
@ -22,11 +24,33 @@ export const UserListPage = observer(() => {
field: "name", field: "name",
headerName: "Имя", headerName: "Имя",
width: 400, 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", field: "email",
headerName: "Email", headerName: "Email",
width: 400, 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", field: "is_admin",
@ -93,10 +117,28 @@ export const UserListPage = observer(() => {
<h1 className="text-2xl">Пользователи</h1> <h1 className="text-2xl">Пользователи</h1>
<CreateButton label="Создать пользователя" path="/user/create" /> <CreateButton label="Создать пользователя" path="/user/create" />
</div> </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 <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter hideFooter
/> />
</div> </div>
@ -107,7 +149,6 @@ export const UserListPage = observer(() => {
if (rowId) { if (rowId) {
await deleteUser(rowId); await deleteUser(rowId);
} }
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
setRowId(null); setRowId(null);
}} }}
@ -116,6 +157,19 @@ export const UserListPage = observer(() => {
setRowId(null); setRowId(null);
}} }}
/> />
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteUser(id)));
getUsers();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</> </>
); );
}); });

View File

@ -7,7 +7,12 @@ import {
FormControl, FormControl,
InputLabel, InputLabel,
} from "@mui/material"; } from "@mui/material";
import { vehicleStore, VEHICLE_TYPES, carrierStore } from "@shared"; import {
vehicleStore,
VEHICLE_TYPES,
carrierStore,
languageStore,
} from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
@ -21,10 +26,11 @@ export const VehicleCreatePage = observer(() => {
const [type, setType] = useState(""); const [type, setType] = useState("");
const [carrierId, setCarrierId] = useState<number | null>(null); const [carrierId, setCarrierId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => { useEffect(() => {
carrierStore.getCarriers(); carrierStore.getCarriers(language);
}, []); }, [language]);
const handleCreate = async () => { const handleCreate = async () => {
try { try {
@ -32,7 +38,8 @@ export const VehicleCreatePage = observer(() => {
await vehicleStore.createVehicle( await vehicleStore.createVehicle(
Number(tailNumber), Number(tailNumber),
Number(type), Number(type),
carrierStore.carriers.data.find((c) => c.id === carrierId)?.full_name!, carrierStore.carriers[language].data?.find((c) => c.id === carrierId)
?.full_name as string,
carrierId! carrierId!
); );
toast.success("Транспорт успешно создан"); toast.success("Транспорт успешно создан");
@ -88,7 +95,7 @@ export const VehicleCreatePage = observer(() => {
required required
onChange={(e) => setCarrierId(e.target.value as number)} onChange={(e) => setCarrierId(e.target.value as number)}
> >
{carrierStore.carriers.data.map((carrier) => ( {carrierStore.carriers[language].data?.map((carrier) => (
<MenuItem key={carrier.id} value={carrier.id}> <MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name} {carrier.full_name}
</MenuItem> </MenuItem>

View File

@ -11,7 +11,12 @@ import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { carrierStore, VEHICLE_TYPES, vehicleStore } from "@shared"; import {
carrierStore,
languageStore,
VEHICLE_TYPES,
vehicleStore,
} from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
export const VehicleEditPage = observer(() => { export const VehicleEditPage = observer(() => {
@ -25,11 +30,12 @@ export const VehicleEditPage = observer(() => {
editVehicle, editVehicle,
} = vehicleStore; } = vehicleStore;
const { getCarriers } = carrierStore; const { getCarriers } = carrierStore;
const { language } = languageStore;
useEffect(() => { useEffect(() => {
(async () => { (async () => {
await getVehicle(Number(id)); await getVehicle(Number(id));
await getCarriers(); await getCarriers(language);
setEditVehicleData({ setEditVehicleData({
tail_number: vehicle[Number(id)]?.vehicle.tail_number, tail_number: vehicle[Number(id)]?.vehicle.tail_number,
type: vehicle[Number(id)]?.vehicle.type, type: vehicle[Number(id)]?.vehicle.type,
@ -37,7 +43,7 @@ export const VehicleEditPage = observer(() => {
carrier_id: vehicle[Number(id)]?.vehicle.carrier_id, carrier_id: vehicle[Number(id)]?.vehicle.carrier_id,
}); });
})(); })();
}, [id]); }, [id, language]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const handleEdit = async () => { const handleEdit = async () => {
try { try {
@ -108,7 +114,7 @@ export const VehicleEditPage = observer(() => {
}) })
} }
> >
{carrierStore.carriers.data.map((carrier) => ( {carrierStore.carriers[language].data?.map((carrier) => (
<MenuItem key={carrier.id} value={carrier.id}> <MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name} {carrier.full_name}
</MenuItem> </MenuItem>

View File

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

View 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>
);
};

View File

@ -7,22 +7,24 @@ import {
Users, Users,
Earth, Earth,
Landmark, Landmark,
BusFront,
GitBranch, GitBranch,
Car, // Car,
Table, Table,
Notebook,
Split, Split,
Newspaper, Newspaper,
PersonStanding, PersonStanding,
Cpu, Cpu,
BookImage, BookImage,
} from "lucide-react"; } from "lucide-react";
import { CarrierSvg } from "./CarrierSvg";
export const DRAWER_WIDTH = 300; export const DRAWER_WIDTH = 300;
interface NavigationItem { interface NavigationItem {
id: string; id: string;
label: string; label: string;
icon: LucideIcon; icon?: LucideIcon | React.ReactNode;
path?: string; path?: string;
onClick?: () => void; onClick?: () => void;
nestedItems?: NavigationItem[]; nestedItems?: NavigationItem[];
@ -34,43 +36,6 @@ export const NAVIGATION_ITEMS: {
secondary: NavigationItem[]; secondary: NavigationItem[];
} = { } = {
primary: [ 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", id: "all",
label: "Все сущности", label: "Все сущности",
@ -106,15 +71,58 @@ export const NAVIGATION_ITEMS: {
icon: Split, icon: Split,
path: "/route", 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", id: "snapshots",
label: "Транспорт", label: "Снапшоты",
icon: Car, icon: GitBranch,
path: "/vehicle", 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", id: "users",
label: "Пользователи", label: "Пользователи",

View File

@ -120,7 +120,6 @@ export const PreviewMediaDialog = observer(
disabled={isLoading} disabled={isLoading}
/> />
</Box> </Box>
<TextField <TextField
fullWidth fullWidth
label="Тип медиа" label="Тип медиа"
@ -133,7 +132,7 @@ export const PreviewMediaDialog = observer(
sx={{ width: "50%" }} sx={{ width: "50%" }}
/> />
<Box className="flex gap-4 h-full"> <Box className="flex gap-4">
<Paper <Paper
elevation={2} elevation={2}
sx={{ sx={{
@ -142,8 +141,8 @@ export const PreviewMediaDialog = observer(
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
minHeight: 400,
}} }}
className="max-h-[40vh]"
> >
<MediaViewer <MediaViewer
media={{ media={{
@ -151,6 +150,7 @@ export const PreviewMediaDialog = observer(
media_type: media.media_type, media_type: media.media_type,
filename: media.filename, filename: media.filename,
}} }}
fullHeight
/> />
</Paper> </Paper>

View File

@ -188,7 +188,7 @@ export const UploadMediaDialog = observer(
</Select> </Select>
</FormControl> </FormControl>
<Box className="flex gap-4 h-full"> <Box className="flex gap-4 h-[40vh]">
<Paper <Paper
elevation={2} elevation={2}
sx={{ sx={{
@ -197,7 +197,7 @@ export const UploadMediaDialog = observer(
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
minHeight: 400, height: "100%",
}} }}
> >
{/* <MediaViewer {/* <MediaViewer

View File

@ -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"; import { computed, makeAutoObservable, runInAction } from "mobx";
export type Article = { export type Article = {
@ -6,6 +12,18 @@ export type Article = {
heading: string; heading: string;
body: string; body: string;
service_name: string; service_name: string;
ru?: {
heading: string;
body: string;
};
en?: {
heading: string;
body: string;
};
zh?: {
heading: string;
body: string;
};
}; };
type Media = { type Media = {
@ -99,13 +117,25 @@ class ArticlesStore {
this.articleLoading = false; this.articleLoading = false;
}; };
getArticle = async (id: number) => { getArticle = async (id: number, language?: Language) => {
this.articleLoading = true; this.articleLoading = true;
const response = await authInstance.get(`/article/${id}`); if (language) {
const response = await languageInstance(language).get(`/article/${id}`);
runInAction(() => { runInAction(() => {
this.articleData = response.data; 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; this.articleLoading = false;
}; };
@ -137,6 +167,20 @@ class ArticlesStore {
} }
return null; 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(); export const articlesStore = new ArticlesStore();

View File

@ -1,4 +1,10 @@
import { authInstance } from "@shared"; import {
authInstance,
cityStore,
languageStore,
languageInstance,
Language,
} from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
export type Carrier = { export type Carrier = {
@ -9,22 +15,45 @@ export type Carrier = {
city: string; city: string;
city_id: number; city_id: number;
logo: string; logo: string;
main_color: string; // main_color: string;
left_color: string; // left_color: string;
right_color: string; // right_color: string;
}; };
type Carriers = { type CarrierData = {
data: Carrier[]; data: Carrier[];
loaded: boolean; loaded: boolean;
}; };
type CashedCarrier = Record<number, Carrier>; type Carriers = {
ru: CarrierData;
en: CarrierData;
zh: CarrierData;
};
type CashedCarrier = Record<
number,
{
ru: Carrier | null;
en: Carrier | null;
zh: Carrier | null;
}
>;
class CarrierStore { class CarrierStore {
carriers: Carriers = { carriers: Carriers = {
data: [], ru: {
loaded: false, data: [],
loaded: false,
},
en: {
data: [],
loaded: false,
},
zh: {
data: [],
loaded: false,
},
}; };
carrier: CashedCarrier = {}; carrier: CashedCarrier = {};
@ -32,14 +61,14 @@ class CarrierStore {
makeAutoObservable(this); makeAutoObservable(this);
} }
getCarriers = async () => { getCarriers = async (language: Language) => {
if (this.carriers.loaded) return; if (this.carriers[language as keyof Carriers].loaded) return;
const response = await authInstance.get("/carrier"); const response = await authInstance.get("/carrier");
runInAction(() => { runInAction(() => {
this.carriers.data = response.data; this.carriers[language as keyof Carriers].data = response.data;
this.carriers.loaded = true; this.carriers[language as keyof Carriers].loaded = true;
}); });
}; };
@ -47,113 +76,163 @@ class CarrierStore {
await authInstance.delete(`/carrier/${id}`); await authInstance.delete(`/carrier/${id}`);
runInAction(() => { runInAction(() => {
this.carriers.data = this.carriers.data.filter( for (const language of ["ru", "en", "zh"] as const) {
(carrier) => carrier.id !== id this.carriers[language].data = this.carriers[language].data.filter(
); (carrier: Carrier) => carrier.id !== id
);
}
delete this.carrier[id]; delete this.carrier[id];
}); });
}; };
getCarrier = async (id: number) => { getCarrier = async (id: number) => {
if (this.carrier[id]) return; if (this.carrier[id]?.ru && this.carrier[id]?.en && this.carrier[id]?.zh)
const response = await authInstance.get(`/carrier/${id}`); return;
const ruResponse = await languageInstance("ru").get(`/carrier/${id}`);
const enResponse = await languageInstance("en").get(`/carrier/${id}`);
const zhResponse = await languageInstance("zh").get(`/carrier/${id}`);
runInAction(() => { runInAction(() => {
if (!this.carrier[id]) { if (!this.carrier[id]) {
this.carrier[id] = { this.carrier[id] = {
id: 0, ru: null,
short_name: "", en: null,
full_name: "", zh: null,
slogan: "",
city: "",
city_id: 0,
logo: "",
main_color: "",
left_color: "",
right_color: "",
}; };
} }
this.carrier[id] = response.data; this.carrier[id].ru = ruResponse.data;
this.carrier[id].en = enResponse.data;
this.carrier[id].zh = zhResponse.data;
}); });
return response.data; return this.carrier[id];
}; };
createCarrier = async ( createCarrier = async (
fullName: string, fullName: string,
shortName: string, shortName: string,
city: string,
cityId: number, cityId: number,
main_color: string,
left_color: string,
right_color: string,
slogan: string, slogan: string,
logoId: string logoId: string
) => { ) => {
const response = await authInstance.post("/carrier", { const { language } = languageStore;
const cityName =
cityStore.cities[language].data.find((city) => city.id === cityId)
?.name || "";
const response = await languageInstance(language).post("/carrier", {
full_name: fullName, full_name: fullName,
short_name: shortName, short_name: shortName,
city, city: cityName,
city_id: cityId, city_id: cityId,
main_color,
left_color,
right_color,
slogan, slogan,
logo: logoId, logo: logoId,
}); });
const carrierId = response.data.id;
// Create translations for other languages
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
await languageInstance(lang as Language).patch(`/carrier/${carrierId}`, {
full_name: fullName,
short_name: shortName,
city: cityName,
city_id: cityId,
slogan,
logo: logoId,
});
}
runInAction(() => { runInAction(() => {
this.carriers.data.push(response.data); for (const language of ["ru", "en", "zh"] as const) {
this.carriers[language].data.push(response.data);
}
}); });
}; };
editCarrierData = { editCarrierData = {
full_name: "", ru: {
short_name: "", full_name: "",
city: "", short_name: "",
// main_color: "",
// left_color: "",
// right_color: "",
slogan: "",
},
en: {
full_name: "",
short_name: "",
// main_color: "",
// left_color: "",
// right_color: "",
slogan: "",
},
city_id: 0, city_id: 0,
main_color: "",
left_color: "",
right_color: "",
slogan: "",
logo: "", logo: "",
zh: {
full_name: "",
short_name: "",
// main_color: "",
// left_color: "",
// right_color: "",
slogan: "",
},
}; };
setEditCarrierData = ( setEditCarrierData = (
fullName: string, fullName: string,
shortName: string, shortName: string,
city: string,
cityId: number, cityId: number,
main_color: string, // main_color: string,
left_color: string, // left_color: string,
right_color: string, // right_color: string,
slogan: string, slogan: string,
logoId: string logoId: string,
language: Language
) => { ) => {
this.editCarrierData = { this.editCarrierData.city_id = cityId;
this.editCarrierData.logo = logoId;
this.editCarrierData[language] = {
full_name: fullName, full_name: fullName,
short_name: shortName, short_name: shortName,
city, // main_color: main_color,
city_id: cityId, // left_color: left_color,
main_color: main_color, // right_color: right_color,
left_color: left_color,
right_color: right_color,
slogan: slogan, slogan: slogan,
logo: logoId,
}; };
}; };
editCarrier = async (id: number) => { editCarrier = async (id: number) => {
const response = await authInstance.patch( const cityName =
`/carrier/${id}`, cityStore.cities[languageStore.language].data.find(
this.editCarrierData (city) => city.id === this.editCarrierData.city_id
); )?.name || "";
runInAction(() => { for (const language of ["ru", "en", "zh"] as const) {
this.carriers.data = this.carriers.data.map((carrier) => const response = await languageInstance(language).patch(
carrier.id === id ? { ...carrier, ...response.data } : carrier `/carrier/${id}`,
{
...this.editCarrierData[language],
city: cityName,
logo: this.editCarrierData.logo,
}
); );
this.carrier[id] = response.data; runInAction(() => {
}); if (this.carrier[id]) {
this.carrier[id][language] = response.data;
}
for (const language of ["ru", "en", "zh"] as const) {
this.carriers[language].data = this.carriers[language].data.map(
(carrier: Carrier) =>
carrier.id === id ? { ...carrier, ...response.data } : carrier
);
}
});
}
}; };
} }

View File

@ -4,6 +4,7 @@ import {
Language, Language,
languageStore, languageStore,
countryStore, countryStore,
CashedCountries,
} from "@shared"; } from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
@ -16,9 +17,18 @@ export type City = {
}; };
export type CashedCities = { export type CashedCities = {
ru: City[]; ru: {
en: City[]; data: City[];
zh: City[]; loaded: boolean;
};
en: {
data: City[];
loaded: boolean;
};
zh: {
data: City[];
loaded: boolean;
};
}; };
export type CashedCity = { export type CashedCity = {
@ -29,9 +39,18 @@ export type CashedCity = {
class CityStore { class CityStore {
cities: CashedCities = { cities: CashedCities = {
ru: [], ru: {
en: [], data: [],
zh: [], loaded: false,
},
en: {
data: [],
loaded: false,
},
zh: {
data: [],
loaded: false,
},
}; };
city: Record<string, CashedCity> = {}; city: Record<string, CashedCity> = {};
@ -40,25 +59,37 @@ class CityStore {
makeAutoObservable(this); makeAutoObservable(this);
} }
ruCities: City[] = []; ruCities: {
data: City[];
loaded: boolean;
} = {
data: [],
loaded: false,
};
getRuCities = async () => { getRuCities = async () => {
if (this.ruCities.loaded) {
return;
}
const response = await languageInstance("ru").get(`/city`); const response = await languageInstance("ru").get(`/city`);
runInAction(() => { runInAction(() => {
this.ruCities = response.data; this.ruCities.data = response.data;
this.ruCities.loaded = true;
}); });
}; };
getCities = async (language: keyof CashedCities) => { getCities = async (language: keyof CashedCities) => {
if (this.cities[language] && this.cities[language].length > 0) { if (this.cities[language].loaded) {
return; return;
} }
const response = await authInstance.get(`/city`); const response = await authInstance.get(`/city`);
runInAction(() => { 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; return response.data;
}; };
deleteCity = async (code: string, language: keyof CashedCities) => { deleteCity = async (code: string) => {
await authInstance.delete(`/city/${code}`); await authInstance.delete(`/city/${code}`);
runInAction(() => { runInAction(() => {
this.cities[language] = this.cities[language].filter( for (const secondaryLanguage of ["ru", "en", "zh"] as Language[]) {
(city) => city.country_code !== code this.cities[secondaryLanguage].data = this.cities[
); secondaryLanguage
this.city[code][language] = null; ].data.filter((city) => city.id !== Number(code));
if (this.city[code]) {
this.city[code][secondaryLanguage] = null;
}
}
}); });
}; };
createCityData = { createCityData = {
country: "",
country_code: "", country_code: "",
arms: "", arms: "",
ru: { ru: {
@ -111,14 +145,12 @@ class CityStore {
setCreateCityData = ( setCreateCityData = (
name: string, name: string,
country: string,
country_code: string, country_code: string,
arms: string, arms: string,
language: keyof CashedCities language: keyof CashedCities
) => { ) => {
this.createCityData = { this.createCityData = {
...this.createCityData, ...this.createCityData,
country: country,
country_code: country_code, country_code: country_code,
arms: arms, arms: arms,
[language]: { [language]: {
@ -127,73 +159,84 @@ class CityStore {
}; };
}; };
createCity = async () => { async createCity() {
const { language } = languageStore; const language = languageStore.language as Language;
const { country, country_code, arms } = this.createCityData; const { country_code, arms } = this.createCityData;
const { name } = this.createCityData[language as keyof CashedCities]; const { name } = this.createCityData[language];
if (name && country && country_code && arms) { if (!name || !country_code) {
const cityResponse = await languageInstance(language as Language).post( return;
"/city", }
{
name: name,
country: country,
country_code: country_code,
arms: arms,
}
);
runInAction(() => { try {
this.cities[language as keyof CashedCities] = [ // Create city in primary language
...this.cities[language as keyof CashedCities], const cityResponse = await languageInstance(language).post("/city", {
cityResponse.data, 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 (l) => l !== language
)) { )) {
const { name } = const { name: secondaryName } = this.createCityData[secondaryLanguage];
this.createCityData[secondaryLanguage as keyof CashedCities];
const patchResponse = await languageInstance( // Get country name in secondary language
secondaryLanguage as Language const countryName =
).patch(`/city/${cityResponse.data.id}`, { countryStore.countries[secondaryLanguage]?.data.find(
name: name, (c) => c.code === country_code
country: country, )?.name || "";
country_code: country_code,
arms: arms, const patchResponse = await languageInstance(secondaryLanguage).patch(
}); `/city/${cityId}`,
{
name: secondaryName || "",
country: countryName,
country_code: country_code || "",
arms: arms || "",
}
);
runInAction(() => { runInAction(() => {
this.cities[secondaryLanguage as keyof CashedCities] = [ this.cities[secondaryLanguage].data = [
...this.cities[secondaryLanguage as keyof CashedCities], ...this.cities[secondaryLanguage].data,
patchResponse.data, patchResponse.data,
]; ];
}); });
} }
}
runInAction(() => { // Update primary language data
this.createCityData = { runInAction(() => {
country: "", this.cities[language].data = [
country_code: "", ...this.cities[language].data,
arms: "", cityResponse.data,
ru: { ];
name: "", });
},
en: { // Reset form data
name: "", runInAction(() => {
}, this.createCityData = {
zh: { country_code: "",
name: "", arms: "",
}, ru: { name: "" },
}; en: { name: "" },
}); zh: { name: "" },
}; };
});
} catch (error) {
console.error("Error creating city:", error);
throw error;
}
}
editCityData = { editCityData = {
country: "",
country_code: "", country_code: "",
arms: "", arms: "",
ru: { ru: {
@ -209,14 +252,12 @@ class CityStore {
setEditCityData = ( setEditCityData = (
name: string, name: string,
country: string,
country_code: string, country_code: string,
arms: string, arms: string,
language: keyof CashedCities language: keyof CashedCities
) => { ) => {
this.editCityData = { this.editCityData = {
...this.editCityData, ...this.editCityData,
country: country,
country_code: country_code, country_code: country_code,
arms: arms, arms: arms,
@ -232,7 +273,7 @@ class CityStore {
const { name } = this.editCityData[language as keyof CashedCities]; const { name } = this.editCityData[language as keyof CashedCities];
const { countries } = countryStore; 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 (country) => country.code === country_code
); );
@ -255,9 +296,9 @@ class CityStore {
} }
if (this.cities[language as keyof CashedCities]) { 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 language as keyof CashedCities
].map((city) => ].data.map((city) =>
city.id === Number(code) city.id === Number(code)
? { ? {
id: city.id, id: city.id,

View File

@ -12,9 +12,18 @@ export type Country = {
}; };
export type CashedCountries = { export type CashedCountries = {
ru: Country[]; ru: {
en: Country[]; data: Country[];
zh: Country[]; loaded: boolean;
};
en: {
data: Country[];
loaded: boolean;
};
zh: {
data: Country[];
loaded: boolean;
};
}; };
export type CashedCountry = { export type CashedCountry = {
@ -25,9 +34,18 @@ export type CashedCountry = {
class CountryStore { class CountryStore {
countries: CashedCountries = { countries: CashedCountries = {
ru: [], ru: {
en: [], data: [],
zh: [], loaded: false,
},
en: {
data: [],
loaded: false,
},
zh: {
data: [],
loaded: false,
},
}; };
country: Record<string, CashedCountry> = {}; country: Record<string, CashedCountry> = {};
@ -37,14 +55,15 @@ class CountryStore {
} }
getCountries = async (language: keyof CashedCountries) => { getCountries = async (language: keyof CashedCountries) => {
if (this.countries[language] && this.countries[language].length > 0) { if (this.countries[language].loaded) {
return; return;
} }
const response = await authInstance.get(`/country`); const response = await languageInstance(language).get(`/country`);
runInAction(() => { 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}`); await authInstance.delete(`/country/${code}`);
runInAction(() => { runInAction(() => {
this.countries[language] = this.countries[language].filter( this.countries[language].data = this.countries[language].data.filter(
(country) => country.code !== code (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(() => { runInAction(() => {
this.countries[language as keyof CashedCountries] = [ this.countries[language as keyof CashedCountries].data = [
...this.countries[language as keyof CashedCountries], ...this.countries[language as keyof CashedCountries].data,
{ code: code, name: name }, { code: code, name: name },
]; ];
}); });
@ -142,8 +166,8 @@ class CountryStore {
); );
} }
runInAction(() => { runInAction(() => {
this.countries[secondaryLanguage as keyof CashedCountries] = [ this.countries[secondaryLanguage as keyof CashedCountries].data = [
...this.countries[secondaryLanguage as keyof CashedCountries], ...this.countries[secondaryLanguage as keyof CashedCountries].data,
{ code: code, name: name }, { code: code, name: name },
]; ];
}); });
@ -204,11 +228,10 @@ class CountryStore {
}; };
} }
if (this.countries[language as keyof CashedCountries]) { if (this.countries[language as keyof CashedCountries]) {
this.countries[language as keyof CashedCountries] = this.countries[ this.countries[language as keyof CashedCountries].data =
language as keyof CashedCountries this.countries[language as keyof CashedCountries].data.map(
].map((country) => (country) => (country.code === code ? { code, name } : country)
country.code === code ? { code, name } : country );
);
} }
}); });
} }

View File

@ -2,15 +2,20 @@ import { Canvas } from "@react-three/fiber";
import { OrbitControls, Stage, useGLTF } from "@react-three/drei"; import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
type ModelViewerProps = { type ModelViewerProps = {
width?: string;
fileUrl: string; fileUrl: string;
height?: string; height?: string;
}; };
export const ThreeView = ({ fileUrl, height = "100%" }: ModelViewerProps) => { export const ThreeView = ({
fileUrl,
height = "100%",
width = "100%",
}: ModelViewerProps) => {
const { scene } = useGLTF(fileUrl); const { scene } = useGLTF(fileUrl);
return ( return (
<Canvas style={{ width: "100%", height: height }}> <Canvas style={{ height: height, width: width }}>
<ambientLight /> <ambientLight />
<directionalLight /> <directionalLight />
<Stage environment="city" intensity={0.6}> <Stage environment="city" intensity={0.6}>

View File

@ -13,16 +13,30 @@ export function MediaViewer({
media, media,
className, className,
fullWidth, fullWidth,
}: Readonly<{ media?: MediaData; className?: string; fullWidth?: boolean }>) { fullHeight,
}: Readonly<{
media?: MediaData;
className?: string;
fullWidth?: boolean;
fullHeight?: boolean;
}>) {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
return ( return (
<Box className={className} width={fullWidth ? "100%" : "auto"}> <Box
className={className}
width={fullWidth ? "100%" : "auto"}
height={fullHeight ? "100%" : "auto"}
>
{media?.media_type === 1 && ( {media?.media_type === 1 && (
<img <img
src={`${import.meta.env.VITE_KRBL_MEDIA}${ src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id media?.id
}/download?token=${token}`} }/download?token=${token}`}
alt={media?.filename} alt={media?.filename}
style={{
height: fullHeight ? "100%" : "auto",
width: fullWidth ? "100%" : "auto",
}}
/> />
)} )}
@ -48,6 +62,10 @@ export function MediaViewer({
media?.id media?.id
}/download?token=${token}`} }/download?token=${token}`}
alt={media?.filename} alt={media?.filename}
style={{
height: fullHeight ? "100%" : "auto",
width: fullWidth ? "100%" : "auto",
}}
/> />
)} )}
{media?.media_type === 4 && ( {media?.media_type === 4 && (
@ -78,6 +96,7 @@ export function MediaViewer({
media?.id media?.id
}/download?token=${token}`} }/download?token=${token}`}
height="100%" height="100%"
width="1000px"
/> />
)} )}
</Box> </Box>

View File

@ -175,9 +175,10 @@ export const CreateInformationTab = observer(
/> />
<Autocomplete <Autocomplete
options={ruCities ?? []} options={ruCities.data ?? []}
value={ value={
ruCities.find((city) => city.id === sight.city_id) ?? null ruCities.data.find((city) => city.id === sight.city_id) ??
null
} }
getOptionLabel={(option) => option.name} getOptionLabel={(option) => option.name}
onChange={(_, value) => { onChange={(_, value) => {

View File

@ -19,14 +19,7 @@ import {
MediaViewer, MediaViewer,
DeleteModal, DeleteModal,
} from "@widgets"; } from "@widgets";
import { import { Trash2, ImagePlus, Unlink, Plus, Save, Search } from "lucide-react";
Trash2,
ImagePlus,
Unlink,
MousePointer,
Plus,
Save,
} from "lucide-react";
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@ -160,7 +153,7 @@ export const CreateLeftTab = observer(
variant="contained" variant="contained"
color="primary" color="primary"
size="small" size="small"
startIcon={<MousePointer color="white" size={18} />} startIcon={<Search color="white" size={18} />}
onClick={() => setIsSelectArticleDialogOpen(true)} onClick={() => setIsSelectArticleDialogOpen(true)}
> >
Выбрать статью Выбрать статью

View File

@ -551,6 +551,7 @@ export const CreateRightTab = observer(
media={ media={
sight[language].right[activeArticleIndex].media[0] sight[language].right[activeArticleIndex].media[0]
} }
fullWidth
/> />
</Box> </Box>
) : ( ) : (

View File

@ -163,17 +163,22 @@ export const InformationTab = observer(
/> />
<Autocomplete <Autocomplete
options={ruCities ?? []} options={ruCities?.data ?? []}
value={ value={
ruCities.find((city) => city.id === sight.common.city_id) ?? ruCities?.data?.find(
null (city) => city.id === sight.common.city_id
) ?? null
} }
getOptionLabel={(option) => option.name} getOptionLabel={(option) => option.name}
onChange={(_, value) => { onChange={(_, value) => {
setCity(value?.id ?? 0); setCity(value?.id ?? 0);
handleChange(language as Language, { handleChange(
city_id: value?.id ?? 0, language as Language,
}); {
city_id: value?.id ?? 0,
},
true
);
}} }}
renderInput={(params) => ( renderInput={(params) => (
<TextField {...params} label="Город" /> <TextField {...params} label="Город" />

View File

@ -18,14 +18,7 @@ import {
MediaViewer, MediaViewer,
DeleteModal, DeleteModal,
} from "@widgets"; } from "@widgets";
import { import { Trash2, ImagePlus, Unlink, Plus, Save, Search } from "lucide-react";
Trash2,
ImagePlus,
Unlink,
Plus,
MousePointer,
Save,
} from "lucide-react";
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@ -175,7 +168,7 @@ export const LeftWidgetTab = observer(
variant="contained" variant="contained"
color="primary" color="primary"
size="small" size="small"
startIcon={<MousePointer color="white" size={18} />} startIcon={<Search color="white" size={18} />}
onClick={() => setIsSelectArticleDialogOpen(true)} onClick={() => setIsSelectArticleDialogOpen(true)}
> >
Выбрать статью Выбрать статью

View File

@ -493,6 +493,7 @@ export const RightWidgetTab = observer(
media={ media={
sight[language].right[activeArticleIndex].media[0] sight[language].right[activeArticleIndex].media[0]
} }
fullWidth
/> />
</Box> </Box>
) : ( ) : (

View File

@ -67,7 +67,7 @@ export const SightsTable = observer(() => {
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{rows(sights, cities[language])?.map((row) => ( {rows(sights, cities[language]?.data ?? [])?.map((row) => (
<TableRow <TableRow
key={row?.id} key={row?.id}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}

File diff suppressed because one or more lines are too long