fix: Fix Map page
				
					
				
			This commit is contained in:
		| @@ -16,22 +16,22 @@ import { | ||||
|   SnapshotListPage, | ||||
|   CarrierListPage, | ||||
|   StationListPage, | ||||
|   VehicleListPage, | ||||
|   // VehicleListPage, | ||||
|   ArticleListPage, | ||||
|   CityPreviewPage, | ||||
|   CountryPreviewPage, | ||||
|   VehiclePreviewPage, | ||||
|   CarrierPreviewPage, | ||||
|   // CountryPreviewPage, | ||||
|   // VehiclePreviewPage, | ||||
|   // CarrierPreviewPage, | ||||
|   SnapshotCreatePage, | ||||
|   CountryCreatePage, | ||||
|   CityCreatePage, | ||||
|   CarrierCreatePage, | ||||
|   VehicleCreatePage, | ||||
|   // VehicleCreatePage, | ||||
|   CountryEditPage, | ||||
|   CityEditPage, | ||||
|   UserCreatePage, | ||||
|   UserEditPage, | ||||
|   VehicleEditPage, | ||||
|   // VehicleEditPage, | ||||
|   CarrierEditPage, | ||||
|   StationCreatePage, | ||||
|   StationPreviewPage, | ||||
| @@ -133,7 +133,7 @@ const router = createBrowserRouter([ | ||||
|       // Country | ||||
|       { path: "country", element: <CountryListPage /> }, | ||||
|       { path: "country/create", element: <CountryCreatePage /> }, | ||||
|       { path: "country/:id", element: <CountryPreviewPage /> }, | ||||
|       // { path: "country/:id", element: <CountryPreviewPage /> }, | ||||
|       { path: "country/:id/edit", element: <CountryEditPage /> }, | ||||
|       // City | ||||
|       { path: "city", element: <CityListPage /> }, | ||||
| @@ -156,7 +156,7 @@ const router = createBrowserRouter([ | ||||
|       // Carrier | ||||
|       { path: "carrier", element: <CarrierListPage /> }, | ||||
|       { path: "carrier/create", element: <CarrierCreatePage /> }, | ||||
|       { path: "carrier/:id", element: <CarrierPreviewPage /> }, | ||||
|       // { path: "carrier/:id", element: <CarrierPreviewPage /> }, | ||||
|       { path: "carrier/:id/edit", element: <CarrierEditPage /> }, | ||||
|       // Station | ||||
|       { path: "station", element: <StationListPage /> }, | ||||
| @@ -164,10 +164,10 @@ const router = createBrowserRouter([ | ||||
|       { path: "station/:id", element: <StationPreviewPage /> }, | ||||
|       { path: "station/:id/edit", element: <StationEditPage /> }, | ||||
|       // Vehicle | ||||
|       { path: "vehicle", element: <VehicleListPage /> }, | ||||
|       { path: "vehicle/create", element: <VehicleCreatePage /> }, | ||||
|       { path: "vehicle/:id", element: <VehiclePreviewPage /> }, | ||||
|       { path: "vehicle/:id/edit", element: <VehicleEditPage /> }, | ||||
|       // { path: "vehicle", element: <VehicleListPage /> }, | ||||
|       // { path: "vehicle/create", element: <VehicleCreatePage /> }, | ||||
|       // { path: "vehicle/:id", element: <VehiclePreviewPage /> }, | ||||
|       // { path: "vehicle/:id/edit", element: <VehicleEditPage /> }, | ||||
|       // Article | ||||
|       { path: "article", element: <ArticleListPage /> }, | ||||
|       // { path: "article/:id", element: <ArticlePreviewPage /> }, | ||||
|   | ||||
| @@ -9,6 +9,7 @@ import ExpandLessIcon from "@mui/icons-material/ExpandLess"; | ||||
| import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; | ||||
| import type { NavigationItem } from "../model"; | ||||
| import { useNavigate, useLocation } from "react-router-dom"; | ||||
| import { Plus } from "lucide-react"; | ||||
|  | ||||
| interface NavigationItemProps { | ||||
|   item: NavigationItem; | ||||
| @@ -58,7 +59,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({ | ||||
|                   justifyContent: "center", | ||||
|                 }, | ||||
|             isNested && { | ||||
|               pl: 4, | ||||
|               pl: open ? 4 : 2.5, | ||||
|             }, | ||||
|             isActive && { | ||||
|               backgroundColor: "rgba(0, 0, 0, 0.08)", | ||||
| @@ -84,7 +85,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({ | ||||
|                   }, | ||||
|             ]} | ||||
|           > | ||||
|             <Icon /> | ||||
|             {Icon ? <Icon /> : <Plus />} | ||||
|           </ListItemIcon> | ||||
|           <ListItemText | ||||
|             primary={item.label} | ||||
| @@ -108,7 +109,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({ | ||||
|         </ListItemButton> | ||||
|       </ListItem> | ||||
|       {item.nestedItems && ( | ||||
|         <Collapse in={isExpanded && open} timeout="auto" unmountOnExit> | ||||
|         <Collapse in={isExpanded} timeout="auto" unmountOnExit> | ||||
|           <List component="div" disablePadding> | ||||
|             {item.nestedItems.map((nestedItem) => ( | ||||
|               <NavigationItemComponent | ||||
|   | ||||
							
								
								
									
										28
									
								
								src/pages/Article/ArticleCreatePage/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/pages/Article/ArticleCreatePage/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| import React, { useState } from "react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { ArrowLeft } from "lucide-react"; | ||||
| import { LanguageSwitcher } from "@widgets"; | ||||
|  | ||||
| const ArticleCreatePage: React.FC = () => { | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   return ( | ||||
|     <div className="flex flex-col gap-10 w-full items-end"> | ||||
|       <div className="flex gap-10 items-center mb-5 max-w-[80%]"> | ||||
|         <h1 className="text-3xl break-words">Создание статьи</h1> | ||||
|       </div> | ||||
|       <LanguageSwitcher /> | ||||
|       <div className="flex items-center gap-4"> | ||||
|         <button | ||||
|           className="flex items-center gap-2" | ||||
|           onClick={() => navigate(-1)} | ||||
|         > | ||||
|           <ArrowLeft size={20} /> | ||||
|           Назад | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default ArticleCreatePage; | ||||
							
								
								
									
										44
									
								
								src/pages/Article/ArticleEditPage/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/pages/Article/ArticleEditPage/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| import React, { useEffect, useState } from "react"; | ||||
| import { useNavigate, useParams } from "react-router-dom"; | ||||
| import { ArrowLeft } from "lucide-react"; | ||||
| import { LanguageSwitcher } from "@widgets"; | ||||
| import { articlesStore, languageStore } from "@shared"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
|  | ||||
| const ArticleEditPage: React.FC = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
|   const { id } = useParams(); | ||||
|   const { language } = languageStore; | ||||
|   const { articleData, getArticle } = articlesStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (id) { | ||||
|       // Fetch data for all languages | ||||
|       getArticle(parseInt(id), "ru"); | ||||
|       getArticle(parseInt(id), "en"); | ||||
|       getArticle(parseInt(id), "zh"); | ||||
|     } | ||||
|   }, [id]); | ||||
|  | ||||
|   return ( | ||||
|     <div className="flex flex-col gap-10 w-full items-end"> | ||||
|       <div className="flex gap-10 items-center mb-5 max-w-[80%]"> | ||||
|         <h1 className="text-3xl break-words"> | ||||
|           {articleData?.ru?.heading || "Редактирование статьи"} | ||||
|         </h1> | ||||
|       </div> | ||||
|       <LanguageSwitcher /> | ||||
|       <div className="flex items-center gap-4"> | ||||
|         <button | ||||
|           className="flex items-center gap-2" | ||||
|           onClick={() => navigate(-1)} | ||||
|         > | ||||
|           <ArrowLeft size={20} /> | ||||
|           Назад | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }); | ||||
|  | ||||
| export default ArticleEditPage; | ||||
| @@ -2,16 +2,18 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { articlesStore, languageStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Trash2, Eye } from "lucide-react"; | ||||
| import { Trash2, Eye, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { DeleteModal, LanguageSwitcher } from "@widgets"; | ||||
|  | ||||
| export const ArticleListPage = observer(() => { | ||||
|   const { articleList, getArticleList } = articlesStore; | ||||
|   const { articleList, getArticleList, deleteArticles } = articlesStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<string | null>(null); // Lifted state | ||||
|   const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<string | null>(null); | ||||
|   const { language } = languageStore; | ||||
|   const [ids, setIds] = useState<number[]>([]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     getArticleList(); | ||||
| @@ -22,6 +24,15 @@ export const ArticleListPage = observer(() => { | ||||
|       field: "heading", | ||||
|       headerName: "Название", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return params.value ? ( | ||||
|           params.value | ||||
|         ) : ( | ||||
|           <div className="flex h-full gap-7 items-center"> | ||||
|             <Minus size={20} className="text-red-500" /> | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|  | ||||
|     { | ||||
| @@ -59,18 +70,42 @@ export const ArticleListPage = observer(() => { | ||||
|       <LanguageSwitcher /> | ||||
|  | ||||
|       <div className="w-full"> | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           hideFooterPagination | ||||
|           hideFooter | ||||
|         /> | ||||
|         <div className="flex justify-between items-center mb-10"> | ||||
|           <h1 className="text-2xl">Статьи</h1> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|           className="flex justify-end mb-5 duration-300" | ||||
|           style={{ opacity: ids.length > 0 ? 1 : 0 }} | ||||
|         > | ||||
|           <button | ||||
|             className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" | ||||
|             onClick={() => setIsBulkDeleteModalOpen(true)} | ||||
|           > | ||||
|             <Trash2 size={20} className="text-white" /> Удалить выбранные ( | ||||
|             {ids.length}) | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <div className="w-full"> | ||||
|           <DataGrid | ||||
|             rows={rows} | ||||
|             columns={columns} | ||||
|             hideFooterPagination | ||||
|             checkboxSelection | ||||
|             onRowSelectionModelChange={(newSelection) => { | ||||
|               setIds(Array.from(newSelection.ids) as number[]); | ||||
|             }} | ||||
|             hideFooter | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           if (rowId) { | ||||
|             await deleteArticles([parseInt(rowId)]); | ||||
|             getArticleList(); | ||||
|           } | ||||
|           setIsDeleteModalOpen(false); | ||||
| @@ -81,6 +116,19 @@ export const ArticleListPage = observer(() => { | ||||
|           setRowId(null); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isBulkDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           await deleteArticles(ids); | ||||
|           getArticleList(); | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|           setIds([]); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -12,21 +12,31 @@ import { ArrowLeft, Save } from "lucide-react"; | ||||
| import { Loader2 } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { toast } from "react-toastify"; | ||||
| import { carrierStore, cityStore, mediaStore } from "@shared"; | ||||
| import { carrierStore, cityStore, mediaStore, languageStore } from "@shared"; | ||||
| import { useState, useEffect } from "react"; | ||||
| import { MediaViewer } from "@widgets"; | ||||
| import { MediaViewer, ImageUploadCard, LanguageSwitcher } from "@widgets"; | ||||
| import { | ||||
|   SelectMediaDialog, | ||||
|   UploadMediaDialog, | ||||
|   PreviewMediaDialog, | ||||
| } from "@shared"; | ||||
|  | ||||
| export const CarrierCreatePage = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
|   const { language } = languageStore; | ||||
|   const [fullName, setFullName] = useState(""); | ||||
|   const [shortName, setShortName] = useState(""); | ||||
|   const [cityId, setCityId] = useState<number | null>(null); | ||||
|   const [main_color, setMainColor] = useState("#000000"); | ||||
|   const [left_color, setLeftColor] = useState("#ffffff"); | ||||
|   const [right_color, setRightColor] = useState("#ff0000"); | ||||
|   const [slogan, setSlogan] = useState(""); | ||||
|   const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); | ||||
|   const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); | ||||
|   const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); | ||||
|   const [mediaId, setMediaId] = useState(""); | ||||
|   const [activeMenuType, setActiveMenuType] = useState< | ||||
|     "thumbnail" | "watermark_lu" | "watermark_rd" | null | ||||
|   >(null); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     cityStore.getCities("ru"); | ||||
| @@ -39,11 +49,7 @@ export const CarrierCreatePage = observer(() => { | ||||
|       await carrierStore.createCarrier( | ||||
|         fullName, | ||||
|         shortName, | ||||
|         cityStore.cities.ru.find((c) => c.id === cityId)?.name!, | ||||
|         cityId!, | ||||
|         main_color, | ||||
|         left_color, | ||||
|         right_color, | ||||
|         slogan, | ||||
|         selectedMediaId! | ||||
|       ); | ||||
| @@ -56,8 +62,22 @@ export const CarrierCreatePage = observer(() => { | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleMediaSelect = (media: { | ||||
|     id: string; | ||||
|     filename: string; | ||||
|     media_name?: string; | ||||
|     media_type: number; | ||||
|   }) => { | ||||
|     setSelectedMediaId(media.id); | ||||
|   }; | ||||
|  | ||||
|   const selectedMedia = selectedMediaId | ||||
|     ? mediaStore.media.find((m) => m.id === selectedMediaId) | ||||
|     : null; | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
|       <LanguageSwitcher /> | ||||
|       <div className="flex items-center gap-4"> | ||||
|         <button | ||||
|           className="flex items-center gap-2" | ||||
| @@ -69,6 +89,10 @@ export const CarrierCreatePage = observer(() => { | ||||
|       </div> | ||||
|  | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <div className="flex gap-10 items-center mb-5 max-w-[80%]"> | ||||
|           <h1 className="text-3xl break-words">Создание перевозчика</h1> | ||||
|         </div> | ||||
|  | ||||
|         <FormControl fullWidth> | ||||
|           <InputLabel>Город</InputLabel> | ||||
|           <Select | ||||
| @@ -77,7 +101,7 @@ export const CarrierCreatePage = observer(() => { | ||||
|             required | ||||
|             onChange={(e) => setCityId(e.target.value as number)} | ||||
|           > | ||||
|             {cityStore.cities.ru.map((city) => ( | ||||
|             {cityStore.cities.ru.data.map((city) => ( | ||||
|               <MenuItem key={city.id} value={city.id}> | ||||
|                 {city.name} | ||||
|               </MenuItem> | ||||
| @@ -101,57 +125,6 @@ export const CarrierCreatePage = observer(() => { | ||||
|           onChange={(e) => setShortName(e.target.value)} | ||||
|         /> | ||||
|  | ||||
|         <div className="flex gap-4 w-full "> | ||||
|           <TextField | ||||
|             fullWidth | ||||
|             label="Основной цвет" | ||||
|             value={main_color} | ||||
|             className="flex-1 w-full" | ||||
|             onChange={(e) => setMainColor(e.target.value)} | ||||
|             type="color" | ||||
|             sx={{ | ||||
|               "& input": { | ||||
|                 height: "50px", | ||||
|                 paddingBlock: "14px", | ||||
|                 paddingInline: "14px", | ||||
|                 cursor: "pointer", | ||||
|               }, | ||||
|             }} | ||||
|           /> | ||||
|           <TextField | ||||
|             fullWidth | ||||
|             label="Цвет левого виджета" | ||||
|             value={left_color} | ||||
|             className="flex-1 w-full" | ||||
|             onChange={(e) => setLeftColor(e.target.value)} | ||||
|             type="color" | ||||
|             sx={{ | ||||
|               "& input": { | ||||
|                 height: "50px", | ||||
|                 paddingBlock: "14px", | ||||
|                 paddingInline: "14px", | ||||
|                 cursor: "pointer", | ||||
|               }, | ||||
|             }} | ||||
|           /> | ||||
|           <TextField | ||||
|             fullWidth | ||||
|             label="Цвет правого виджета" | ||||
|             value={right_color} | ||||
|             className="flex-1 w-full" | ||||
|             onChange={(e) => setRightColor(e.target.value)} | ||||
|             type="color" | ||||
|             sx={{ | ||||
|               "& input": { | ||||
|                 height: "50px", | ||||
|                 paddingBlock: "14px", | ||||
|                 paddingInline: "14px", | ||||
|                 cursor: "pointer", | ||||
|               }, | ||||
|             }} | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Слоган" | ||||
| @@ -159,29 +132,28 @@ export const CarrierCreatePage = observer(() => { | ||||
|           onChange={(e) => setSlogan(e.target.value)} | ||||
|         /> | ||||
|  | ||||
|         <div className="w-full flex flex-col gap-4"> | ||||
|           <FormControl fullWidth> | ||||
|             <InputLabel>Логотип</InputLabel> | ||||
|             <Select | ||||
|               value={selectedMediaId || ""} | ||||
|               label="Логотип" | ||||
|               required | ||||
|               onChange={(e) => setSelectedMediaId(e.target.value as string)} | ||||
|             > | ||||
|               {mediaStore.media | ||||
|                 .filter((media) => media.media_type === 3) | ||||
|                 .map((media) => ( | ||||
|                   <MenuItem key={media.id} value={media.id}> | ||||
|                     {media.media_name || media.filename} | ||||
|                   </MenuItem> | ||||
|                 ))} | ||||
|             </Select> | ||||
|           </FormControl> | ||||
|           {selectedMediaId && ( | ||||
|             <div className="w-32 h-32"> | ||||
|               <MediaViewer media={{ id: selectedMediaId, media_type: 1 }} /> | ||||
|             </div> | ||||
|           )} | ||||
|         <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto"> | ||||
|           <ImageUploadCard | ||||
|             title="Логотип перевозчика" | ||||
|             imageKey="thumbnail" | ||||
|             imageUrl={selectedMedia?.id} | ||||
|             onImageClick={() => { | ||||
|               setIsPreviewMediaOpen(true); | ||||
|               setMediaId(selectedMedia?.id ?? ""); | ||||
|             }} | ||||
|             onDeleteImageClick={() => { | ||||
|               setSelectedMediaId(null); | ||||
|               setActiveMenuType(null); | ||||
|             }} | ||||
|             onSelectFileClick={() => { | ||||
|               setActiveMenuType("thumbnail"); | ||||
|               setIsSelectMediaOpen(true); | ||||
|             }} | ||||
|             setUploadMediaOpen={() => { | ||||
|               setIsUploadMediaOpen(true); | ||||
|               setActiveMenuType("thumbnail"); | ||||
|             }} | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <Button | ||||
| @@ -200,6 +172,26 @@ export const CarrierCreatePage = observer(() => { | ||||
|           )} | ||||
|         </Button> | ||||
|       </div> | ||||
|  | ||||
|       <SelectMediaDialog | ||||
|         open={isSelectMediaOpen} | ||||
|         onClose={() => setIsSelectMediaOpen(false)} | ||||
|         onSelectMedia={handleMediaSelect} | ||||
|         mediaType={3} | ||||
|       /> | ||||
|  | ||||
|       <UploadMediaDialog | ||||
|         open={isUploadMediaOpen} | ||||
|         onClose={() => setIsUploadMediaOpen(false)} | ||||
|         afterUpload={handleMediaSelect} | ||||
|         hardcodeType={activeMenuType} | ||||
|       /> | ||||
|  | ||||
|       <PreviewMediaDialog | ||||
|         open={isPreviewMediaOpen} | ||||
|         onClose={() => setIsPreviewMediaOpen(false)} | ||||
|         mediaId={mediaId} | ||||
|       /> | ||||
|     </Paper> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -14,7 +14,12 @@ import { useNavigate, useParams } from "react-router-dom"; | ||||
| import { toast } from "react-toastify"; | ||||
| import { carrierStore, cityStore, mediaStore } from "@shared"; | ||||
| import { useState, useEffect } from "react"; | ||||
| import { MediaViewer } from "@widgets"; | ||||
| import { MediaViewer, ImageUploadCard } from "@widgets"; | ||||
| import { | ||||
|   SelectMediaDialog, | ||||
|   UploadMediaDialog, | ||||
|   PreviewMediaDialog, | ||||
| } from "@shared"; | ||||
|  | ||||
| export const CarrierEditPage = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
| @@ -23,22 +28,30 @@ export const CarrierEditPage = observer(() => { | ||||
|     carrierStore; | ||||
|  | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); | ||||
|   const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); | ||||
|   const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); | ||||
|   const [mediaId, setMediaId] = useState(""); | ||||
|   const [activeMenuType, setActiveMenuType] = useState< | ||||
|     "thumbnail" | "watermark_lu" | "watermark_rd" | null | ||||
|   >(null); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       await cityStore.getCities("ru"); | ||||
|       await cityStore.getCities("en"); | ||||
|       await cityStore.getCities("zh"); | ||||
|       await getCarrier(Number(id)); | ||||
|  | ||||
|       setEditCarrierData( | ||||
|         carrier?.[Number(id)]?.full_name as string, | ||||
|         carrier?.[Number(id)]?.short_name as string, | ||||
|         carrier?.[Number(id)]?.city as string, | ||||
|  | ||||
|         carrier?.[Number(id)]?.city_id as number, | ||||
|         carrier?.[Number(id)]?.main_color as string, | ||||
|         carrier?.[Number(id)]?.left_color as string, | ||||
|         carrier?.[Number(id)]?.right_color as string, | ||||
|         carrier?.[Number(id)]?.slogan as string, | ||||
|         carrier?.[Number(id)]?.logo as string | ||||
|       ); | ||||
|       cityStore.getCities("ru"); | ||||
|  | ||||
|       mediaStore.getMedia(); | ||||
|     })(); | ||||
|   }, [id]); | ||||
| @@ -56,6 +69,26 @@ export const CarrierEditPage = observer(() => { | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleMediaSelect = (media: { | ||||
|     id: string; | ||||
|     filename: string; | ||||
|     media_name?: string; | ||||
|     media_type: number; | ||||
|   }) => { | ||||
|     setEditCarrierData( | ||||
|       editCarrierData.full_name, | ||||
|       editCarrierData.short_name, | ||||
|  | ||||
|       editCarrierData.city_id, | ||||
|       editCarrierData.slogan, | ||||
|       media.id | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const selectedMedia = editCarrierData.logo | ||||
|     ? mediaStore.media.find((m) => m.id === editCarrierData.logo) | ||||
|     : null; | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
|       <div className="flex items-center gap-4"> | ||||
| @@ -79,17 +112,13 @@ export const CarrierEditPage = observer(() => { | ||||
|               setEditCarrierData( | ||||
|                 editCarrierData.full_name, | ||||
|                 editCarrierData.short_name, | ||||
|                 editCarrierData.city, | ||||
|                 Number(e.target.value), | ||||
|                 editCarrierData.main_color, | ||||
|                 editCarrierData.left_color, | ||||
|                 editCarrierData.right_color, | ||||
|                 editCarrierData.slogan, | ||||
|                 editCarrierData.logo | ||||
|               ) | ||||
|             } | ||||
|           > | ||||
|             {cityStore.cities.ru.map((city) => ( | ||||
|             {cityStore.cities.ru.data?.map((city) => ( | ||||
|               <MenuItem key={city.id} value={city.id}> | ||||
|                 {city.name} | ||||
|               </MenuItem> | ||||
| @@ -106,11 +135,8 @@ export const CarrierEditPage = observer(() => { | ||||
|             setEditCarrierData( | ||||
|               e.target.value, | ||||
|               editCarrierData.short_name, | ||||
|               editCarrierData.city, | ||||
|  | ||||
|               editCarrierData.city_id, | ||||
|               editCarrierData.main_color, | ||||
|               editCarrierData.left_color, | ||||
|               editCarrierData.right_color, | ||||
|               editCarrierData.slogan, | ||||
|               editCarrierData.logo | ||||
|             ) | ||||
| @@ -126,104 +152,14 @@ export const CarrierEditPage = observer(() => { | ||||
|             setEditCarrierData( | ||||
|               editCarrierData.full_name, | ||||
|               e.target.value, | ||||
|               editCarrierData.city, | ||||
|  | ||||
|               editCarrierData.city_id, | ||||
|               editCarrierData.main_color, | ||||
|               editCarrierData.left_color, | ||||
|               editCarrierData.right_color, | ||||
|               editCarrierData.slogan, | ||||
|               editCarrierData.logo | ||||
|             ) | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|         <div className="flex gap-4 w-full"> | ||||
|           <TextField | ||||
|             fullWidth | ||||
|             label="Основной цвет" | ||||
|             value={editCarrierData.main_color} | ||||
|             className="flex-1 w-full" | ||||
|             onChange={(e) => | ||||
|               setEditCarrierData( | ||||
|                 editCarrierData.full_name, | ||||
|                 editCarrierData.short_name, | ||||
|                 editCarrierData.city, | ||||
|                 editCarrierData.city_id, | ||||
|                 e.target.value, | ||||
|                 editCarrierData.left_color, | ||||
|                 editCarrierData.right_color, | ||||
|                 editCarrierData.slogan, | ||||
|                 editCarrierData.logo | ||||
|               ) | ||||
|             } | ||||
|             type="color" | ||||
|             sx={{ | ||||
|               "& input": { | ||||
|                 height: "50px", | ||||
|                 paddingBlock: "14px", | ||||
|                 paddingInline: "14px", | ||||
|                 cursor: "pointer", | ||||
|               }, | ||||
|             }} | ||||
|           /> | ||||
|           <TextField | ||||
|             fullWidth | ||||
|             label="Цвет левого виджета" | ||||
|             value={editCarrierData.left_color} | ||||
|             className="flex-1 w-full" | ||||
|             onChange={(e) => | ||||
|               setEditCarrierData( | ||||
|                 editCarrierData.full_name, | ||||
|                 editCarrierData.short_name, | ||||
|                 editCarrierData.city, | ||||
|                 editCarrierData.city_id, | ||||
|                 editCarrierData.main_color, | ||||
|                 e.target.value, | ||||
|                 editCarrierData.right_color, | ||||
|                 editCarrierData.slogan, | ||||
|                 editCarrierData.logo | ||||
|               ) | ||||
|             } | ||||
|             type="color" | ||||
|             sx={{ | ||||
|               "& input": { | ||||
|                 height: "50px", | ||||
|                 paddingBlock: "14px", | ||||
|                 paddingInline: "14px", | ||||
|                 cursor: "pointer", | ||||
|               }, | ||||
|             }} | ||||
|           /> | ||||
|           <TextField | ||||
|             fullWidth | ||||
|             label="Цвет правого виджета" | ||||
|             value={editCarrierData.right_color} | ||||
|             className="flex-1 w-full" | ||||
|             onChange={(e) => | ||||
|               setEditCarrierData( | ||||
|                 editCarrierData.full_name, | ||||
|                 editCarrierData.short_name, | ||||
|                 editCarrierData.city, | ||||
|                 editCarrierData.city_id, | ||||
|                 editCarrierData.main_color, | ||||
|                 editCarrierData.left_color, | ||||
|                 e.target.value, | ||||
|                 editCarrierData.slogan, | ||||
|                 editCarrierData.logo | ||||
|               ) | ||||
|             } | ||||
|             type="color" | ||||
|             sx={{ | ||||
|               "& input": { | ||||
|                 height: "50px", | ||||
|                 paddingBlock: "14px", | ||||
|                 paddingInline: "14px", | ||||
|                 cursor: "pointer", | ||||
|               }, | ||||
|             }} | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Слоган" | ||||
| @@ -232,54 +168,43 @@ export const CarrierEditPage = observer(() => { | ||||
|             setEditCarrierData( | ||||
|               editCarrierData.full_name, | ||||
|               editCarrierData.short_name, | ||||
|               editCarrierData.city, | ||||
|  | ||||
|               editCarrierData.city_id, | ||||
|               editCarrierData.main_color, | ||||
|               editCarrierData.left_color, | ||||
|               editCarrierData.right_color, | ||||
|               e.target.value, | ||||
|               editCarrierData.logo | ||||
|             ) | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|         <div className="w-full flex flex-col gap-4"> | ||||
|           <FormControl fullWidth> | ||||
|             <InputLabel>Логотип</InputLabel> | ||||
|             <Select | ||||
|               value={editCarrierData.logo || ""} | ||||
|               label="Логотип" | ||||
|               required | ||||
|               onChange={(e) => | ||||
|                 setEditCarrierData( | ||||
|                   editCarrierData.full_name, | ||||
|                   editCarrierData.short_name, | ||||
|                   editCarrierData.city, | ||||
|                   editCarrierData.city_id, | ||||
|                   editCarrierData.main_color, | ||||
|                   editCarrierData.left_color, | ||||
|                   editCarrierData.right_color, | ||||
|                   editCarrierData.slogan, | ||||
|                   e.target.value as string | ||||
|                 ) | ||||
|               } | ||||
|             > | ||||
|               {mediaStore.media | ||||
|                 .filter((media) => media.media_type === 3) | ||||
|                 .map((media) => ( | ||||
|                   <MenuItem key={media.id} value={media.id}> | ||||
|                     {media.media_name || media.filename} | ||||
|                   </MenuItem> | ||||
|                 ))} | ||||
|             </Select> | ||||
|           </FormControl> | ||||
|           {editCarrierData.logo && ( | ||||
|             <div className="w-32 h-32"> | ||||
|               <MediaViewer | ||||
|                 media={{ id: editCarrierData.logo, media_type: 1 }} | ||||
|               /> | ||||
|             </div> | ||||
|           )} | ||||
|         <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto"> | ||||
|           <ImageUploadCard | ||||
|             title="Логотип перевозчика" | ||||
|             imageKey="thumbnail" | ||||
|             imageUrl={selectedMedia?.id} | ||||
|             onImageClick={() => { | ||||
|               setIsPreviewMediaOpen(true); | ||||
|               setMediaId(selectedMedia?.id ?? ""); | ||||
|             }} | ||||
|             onDeleteImageClick={() => { | ||||
|               setEditCarrierData( | ||||
|                 editCarrierData.full_name, | ||||
|                 editCarrierData.short_name, | ||||
|  | ||||
|                 editCarrierData.city_id, | ||||
|                 editCarrierData.slogan, | ||||
|                 "" | ||||
|               ); | ||||
|               setActiveMenuType(null); | ||||
|             }} | ||||
|             onSelectFileClick={() => { | ||||
|               setActiveMenuType("thumbnail"); | ||||
|               setIsSelectMediaOpen(true); | ||||
|             }} | ||||
|             setUploadMediaOpen={() => { | ||||
|               setIsUploadMediaOpen(true); | ||||
|               setActiveMenuType("thumbnail"); | ||||
|             }} | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <Button | ||||
| @@ -302,6 +227,26 @@ export const CarrierEditPage = observer(() => { | ||||
|           )} | ||||
|         </Button> | ||||
|       </div> | ||||
|  | ||||
|       <SelectMediaDialog | ||||
|         open={isSelectMediaOpen} | ||||
|         onClose={() => setIsSelectMediaOpen(false)} | ||||
|         onSelectMedia={handleMediaSelect} | ||||
|         mediaType={3} | ||||
|       /> | ||||
|  | ||||
|       <UploadMediaDialog | ||||
|         open={isUploadMediaOpen} | ||||
|         onClose={() => setIsUploadMediaOpen(false)} | ||||
|         afterUpload={handleMediaSelect} | ||||
|         hardcodeType={activeMenuType} | ||||
|       /> | ||||
|  | ||||
|       <PreviewMediaDialog | ||||
|         open={isPreviewMediaOpen} | ||||
|         onClose={() => setIsPreviewMediaOpen(false)} | ||||
|         mediaId={mediaId} | ||||
|       /> | ||||
|     </Paper> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { carrierStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Eye, Pencil, Trash2 } from "lucide-react"; | ||||
| import { Eye, Pencil, Trash2, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { CreateButton, DeleteModal } from "@widgets"; | ||||
|  | ||||
| @@ -10,7 +10,9 @@ export const CarrierListPage = observer(() => { | ||||
|   const { carriers, getCarriers, deleteCarrier } = carrierStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); // Lifted state | ||||
|   const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); | ||||
|   const [ids, setIds] = useState<number[]>([]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     getCarriers(); | ||||
| @@ -20,18 +22,50 @@ export const CarrierListPage = observer(() => { | ||||
|     { | ||||
|       field: "full_name", | ||||
|       headerName: "Полное имя", | ||||
|  | ||||
|       width: 300, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "short_name", | ||||
|       headerName: "Короткое имя", | ||||
|       width: 200, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "city", | ||||
|       headerName: "Город", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "actions", | ||||
| @@ -45,9 +79,9 @@ export const CarrierListPage = observer(() => { | ||||
|             <button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}> | ||||
|               <Pencil size={20} className="text-blue-500" /> | ||||
|             </button> | ||||
|             <button onClick={() => navigate(`/carrier/${params.row.id}`)}> | ||||
|             {/* <button onClick={() => navigate(`/carrier/${params.row.id}`)}> | ||||
|               <Eye size={20} className="text-green-500" /> | ||||
|             </button> | ||||
|             </button> */} | ||||
|             <button | ||||
|               onClick={() => { | ||||
|                 setIsDeleteModalOpen(true); | ||||
| @@ -76,10 +110,28 @@ export const CarrierListPage = observer(() => { | ||||
|           <h1 className="text-2xl">Перевозчики</h1> | ||||
|           <CreateButton label="Создать перевозчика" path="/carrier/create" /> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|           className="flex justify-end mb-5 duration-300" | ||||
|           style={{ opacity: ids.length > 0 ? 1 : 0 }} | ||||
|         > | ||||
|           <button | ||||
|             className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" | ||||
|             onClick={() => setIsBulkDeleteModalOpen(true)} | ||||
|           > | ||||
|             <Trash2 size={20} className="text-white" /> Удалить выбранные ( | ||||
|             {ids.length}) | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           hideFooterPagination | ||||
|           checkboxSelection | ||||
|           onRowSelectionModelChange={(newSelection) => { | ||||
|             setIds(Array.from(newSelection.ids) as number[]); | ||||
|           }} | ||||
|           hideFooter | ||||
|         /> | ||||
|       </div> | ||||
| @@ -98,6 +150,19 @@ export const CarrierListPage = observer(() => { | ||||
|           setRowId(null); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isBulkDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           await Promise.all(ids.map((id) => deleteCarrier(id))); | ||||
|           getCarriers(); | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|           setIds([]); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -20,9 +20,9 @@ export const CarrierPreviewPage = observer(() => { | ||||
|         carrierResponse?.short_name as string, | ||||
|         carrierResponse?.city as string, | ||||
|         carrierResponse?.city_id as number, | ||||
|         carrierResponse?.main_color as string, | ||||
|         carrierResponse?.left_color as string, | ||||
|         carrierResponse?.right_color as string, | ||||
|         // carrierResponse?.main_color as string, | ||||
|         // carrierResponse?.left_color as string, | ||||
|         // carrierResponse?.right_color as string, | ||||
|         carrierResponse?.slogan as string, | ||||
|         carrierResponse?.logo as string | ||||
|       ); | ||||
| @@ -58,7 +58,7 @@ export const CarrierPreviewPage = observer(() => { | ||||
|               <h1 className="text-lg font-bold">Город</h1> | ||||
|               <p>{carrier[Number(id)]?.city}</p> | ||||
|             </div> | ||||
|             <div className="flex flex-col gap-2 "> | ||||
|             {/* <div className="flex flex-col gap-2 "> | ||||
|               <h1 className="text-lg font-bold">Основной цвет</h1> | ||||
|               <div | ||||
|                 className="w-min" | ||||
| @@ -90,22 +90,24 @@ export const CarrierPreviewPage = observer(() => { | ||||
|               > | ||||
|                 {carrier[Number(id)]?.right_color} | ||||
|               </div> | ||||
|             </div> | ||||
|             </div> */} | ||||
|             <div className="flex flex-col gap-2"> | ||||
|               <h1 className="text-lg font-bold">Краткое имя</h1> | ||||
|               <p>{carrier[Number(id)]?.short_name}</p> | ||||
|             </div> | ||||
|             <div className="flex flex-col gap-2"> | ||||
|               <h1 className="text-lg font-bold">Логотип</h1> | ||||
|             {oneMedia && ( | ||||
|               <div className="flex flex-col gap-2"> | ||||
|                 <h1 className="text-lg font-bold">Логотип</h1> | ||||
|  | ||||
|               <MediaViewer | ||||
|                 media={{ | ||||
|                   id: oneMedia?.id as string, | ||||
|                   media_type: oneMedia?.media_type as number, | ||||
|                   filename: oneMedia?.filename, | ||||
|                 }} | ||||
|               /> | ||||
|             </div> | ||||
|                 <MediaViewer | ||||
|                   media={{ | ||||
|                     id: oneMedia?.id as string, | ||||
|                     media_type: oneMedia?.media_type as number, | ||||
|                     filename: oneMedia?.filename, | ||||
|                   }} | ||||
|                 /> | ||||
|               </div> | ||||
|             )} | ||||
|           </div> | ||||
|         </> | ||||
|       )} | ||||
|   | ||||
| @@ -9,14 +9,18 @@ import { | ||||
|   Box, | ||||
| } from "@mui/material"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { ArrowLeft, Save, ImagePlus } from "lucide-react"; | ||||
| import { ArrowLeft, Save, ImagePlus, Minus } from "lucide-react"; | ||||
| import { Loader2 } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { toast } from "react-toastify"; | ||||
| import { cityStore, countryStore, languageStore, mediaStore } from "@shared"; | ||||
| import { useState, useEffect } from "react"; | ||||
| import { LanguageSwitcher, MediaViewer } from "@widgets"; | ||||
| import { SelectMediaDialog } from "@shared"; | ||||
| import { LanguageSwitcher, MediaViewer, ImageUploadCard } from "@widgets"; | ||||
| import { | ||||
|   SelectMediaDialog, | ||||
|   UploadMediaDialog, | ||||
|   PreviewMediaDialog, | ||||
| } from "@shared"; | ||||
|  | ||||
| export const CityCreatePage = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
| @@ -24,12 +28,20 @@ export const CityCreatePage = observer(() => { | ||||
|   const { createCityData, setCreateCityData } = cityStore; | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); | ||||
|   const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); | ||||
|   const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); | ||||
|   const [mediaId, setMediaId] = useState(""); | ||||
|   const [activeMenuType, setActiveMenuType] = useState< | ||||
|     "thumbnail" | "watermark_lu" | "watermark_rd" | null | ||||
|   >(null); | ||||
|   const { getCountries } = countryStore; | ||||
|   const { getMedia } = mediaStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       await getCountries(language); | ||||
|       await getCountries("ru"); | ||||
|       await getCountries("en"); | ||||
|       await getCountries("zh"); | ||||
|       await getMedia(); | ||||
|     })(); | ||||
|   }, [language]); | ||||
| @@ -55,7 +67,6 @@ export const CityCreatePage = observer(() => { | ||||
|   }) => { | ||||
|     setCreateCityData( | ||||
|       createCityData[language].name, | ||||
|       createCityData.country, | ||||
|       createCityData.country_code, | ||||
|       media.id, | ||||
|       language | ||||
| @@ -80,6 +91,9 @@ export const CityCreatePage = observer(() => { | ||||
|       </div> | ||||
|  | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <div className="flex gap-10 items-center mb-5 max-w-[80%]"> | ||||
|           <h1 className="text-3xl break-words">Создание города</h1> | ||||
|         </div> | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Название города" | ||||
| @@ -88,7 +102,6 @@ export const CityCreatePage = observer(() => { | ||||
|           onChange={(e) => | ||||
|             setCreateCityData( | ||||
|               e.target.value, | ||||
|               createCityData.country, | ||||
|               createCityData.country_code, | ||||
|               createCityData.arms, | ||||
|               language | ||||
| @@ -103,19 +116,15 @@ export const CityCreatePage = observer(() => { | ||||
|             label="Страна" | ||||
|             required | ||||
|             onChange={(e) => { | ||||
|               const selectedCountry = countryStore.countries[language]?.find( | ||||
|                 (country) => country.code === e.target.value | ||||
|               ); | ||||
|               setCreateCityData( | ||||
|                 createCityData[language].name, | ||||
|                 selectedCountry?.name || "", | ||||
|                 e.target.value, | ||||
|                 createCityData.arms, | ||||
|                 language | ||||
|               ); | ||||
|             }} | ||||
|           > | ||||
|             {countryStore.countries[language].map((country) => ( | ||||
|             {countryStore.countries["ru"]?.data?.map((country) => ( | ||||
|               <MenuItem key={country.code} value={country.code}> | ||||
|                 {country.name} | ||||
|               </MenuItem> | ||||
| @@ -123,44 +132,39 @@ export const CityCreatePage = observer(() => { | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|  | ||||
|         <div className="w-full flex flex-col gap-4"> | ||||
|           <label className="text-sm text-gray-600">Герб города</label> | ||||
|           <div className="flex items-center gap-4"> | ||||
|             <Button | ||||
|               variant="outlined" | ||||
|               onClick={() => setIsSelectMediaOpen(true)} | ||||
|               startIcon={<ImagePlus size={20} />} | ||||
|             > | ||||
|               Выбрать герб | ||||
|             </Button> | ||||
|             {selectedMedia && ( | ||||
|               <span className="text-sm text-gray-600"> | ||||
|                 {selectedMedia.media_name || selectedMedia.filename} | ||||
|               </span> | ||||
|             )} | ||||
|           </div> | ||||
|           {selectedMedia && ( | ||||
|             <Box | ||||
|               sx={{ | ||||
|                 width: "200px", | ||||
|                 height: "200px", | ||||
|                 border: "1px solid #e0e0e0", | ||||
|                 borderRadius: "8px", | ||||
|                 overflow: "hidden", | ||||
|                 display: "flex", | ||||
|                 alignItems: "center", | ||||
|                 justifyContent: "center", | ||||
|               }} | ||||
|             > | ||||
|               <MediaViewer | ||||
|                 media={{ | ||||
|                   id: selectedMedia.id, | ||||
|                   media_type: selectedMedia.media_type, | ||||
|                   filename: selectedMedia.filename, | ||||
|                 }} | ||||
|               /> | ||||
|             </Box> | ||||
|         <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto"> | ||||
|           {!selectedMedia && ( | ||||
|             <div className="flex items-center gap-2 text-red-500"> | ||||
|               <Minus size={20} /> | ||||
|               <span className="text-sm">Герб города не выбран</span> | ||||
|             </div> | ||||
|           )} | ||||
|           <ImageUploadCard | ||||
|             title="Герб города" | ||||
|             imageKey="thumbnail" | ||||
|             imageUrl={selectedMedia?.id} | ||||
|             onImageClick={() => { | ||||
|               setIsPreviewMediaOpen(true); | ||||
|               setMediaId(selectedMedia?.id ?? ""); | ||||
|             }} | ||||
|             onDeleteImageClick={() => { | ||||
|               setCreateCityData( | ||||
|                 createCityData[language].name, | ||||
|                 createCityData.country_code, | ||||
|                 "", | ||||
|                 language | ||||
|               ); | ||||
|               setActiveMenuType(null); | ||||
|             }} | ||||
|             onSelectFileClick={() => { | ||||
|               setActiveMenuType("thumbnail"); | ||||
|               setIsSelectMediaOpen(true); | ||||
|             }} | ||||
|             setUploadMediaOpen={() => { | ||||
|               setIsUploadMediaOpen(true); | ||||
|               setActiveMenuType("thumbnail"); | ||||
|             }} | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <Button | ||||
| @@ -184,6 +188,19 @@ export const CityCreatePage = observer(() => { | ||||
|         onSelectMedia={handleMediaSelect} | ||||
|         mediaType={3} // Тип медиа для иконок | ||||
|       /> | ||||
|  | ||||
|       <UploadMediaDialog | ||||
|         open={isUploadMediaOpen} | ||||
|         onClose={() => setIsUploadMediaOpen(false)} | ||||
|         afterUpload={handleMediaSelect} | ||||
|         hardcodeType={activeMenuType} | ||||
|       /> | ||||
|  | ||||
|       <PreviewMediaDialog | ||||
|         open={isPreviewMediaOpen} | ||||
|         onClose={() => setIsPreviewMediaOpen(false)} | ||||
|         mediaId={mediaId} | ||||
|       /> | ||||
|     </Paper> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -21,13 +21,23 @@ import { | ||||
|   CashedCities, | ||||
| } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { LanguageSwitcher, MediaViewer } from "@widgets"; | ||||
| import { SelectMediaDialog } from "@shared"; | ||||
| import { LanguageSwitcher, MediaViewer, ImageUploadCard } from "@widgets"; | ||||
| import { | ||||
|   SelectMediaDialog, | ||||
|   UploadMediaDialog, | ||||
|   PreviewMediaDialog, | ||||
| } from "@shared"; | ||||
|  | ||||
| export const CityEditPage = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); | ||||
|   const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); | ||||
|   const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); | ||||
|   const [mediaId, setMediaId] = useState(""); | ||||
|   const [activeMenuType, setActiveMenuType] = useState< | ||||
|     "thumbnail" | "watermark_lu" | "watermark_rd" | null | ||||
|   >(null); | ||||
|   const { language } = languageStore; | ||||
|   const { id } = useParams(); | ||||
|   const { editCityData, editCity, getCity, setEditCityData } = cityStore; | ||||
| @@ -49,20 +59,22 @@ export const CityEditPage = observer(() => { | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       if (id) { | ||||
|         const data = await getCity(id as string, language); | ||||
|         setEditCityData( | ||||
|           data.name, | ||||
|           data.country, | ||||
|           data.country_code, | ||||
|           data.arms, | ||||
|           language | ||||
|         ); | ||||
|         await getOneMedia(data.arms as string); | ||||
|         // Fetch data for all languages | ||||
|         const ruData = await getCity(id as string, "ru"); | ||||
|         const enData = await getCity(id as string, "en"); | ||||
|         const zhData = await getCity(id as string, "zh"); | ||||
|  | ||||
|         // Set data for each language | ||||
|         setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru"); | ||||
|         setEditCityData(enData.name, enData.country_code, enData.arms, "en"); | ||||
|         setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh"); | ||||
|  | ||||
|         await getOneMedia(ruData.arms as string); | ||||
|         await getCountries(language); | ||||
|         await getMedia(); | ||||
|       } | ||||
|     })(); | ||||
|   }, [id, language]); | ||||
|   }, [id]); | ||||
|  | ||||
|   const handleMediaSelect = (media: { | ||||
|     id: string; | ||||
| @@ -72,7 +84,6 @@ export const CityEditPage = observer(() => { | ||||
|   }) => { | ||||
|     setEditCityData( | ||||
|       editCityData[language].name, | ||||
|       editCityData.country, | ||||
|       editCityData.country_code, | ||||
|       media.id, | ||||
|       language | ||||
| @@ -97,6 +108,9 @@ export const CityEditPage = observer(() => { | ||||
|       </div> | ||||
|  | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <div className="flex gap-10 items-center mb-5 max-w-[80%]"> | ||||
|           <h1 className="text-3xl break-words">{editCityData.ru.name}</h1> | ||||
|         </div> | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Название" | ||||
| @@ -105,7 +119,6 @@ export const CityEditPage = observer(() => { | ||||
|           onChange={(e) => | ||||
|             setEditCityData( | ||||
|               e.target.value, | ||||
|               editCityData.country, | ||||
|               editCityData.country_code, | ||||
|               editCityData.arms, | ||||
|               language | ||||
| @@ -120,19 +133,15 @@ export const CityEditPage = observer(() => { | ||||
|             label="Страна" | ||||
|             required | ||||
|             onChange={(e) => { | ||||
|               const selectedCountry = countryStore.countries[language]?.find( | ||||
|                 (country) => country.code === e.target.value | ||||
|               ); | ||||
|               setEditCityData( | ||||
|                 editCityData[language as keyof CashedCities]?.name || "", | ||||
|                 selectedCountry?.name || "", | ||||
|                 editCityData[language].name, | ||||
|                 e.target.value, | ||||
|                 editCityData.arms, | ||||
|                 language | ||||
|               ); | ||||
|             }} | ||||
|           > | ||||
|             {countryStore.countries[language].map((country) => ( | ||||
|             {countryStore.countries[language].data.map((country) => ( | ||||
|               <MenuItem key={country.code} value={country.code}> | ||||
|                 {country.name} | ||||
|               </MenuItem> | ||||
| @@ -140,44 +149,33 @@ export const CityEditPage = observer(() => { | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|  | ||||
|         <div className="w-full flex flex-col gap-4"> | ||||
|           <label className="text-sm text-gray-600">Герб города</label> | ||||
|           <div className="flex items-center gap-4"> | ||||
|             <Button | ||||
|               variant="outlined" | ||||
|               onClick={() => setIsSelectMediaOpen(true)} | ||||
|               startIcon={<ImagePlus size={20} />} | ||||
|             > | ||||
|               Выбрать герб | ||||
|             </Button> | ||||
|             {selectedMedia && ( | ||||
|               <span className="text-sm text-gray-600"> | ||||
|                 {selectedMedia.media_name || selectedMedia.filename} | ||||
|               </span> | ||||
|             )} | ||||
|           </div> | ||||
|           {selectedMedia && ( | ||||
|             <Box | ||||
|               sx={{ | ||||
|                 width: "200px", | ||||
|                 height: "200px", | ||||
|                 border: "1px solid #e0e0e0", | ||||
|                 borderRadius: "8px", | ||||
|                 overflow: "hidden", | ||||
|                 display: "flex", | ||||
|                 alignItems: "center", | ||||
|                 justifyContent: "center", | ||||
|               }} | ||||
|             > | ||||
|               <MediaViewer | ||||
|                 media={{ | ||||
|                   id: selectedMedia.id, | ||||
|                   media_type: selectedMedia.media_type, | ||||
|                   filename: selectedMedia.filename, | ||||
|                 }} | ||||
|               /> | ||||
|             </Box> | ||||
|           )} | ||||
|         <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto"> | ||||
|           <ImageUploadCard | ||||
|             title="Герб города" | ||||
|             imageKey="thumbnail" | ||||
|             imageUrl={selectedMedia?.id} | ||||
|             onImageClick={() => { | ||||
|               setIsPreviewMediaOpen(true); | ||||
|               setMediaId(selectedMedia?.id ?? ""); | ||||
|             }} | ||||
|             onDeleteImageClick={() => { | ||||
|               setEditCityData( | ||||
|                 editCityData[language].name, | ||||
|                 editCityData.country_code, | ||||
|                 "", | ||||
|                 language | ||||
|               ); | ||||
|               setActiveMenuType(null); | ||||
|             }} | ||||
|             onSelectFileClick={() => { | ||||
|               setActiveMenuType("thumbnail"); | ||||
|               setIsSelectMediaOpen(true); | ||||
|             }} | ||||
|             setUploadMediaOpen={() => { | ||||
|               setIsUploadMediaOpen(true); | ||||
|               setActiveMenuType("thumbnail"); | ||||
|             }} | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <Button | ||||
| @@ -201,6 +199,20 @@ export const CityEditPage = observer(() => { | ||||
|         open={isSelectMediaOpen} | ||||
|         onClose={() => setIsSelectMediaOpen(false)} | ||||
|         onSelectMedia={handleMediaSelect} | ||||
|         mediaType={3} // Тип медиа для иконок | ||||
|       /> | ||||
|  | ||||
|       <UploadMediaDialog | ||||
|         open={isUploadMediaOpen} | ||||
|         onClose={() => setIsUploadMediaOpen(false)} | ||||
|         afterUpload={handleMediaSelect} | ||||
|         hardcodeType={activeMenuType} | ||||
|       /> | ||||
|  | ||||
|       <PreviewMediaDialog | ||||
|         open={isPreviewMediaOpen} | ||||
|         onClose={() => setIsPreviewMediaOpen(false)} | ||||
|         mediaId={mediaId} | ||||
|       /> | ||||
|     </Paper> | ||||
|   ); | ||||
|   | ||||
| @@ -2,9 +2,10 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { languageStore, cityStore, CashedCities } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Eye, Pencil, Trash2 } from "lucide-react"; | ||||
| import { Eye, Pencil, Trash2, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; | ||||
| import { toast } from "react-toastify"; | ||||
|  | ||||
| export const CityListPage = observer(() => { | ||||
|   const { cities, getCities, deleteCity } = cityStore; | ||||
| @@ -22,11 +23,33 @@ export const CityListPage = observer(() => { | ||||
|       field: "country", | ||||
|       headerName: "Страна", | ||||
|       width: 150, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "name", | ||||
|       headerName: "Название", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "actions", | ||||
| @@ -58,7 +81,7 @@ export const CityListPage = observer(() => { | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   const rows = cities[language].map((city) => ({ | ||||
|   const rows = cities[language]?.data?.map((city) => ({ | ||||
|     id: city.id, | ||||
|     name: city.name, | ||||
|     country: city.country, | ||||
| @@ -85,7 +108,8 @@ export const CityListPage = observer(() => { | ||||
|         open={isDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           if (rowId) { | ||||
|             deleteCity(rowId.toString(), language as keyof CashedCities); | ||||
|             await deleteCity(rowId.toString()); | ||||
|             toast.success("Город успешно удален"); | ||||
|           } | ||||
|           setIsDeleteModalOpen(false); | ||||
|           setRowId(null); | ||||
|   | ||||
| @@ -16,18 +16,18 @@ export const CityPreviewPage = observer(() => { | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       if (id) { | ||||
|         const cityResponse = await getCity(id as string, language); | ||||
|         setEditCityData( | ||||
|           cityResponse.name, | ||||
|           cityResponse.country, | ||||
|           cityResponse.country_code, | ||||
|           cityResponse.arms, | ||||
|           language | ||||
|         ); | ||||
|         await getOneMedia(cityResponse.arms as string); | ||||
|         const ruData = await getCity(id as string, "ru"); | ||||
|         const enData = await getCity(id as string, "en"); | ||||
|         const zhData = await getCity(id as string, "zh"); | ||||
|  | ||||
|         setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru"); | ||||
|         setEditCityData(enData.name, enData.country_code, enData.arms, "en"); | ||||
|         setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh"); | ||||
|  | ||||
|         await getOneMedia(ruData.arms as string); | ||||
|       } | ||||
|     })(); | ||||
|   }, [id, language]); | ||||
|   }, [id]); | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
|   | ||||
| @@ -41,6 +41,9 @@ export const CountryCreatePage = observer(() => { | ||||
|       </div> | ||||
|  | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <div className="flex gap-10 items-center mb-5 max-w-[80%]"> | ||||
|           <h1 className="text-3xl break-words">Создание страны</h1> | ||||
|         </div> | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Код страны" | ||||
|   | ||||
| @@ -31,11 +31,18 @@ export const CountryEditPage = observer(() => { | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       if (id) { | ||||
|         const data = await getCountry(id as string, language); | ||||
|         setEditCountryData(data.name, language); | ||||
|         // Fetch data for all languages | ||||
|         const ruData = await getCountry(id as string, "ru"); | ||||
|         const enData = await getCountry(id as string, "en"); | ||||
|         const zhData = await getCountry(id as string, "zh"); | ||||
|  | ||||
|         // Set data for each language | ||||
|         setEditCountryData(ruData.name, "ru"); | ||||
|         setEditCountryData(enData.name, "en"); | ||||
|         setEditCountryData(zhData.name, "zh"); | ||||
|       } | ||||
|     })(); | ||||
|   }, [id, language]); | ||||
|   }, [id]); | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
| @@ -51,6 +58,9 @@ export const CountryEditPage = observer(() => { | ||||
|       </div> | ||||
|  | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <div className="flex gap-10 items-center mb-5 max-w-[80%]"> | ||||
|           <h1 className="text-3xl break-words">{editCountryData.ru.name}</h1> | ||||
|         </div> | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Код страны" | ||||
|   | ||||
| @@ -2,15 +2,15 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { countryStore, languageStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Eye, Pencil, Trash2 } from "lucide-react"; | ||||
| import { Pencil, Trash2, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; | ||||
|  | ||||
| export const CountryListPage = observer(() => { | ||||
|   const { countries, getCountries } = countryStore; | ||||
|   const { countries, getCountries, deleteCountry } = countryStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<string | null>(null); // Lifted state | ||||
|   const [rowId, setRowId] = useState<string | null>(null); | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
| @@ -22,6 +22,17 @@ export const CountryListPage = observer(() => { | ||||
|       field: "name", | ||||
|       headerName: "Название", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center "> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "actions", | ||||
| @@ -37,9 +48,9 @@ export const CountryListPage = observer(() => { | ||||
|             > | ||||
|               <Pencil size={20} className="text-blue-500" /> | ||||
|             </button> | ||||
|             <button onClick={() => navigate(`/country/${params.row.code}`)}> | ||||
|             {/* <button onClick={() => navigate(`/country/${params.row.code}`)}> | ||||
|               <Eye size={20} className="text-green-500" /> | ||||
|             </button> | ||||
|             </button> */} | ||||
|             <button | ||||
|               onClick={() => { | ||||
|                 setIsDeleteModalOpen(true); | ||||
| @@ -54,7 +65,7 @@ export const CountryListPage = observer(() => { | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   const rows = countries[language]?.map((country) => ({ | ||||
|   const rows = countries[language]?.data.map((country) => ({ | ||||
|     id: country.code, | ||||
|     code: country.code, | ||||
|     name: country.name, | ||||
| @@ -75,17 +86,14 @@ export const CountryListPage = observer(() => { | ||||
|       <DeleteModal | ||||
|         open={isDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           if (rowId) { | ||||
|             await countryStore.deleteCountry(rowId, language); | ||||
|             getCountries(language); // Refresh the list after deletion | ||||
|             setIsDeleteModalOpen(false); | ||||
|           } | ||||
|           setIsDeleteModalOpen(false); | ||||
|           if (!rowId) return; | ||||
|           await deleteCountry(rowId, language); | ||||
|           setRowId(null); | ||||
|           setIsDeleteModalOpen(false); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsDeleteModalOpen(false); | ||||
|           setRowId(null); | ||||
|           setIsDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   | ||||
| @@ -15,11 +15,16 @@ export const CountryPreviewPage = observer(() => { | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       if (id) { | ||||
|         const data = await getCountry(id as string, language); | ||||
|         setEditCountryData(data.name, language); | ||||
|         const ruData = await getCountry(id as string, "ru"); | ||||
|         const enData = await getCountry(id as string, "en"); | ||||
|         const zhData = await getCountry(id as string, "zh"); | ||||
|  | ||||
|         setEditCountryData(ruData.name, "ru"); | ||||
|         setEditCountryData(enData.name, "en"); | ||||
|         setEditCountryData(zhData.name, "zh"); | ||||
|       } | ||||
|     })(); | ||||
|   }, [id, language]); | ||||
|   }, [id]); | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
| @@ -55,7 +60,7 @@ export const CountryPreviewPage = observer(() => { | ||||
|         <div className="flex flex-col gap-10 w-full"> | ||||
|           <div className="flex flex-col gap-2"> | ||||
|             <h1 className="text-lg font-bold">Название</h1> | ||||
|             <p>{country[id!]?.[language]?.name}</p> | ||||
|             <p>{country[id!]?.ru?.name}</p> | ||||
|           </div> | ||||
|         </div> | ||||
|       )} | ||||
|   | ||||
| @@ -38,13 +38,17 @@ export const EditSightPage = observer(() => { | ||||
|   useEffect(() => { | ||||
|     const fetchData = async () => { | ||||
|       if (id) { | ||||
|         await getSightInfo(+id, language); | ||||
|         await getArticles(language); | ||||
|         await getSightInfo(+id, "ru"); | ||||
|         await getSightInfo(+id, "en"); | ||||
|         await getSightInfo(+id, "zh"); | ||||
|         await getArticles("ru"); | ||||
|         await getArticles("en"); | ||||
|         await getArticles("zh"); | ||||
|         await getRuCities(); | ||||
|       } | ||||
|     }; | ||||
|     fetchData(); | ||||
|   }, [id, language]); | ||||
|   }, [id]); | ||||
|  | ||||
|   return ( | ||||
|     <Box | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import React, { | ||||
|   useState, | ||||
|   useCallback, | ||||
|   ReactNode, | ||||
|   useMemo, | ||||
| } from "react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { Map, View, Overlay, MapBrowserEvent } from "ol"; | ||||
| @@ -21,7 +22,7 @@ import { | ||||
|   Circle as CircleStyle, | ||||
|   RegularShape, | ||||
| } from "ol/style"; | ||||
| import { Point, LineString, Geometry } from "ol/geom"; | ||||
| import { Point, LineString, Geometry, Polygon } from "ol/geom"; | ||||
| import { transform } from "ol/proj"; | ||||
| import { GeoJSON } from "ol/format"; | ||||
| import { | ||||
| @@ -34,6 +35,9 @@ import { | ||||
|   Pencil, | ||||
|   Save, | ||||
|   Loader2, | ||||
|   Lasso, | ||||
|   InfoIcon, | ||||
|   X, // --- ИЗМЕНЕНО --- Импортируем иконку крестика | ||||
| } from "lucide-react"; | ||||
| import { toast } from "react-toastify"; | ||||
| import { singleClick, doubleClick } from "ol/events/condition"; | ||||
| @@ -123,6 +127,10 @@ class MapService { | ||||
|   private boundHandlePointerLeave: () => void; | ||||
|   private boundHandleContextMenu: (event: MouseEvent) => void; | ||||
|   private boundHandleKeyDown: (event: KeyboardEvent) => void; | ||||
|   private lassoInteraction: Draw | null = null; | ||||
|   private selectedIds: Set<string | number> = new Set(); | ||||
|   private onSelectionChange: ((ids: Set<string | number>) => void) | null = | ||||
|     null; | ||||
|  | ||||
|   // Styles | ||||
|   private defaultStyle: Style; | ||||
| @@ -152,7 +160,8 @@ class MapService { | ||||
|     onModeChangeCallback: (mode: string) => void, | ||||
|     onFeaturesChange: (features: Feature<Geometry>[]) => void, | ||||
|     onFeatureSelect: (feature: Feature<Geometry> | null) => void, | ||||
|     tooltipElement: HTMLElement | ||||
|     tooltipElement: HTMLElement, | ||||
|     onSelectionChange?: (ids: Set<string | number>) => void | ||||
|   ) { | ||||
|     this.map = null; | ||||
|     this.tooltipElement = tooltipElement; | ||||
| @@ -233,10 +242,10 @@ class MapService { | ||||
|     this.selectedSightIconStyle = new Style({ | ||||
|       image: new RegularShape({ | ||||
|         fill: new Fill({ color: "rgba(221, 107, 32, 0.9)" }), | ||||
|         stroke: new Stroke({ color: "#ffffff", width: 2.5 }), | ||||
|         stroke: new Stroke({ color: "#ffffff", width: 2 }), | ||||
|         points: 5, | ||||
|         radius: 14, | ||||
|         radius2: 7, | ||||
|         radius: 12, | ||||
|         radius2: 6, | ||||
|         angle: 0, | ||||
|       }), | ||||
|     }); | ||||
| @@ -291,6 +300,7 @@ class MapService { | ||||
|           .getArray() | ||||
|           .includes(feature); | ||||
|         const isHovered = this.hoveredFeatureId === fId; | ||||
|         const isLassoSelected = fId !== undefined && this.selectedIds.has(fId); | ||||
|  | ||||
|         if (geometryType === "Point") { | ||||
|           const defaultPointStyle = | ||||
| @@ -308,6 +318,28 @@ class MapService { | ||||
|               ? this.hoverSightIconStyle | ||||
|               : this.universalHoverStylePoint; | ||||
|           } | ||||
|  | ||||
|           if (isLassoSelected) { | ||||
|             let imageStyle; | ||||
|             if (featureType === "sight") { | ||||
|               imageStyle = new RegularShape({ | ||||
|                 fill: new Fill({ color: "#14b8a6" }), | ||||
|                 stroke: new Stroke({ color: "#fff", width: 2 }), | ||||
|                 points: 5, | ||||
|                 radius: 12, | ||||
|                 radius2: 6, | ||||
|                 angle: 0, | ||||
|               }); | ||||
|             } else { | ||||
|               imageStyle = new CircleStyle({ | ||||
|                 radius: 10, | ||||
|                 fill: new Fill({ color: "#14b8a6" }), | ||||
|                 stroke: new Stroke({ color: "#fff", width: 2 }), | ||||
|               }); | ||||
|             } | ||||
|             return new Style({ image: imageStyle, zIndex: Infinity }); | ||||
|           } | ||||
|  | ||||
|           return defaultPointStyle; | ||||
|         } else if (geometryType === "LineString") { | ||||
|           if (isEditSelected) { | ||||
| @@ -316,6 +348,12 @@ class MapService { | ||||
|           if (isHovered) { | ||||
|             return this.universalHoverStyleLine; | ||||
|           } | ||||
|           if (isLassoSelected) { | ||||
|             return new Style({ | ||||
|               stroke: new Stroke({ color: "#14b8a6", width: 6 }), | ||||
|               zIndex: Infinity, | ||||
|             }); | ||||
|           } | ||||
|           return this.defaultStyle; | ||||
|         } | ||||
|  | ||||
| @@ -482,11 +520,43 @@ class MapService { | ||||
|       this.updateFeaturesInReact(); | ||||
|     }); | ||||
|  | ||||
|     this.lassoInteraction = new Draw({ | ||||
|       type: "Polygon", | ||||
|       style: new Style({ | ||||
|         stroke: new Stroke({ color: "#14b8a6", width: 2 }), | ||||
|         fill: new Fill({ color: "rgba(20, 184, 166, 0.1)" }), | ||||
|       }), | ||||
|     }); | ||||
|     this.lassoInteraction.setActive(false); | ||||
|     this.lassoInteraction.on("drawend", (event: DrawEvent) => { | ||||
|       const geometry = event.feature.getGeometry() as Polygon; | ||||
|       const extent = geometry.getExtent(); | ||||
|       const selected = new Set<string | number>(); | ||||
|  | ||||
|       this.vectorSource.forEachFeatureInExtent(extent, (f) => { | ||||
|         const geom = f.getGeometry(); | ||||
|         if (geom && geom.getType() === "Point") { | ||||
|           const pointCoords = (geom as Point).getCoordinates(); | ||||
|           if (geometry.intersectsCoordinate(pointCoords)) { | ||||
|             if (f.getId() !== undefined) selected.add(f.getId()!); | ||||
|           } | ||||
|         } else if (geom && geom.intersectsExtent(extent)) { | ||||
|           // For lines/polygons | ||||
|           if (f.getId() !== undefined) selected.add(f.getId()!); | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       this.setSelectedIds(selected); | ||||
|       this.deactivateLasso(); | ||||
|     }); | ||||
|  | ||||
|     if (this.map) { | ||||
|       this.map.addInteraction(this.modifyInteraction); | ||||
|       this.map.addInteraction(this.selectInteraction); | ||||
|       this.map.addInteraction(this.lassoInteraction); | ||||
|       this.modifyInteraction.setActive(false); | ||||
|       this.selectInteraction.setActive(false); | ||||
|       this.lassoInteraction.setActive(false); | ||||
|  | ||||
|       this.selectInteraction.on("select", (e: SelectEvent) => { | ||||
|         if (this.mode === "edit") { | ||||
| @@ -508,6 +578,20 @@ class MapService { | ||||
|       document.addEventListener("keydown", this.boundHandleKeyDown); | ||||
|       this.activateEditMode(); | ||||
|     } | ||||
|  | ||||
|     if (onSelectionChange) { | ||||
|       this.onSelectionChange = onSelectionChange; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // --- ИЗМЕНЕНО --- Добавляем новый публичный метод для сброса выделения | ||||
|   public unselect(): void { | ||||
|     // Сбрасываем основное (одиночное) выделение | ||||
|     this.selectInteraction.getFeatures().clear(); | ||||
|     this.onFeatureSelect(null); // Оповещаем React | ||||
|  | ||||
|     // Сбрасываем множественное выделение | ||||
|     this.setSelectedIds(new Set()); // Это вызовет onSelectionChange и перерисовку | ||||
|   } | ||||
|  | ||||
|   public saveMapState(): void { | ||||
| @@ -671,14 +755,7 @@ class MapService { | ||||
|       return; | ||||
|     } | ||||
|     if (event.key === "Escape") { | ||||
|       if (this.mode && this.mode.startsWith("drawing-")) this.finishDrawing(); | ||||
|       else if ( | ||||
|         this.mode === "edit" && | ||||
|         this.selectInteraction?.getFeatures().getLength() > 0 | ||||
|       ) { | ||||
|         this.selectInteraction.getFeatures().clear(); | ||||
|         this.onFeatureSelect(null); | ||||
|       } | ||||
|       this.unselect(); // --- ИЗМЕНЕНО --- Esc теперь тоже сбрасывает все выделения | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -907,6 +984,38 @@ class MapService { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public handleMapClick(event: MapBrowserEvent<any>, ctrlKey: boolean): void { | ||||
|     if (!this.map) return; | ||||
|  | ||||
|     const pixel = this.map.getEventPixel(event.originalEvent); | ||||
|     const featureAtPixel: Feature<Geometry> | undefined = | ||||
|       this.map.forEachFeatureAtPixel( | ||||
|         pixel, | ||||
|         (f: FeatureLike) => f as Feature<Geometry>, | ||||
|         { layerFilter: (l) => l === this.vectorLayer, hitTolerance: 5 } | ||||
|       ); | ||||
|  | ||||
|     if (!featureAtPixel) return; | ||||
|  | ||||
|     const featureId = featureAtPixel.getId(); | ||||
|     if (featureId === undefined) return; | ||||
|  | ||||
|     if (ctrlKey) { | ||||
|       const newSet = new Set(this.selectedIds); | ||||
|       if (newSet.has(featureId)) { | ||||
|         newSet.delete(featureId); | ||||
|       } else { | ||||
|         newSet.add(featureId); | ||||
|       } | ||||
|       this.setSelectedIds(newSet); | ||||
|       this.vectorLayer.changed(); | ||||
|     } else { | ||||
|       this.selectFeature(featureId); | ||||
|       const newSet = new Set<string | number>([featureId]); | ||||
|       this.setSelectedIds(newSet); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public selectFeature(featureId: string | number | undefined): void { | ||||
|     if (!this.map || featureId === undefined) { | ||||
|       this.onFeatureSelect(null); | ||||
| @@ -975,12 +1084,9 @@ class MapService { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // --- НОВОЕ --- | ||||
|   // Метод для множественного удаления объектов по их ID | ||||
|   public deleteMultipleFeatures(featureIds: (string | number)[]): void { | ||||
|     if (!featureIds || featureIds.length === 0) return; | ||||
|  | ||||
|     // Вывод в консоль по требованию | ||||
|     console.log("Запрос на множественное удаление. ID объектов:", featureIds); | ||||
|  | ||||
|     const currentState = this.getCurrentStateAsGeoJSON(); | ||||
| @@ -994,26 +1100,22 @@ class MapService { | ||||
|     featureIds.forEach((id) => { | ||||
|       const feature = this.vectorSource.getFeatureById(id); | ||||
|       if (feature) { | ||||
|         // Удаление из "бэкенда"/стора для каждого объекта | ||||
|         const recourse = String(id).split("-")[0]; | ||||
|         const numericId = String(id).split("-")[1]; | ||||
|         if (recourse && numericId) { | ||||
|           mapStore.deleteRecourse(recourse, Number(numericId)); | ||||
|         } | ||||
|  | ||||
|         // Если удаляемый объект выбран для редактирования, убираем его из выделения | ||||
|         if (selectedFeaturesCollection?.getArray().includes(feature)) { | ||||
|           selectedFeaturesCollection.remove(feature); | ||||
|         } | ||||
|  | ||||
|         // Удаляем объект с карты | ||||
|         this.vectorSource.removeFeature(feature); | ||||
|         deletedCount++; | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     if (deletedCount > 0) { | ||||
|       // Если основное выделение стало пустым, оповещаем React | ||||
|       if (selectedFeaturesCollection?.getLength() === 0) { | ||||
|         this.onFeatureSelect(null); | ||||
|       } | ||||
| @@ -1075,17 +1177,62 @@ class MapService { | ||||
|     if (!event.feature) return; | ||||
|     this.updateFeaturesInReact(); | ||||
|   } | ||||
|  | ||||
|   public activateLasso() { | ||||
|     if (this.lassoInteraction && this.map) { | ||||
|       this.lassoInteraction.setActive(true); | ||||
|       this.setMode("lasso"); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public deactivateLasso() { | ||||
|     if (this.lassoInteraction && this.map) { | ||||
|       this.lassoInteraction.setActive(false); | ||||
|       this.setMode("edit"); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public setSelectedIds(ids: Set<string | number>) { | ||||
|     this.selectedIds = new Set(ids); | ||||
|     if (this.onSelectionChange) this.onSelectionChange(this.selectedIds); | ||||
|     this.vectorLayer.changed(); | ||||
|   } | ||||
|  | ||||
|   public getSelectedIds() { | ||||
|     return new Set(this.selectedIds); | ||||
|   } | ||||
|  | ||||
|   public setOnSelectionChange(cb: (ids: Set<string | number>) => void) { | ||||
|     this.onSelectionChange = cb; | ||||
|   } | ||||
|  | ||||
|   public toggleLasso() { | ||||
|     if (this.mode === "lasso") { | ||||
|       this.deactivateLasso(); | ||||
|     } else { | ||||
|       this.activateLasso(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public getMap(): Map | null { | ||||
|     return this.map; | ||||
|   } | ||||
| } | ||||
|  | ||||
| // --- MAP CONTROLS COMPONENT --- | ||||
| // --- ИЗМЕНЕНО --- Добавляем проп isUnselectDisabled | ||||
| interface MapControlsProps { | ||||
|   mapService: MapService | null; | ||||
|   activeMode: string; | ||||
|   isLassoActive: boolean; | ||||
|   isUnselectDisabled: boolean; | ||||
| } | ||||
|  | ||||
| const MapControls: React.FC<MapControlsProps> = ({ | ||||
|   mapService, | ||||
|   activeMode, | ||||
|   isLassoActive, | ||||
|   isUnselectDisabled, // --- ИЗМЕНЕНО --- | ||||
| }) => { | ||||
|   if (!mapService) return null; | ||||
|  | ||||
| @@ -1118,24 +1265,53 @@ const MapControls: React.FC<MapControlsProps> = ({ | ||||
|       icon: <LineIconSvg />, | ||||
|       action: () => mapService.startDrawingLine(), | ||||
|     }, | ||||
|     // { | ||||
|     //   mode: "lasso", | ||||
|     //   title: "Выделение", | ||||
|     //   longTitle: "Выделение области (или зажмите Shift)", | ||||
|     //   icon: <Lasso size={16} className="mr-1 sm:mr-2" />, | ||||
|     //   action: () => mapService.toggleLasso(), | ||||
|     //   isActive: isLassoActive, | ||||
|     // }, | ||||
|     // --- ИЗМЕНЕНО --- Добавляем кнопку сброса | ||||
|     { | ||||
|       mode: "unselect", | ||||
|       title: "Сбросить", | ||||
|       longTitle: "Сбросить выделение (Esc)", | ||||
|       icon: <X size={16} className="mr-1 sm:mr-2" />, | ||||
|       action: () => mapService.unselect(), | ||||
|       disabled: isUnselectDisabled, | ||||
|     }, | ||||
|   ]; | ||||
|   return ( | ||||
|     <div className="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex flex-nowrap justify-center p-2 bg-white/90 backdrop-blur-sm rounded-lg shadow-xl space-x-1 sm:space-x-2"> | ||||
|       {controls.map((c) => ( | ||||
|         <button | ||||
|           key={c.mode} | ||||
|           className={`flex items-center px-3 py-2 rounded-md transition-all duration-200 text-xs sm:text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 focus:ring-opacity-75 ${ | ||||
|             activeMode === c.mode | ||||
|               ? "bg-blue-600 text-white shadow-md hover:bg-blue-700" | ||||
|               : "bg-gray-100 text-gray-700 hover:bg-blue-100 hover:text-blue-700" | ||||
|           }`} | ||||
|           onClick={c.action} | ||||
|           title={c.longTitle} | ||||
|         > | ||||
|           {c.icon} | ||||
|           <span className="hidden sm:inline ml-0 sm:ml-1">{c.title}</span> | ||||
|         </button> | ||||
|       ))} | ||||
|       {controls.map((c) => { | ||||
|         // --- ИЗМЕНЕНО --- Определяем классы в зависимости от состояния | ||||
|         const isActive = | ||||
|           c.isActive !== undefined ? c.isActive : activeMode === c.mode; | ||||
|         const isDisabled = c.disabled; | ||||
|  | ||||
|         const buttonClasses = `flex items-center px-3 py-2 rounded-md transition-all duration-200 text-xs sm:text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 focus:ring-opacity-75 ${ | ||||
|           isDisabled | ||||
|             ? "bg-gray-200 text-gray-400 cursor-not-allowed" | ||||
|             : isActive | ||||
|             ? "bg-blue-600 text-white shadow-md hover:bg-blue-700" | ||||
|             : "bg-gray-100 text-gray-700 hover:bg-blue-100 hover:text-blue-700" | ||||
|         }`; | ||||
|  | ||||
|         return ( | ||||
|           <button | ||||
|             key={c.mode} | ||||
|             className={buttonClasses} | ||||
|             onClick={c.action} | ||||
|             title={c.longTitle} | ||||
|             disabled={isDisabled} | ||||
|           > | ||||
|             {c.icon} | ||||
|             <span className="hidden sm:inline ml-0 sm:ml-1">{c.title}</span> | ||||
|           </button> | ||||
|         ); | ||||
|       })} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -1145,24 +1321,34 @@ interface MapSightbarProps { | ||||
|   mapService: MapService | null; | ||||
|   mapFeatures: Feature<Geometry>[]; | ||||
|   selectedFeature: Feature<Geometry> | null; | ||||
|   selectedIds: Set<string | number>; | ||||
|   setSelectedIds: (ids: Set<string | number>) => void; | ||||
|   activeSection: string | null; | ||||
|   setActiveSection: (section: string | null) => void; | ||||
| } | ||||
|  | ||||
| const MapSightbar: React.FC<MapSightbarProps> = ({ | ||||
|   mapService, | ||||
|   mapFeatures, | ||||
|   selectedFeature, | ||||
|   selectedIds, | ||||
|   setSelectedIds, | ||||
|   activeSection, | ||||
|   setActiveSection, | ||||
| }) => { | ||||
|   const [activeSection, setActiveSection] = useState<string | null>("layers"); | ||||
|   const navigate = useNavigate(); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   // --- НОВОЕ --- | ||||
|   // Состояние для хранения ID объектов, выбранных для удаления | ||||
|   const [selectedForDeletion, setSelectedForDeletion] = useState< | ||||
|     Set<string | number> | ||||
|   >(new Set()); | ||||
|   const [searchQuery, setSearchQuery] = useState(""); | ||||
|  | ||||
|   const toggleSection = (id: string) => | ||||
|     setActiveSection(activeSection === id ? null : id); | ||||
|   const filteredFeatures = useMemo(() => { | ||||
|     if (!searchQuery.trim()) { | ||||
|       return mapFeatures; | ||||
|     } | ||||
|     return mapFeatures.filter((feature) => { | ||||
|       const name = (feature.get("name") as string) || ""; | ||||
|       return name.toLowerCase().includes(searchQuery.toLowerCase()); | ||||
|     }); | ||||
|   }, [mapFeatures, searchQuery]); | ||||
|  | ||||
|   const handleFeatureClick = useCallback( | ||||
|     (id: string | number | undefined) => { | ||||
| @@ -1184,39 +1370,33 @@ const MapSightbar: React.FC<MapSightbarProps> = ({ | ||||
|     [mapService] | ||||
|   ); | ||||
|  | ||||
|   // --- НОВОЕ --- | ||||
|   // Обработчик изменения состояния чекбокса | ||||
|   const handleCheckboxChange = useCallback( | ||||
|     (id: string | number | undefined) => { | ||||
|       if (id === undefined) return; | ||||
|       setSelectedForDeletion((prev) => { | ||||
|         const newSet = new Set(prev); | ||||
|         if (newSet.has(id)) { | ||||
|           newSet.delete(id); | ||||
|         } else { | ||||
|           newSet.add(id); | ||||
|         } | ||||
|         return newSet; | ||||
|       }); | ||||
|       const newSet = new Set(selectedIds); | ||||
|       if (newSet.has(id)) { | ||||
|         newSet.delete(id); | ||||
|       } else { | ||||
|         newSet.add(id); | ||||
|       } | ||||
|       setSelectedIds(newSet); | ||||
|     }, | ||||
|     [] | ||||
|     [selectedIds, setSelectedIds] | ||||
|   ); | ||||
|  | ||||
|   // --- НОВОЕ --- | ||||
|   // Обработчик для запуска множественного удаления | ||||
|   const handleBulkDelete = useCallback(() => { | ||||
|     if (!mapService || selectedForDeletion.size === 0) return; | ||||
|     if (!mapService || selectedIds.size === 0) return; | ||||
|  | ||||
|     if ( | ||||
|       window.confirm( | ||||
|         `Вы уверены, что хотите удалить ${selectedForDeletion.size} объект(ов)? Это действие нельзя отменить.` | ||||
|         `Вы уверены, что хотите удалить ${selectedIds.size} объект(ов)? Это действие нельзя отменить.` | ||||
|       ) | ||||
|     ) { | ||||
|       const idsToDelete = Array.from(selectedForDeletion); | ||||
|       const idsToDelete = Array.from(selectedIds); | ||||
|       mapService.deleteMultipleFeatures(idsToDelete); | ||||
|       setSelectedForDeletion(new Set()); // Очищаем выбор после удаления | ||||
|       setSelectedIds(new Set()); | ||||
|     } | ||||
|   }, [mapService, selectedForDeletion]); | ||||
|   }, [mapService, selectedIds, setSelectedIds]); | ||||
|  | ||||
|   const handleEditFeature = useCallback( | ||||
|     (featureType: string | undefined, fullId: string | number | undefined) => { | ||||
| @@ -1243,51 +1423,83 @@ const MapSightbar: React.FC<MapSightbarProps> = ({ | ||||
|     setIsLoading(false); | ||||
|   }, [mapService]); | ||||
|  | ||||
|   const stations = mapFeatures.filter( | ||||
|   function sortFeatures( | ||||
|     features: Feature<Geometry>[], | ||||
|     selectedIds: Set<string | number>, | ||||
|     selectedFeature: Feature<Geometry> | null | ||||
|   ) { | ||||
|     const selectedId = selectedFeature?.getId(); | ||||
|     return features.slice().sort((a, b) => { | ||||
|       const aId = a.getId(); | ||||
|       const bId = b.getId(); | ||||
|       if (selectedId && aId === selectedId) return -1; | ||||
|       if (selectedId && bId === selectedId) return 1; | ||||
|       const aSelected = selectedIds.has(aId!); | ||||
|       const bSelected = selectedIds.has(bId!); | ||||
|       if (aSelected && !bSelected) return -1; | ||||
|       if (!aSelected && bSelected) return 1; | ||||
|       const aName = (a.get("name") as string) || ""; | ||||
|       const bName = (b.get("name") as string) || ""; | ||||
|       return aName.localeCompare(bName, "ru"); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   const toggleSection = (id: string) => | ||||
|     setActiveSection(activeSection === id ? null : id); | ||||
|  | ||||
|   const stations = (filteredFeatures || []).filter( | ||||
|     (f) => | ||||
|       f.get("featureType") === "station" || | ||||
|       (f.getGeometry()?.getType() === "Point" && !f.get("featureType")) | ||||
|   ); | ||||
|   const lines = mapFeatures.filter( | ||||
|   const lines = (filteredFeatures || []).filter( | ||||
|     (f) => | ||||
|       f.get("featureType") === "route" || | ||||
|       (f.getGeometry()?.getType() === "LineString" && !f.get("featureType")) | ||||
|   ); | ||||
|   const sights = mapFeatures.filter((f) => f.get("featureType") === "sight"); | ||||
|   const sights = (filteredFeatures || []).filter( | ||||
|     (f) => f.get("featureType") === "sight" | ||||
|   ); | ||||
|  | ||||
|   const sortedStations = sortFeatures(stations, selectedIds, selectedFeature); | ||||
|   const sortedLines = sortFeatures(lines, selectedIds, selectedFeature); | ||||
|   const sortedSights = sortFeatures(sights, selectedIds, selectedFeature); | ||||
|  | ||||
|   interface SidebarSection { | ||||
|     id: string; | ||||
|     title: string; | ||||
|     icon: ReactNode; | ||||
|     content: ReactNode; | ||||
|     count: number; | ||||
|   } | ||||
|  | ||||
|   const sections: SidebarSection[] = [ | ||||
|     { | ||||
|       id: "layers", | ||||
|       title: `Остановки (${stations.length})`, | ||||
|       title: `Остановки (${sortedStations.length})`, | ||||
|       icon: <Bus size={20} />, | ||||
|       count: sortedStations.length, | ||||
|       content: ( | ||||
|         <div className="space-y-1 max-h-[500px] overflow-y-auto pr-1"> | ||||
|           {stations.length > 0 ? ( | ||||
|             stations.map((s) => { | ||||
|           {sortedStations.length > 0 ? ( | ||||
|             sortedStations.map((s) => { | ||||
|               const sId = s.getId(); | ||||
|               const sName = (s.get("name") as string) || "Без названия"; | ||||
|               const isSelected = selectedFeature?.getId() === sId; | ||||
|               // --- ИЗМЕНЕНИЕ --- | ||||
|               const isCheckedForDeletion = | ||||
|                 sId !== undefined && selectedForDeletion.has(sId); | ||||
|                 sId !== undefined && selectedIds.has(sId); | ||||
|  | ||||
|               return ( | ||||
|                 <div | ||||
|                   key={String(sId)} | ||||
|                   data-feature-id={sId} | ||||
|                   data-feature-type="station" | ||||
|                   className={`flex items-start p-2 rounded-md group transition-colors duration-150 ${ | ||||
|                     isSelected | ||||
|                       ? "bg-orange-100 border border-orange-300" | ||||
|                       : "hover:bg-blue-50" | ||||
|                   }`} | ||||
|                 > | ||||
|                   {/* --- НОВОЕ: Чекбокс для множественного выбора --- */} | ||||
|                   <div className="flex-shrink-0 pr-2 pt-1"> | ||||
|                     <input | ||||
|                       type="checkbox" | ||||
| @@ -1357,17 +1569,18 @@ const MapSightbar: React.FC<MapSightbarProps> = ({ | ||||
|     }, | ||||
|     { | ||||
|       id: "lines", | ||||
|       title: `Маршруты (${lines.length})`, | ||||
|       title: `Маршруты (${sortedLines.length})`, | ||||
|       icon: <RouteIcon size={20} />, | ||||
|       count: sortedLines.length, | ||||
|       content: ( | ||||
|         <div className="space-y-1 max-h-60 overflow-y-auto pr-1"> | ||||
|           {lines.length > 0 ? ( | ||||
|             lines.map((l) => { | ||||
|           {sortedLines.length > 0 ? ( | ||||
|             sortedLines.map((l) => { | ||||
|               const lId = l.getId(); | ||||
|               const lName = (l.get("name") as string) || "Без названия"; | ||||
|               const isSelected = selectedFeature?.getId() === lId; | ||||
|               const isCheckedForDeletion = | ||||
|                 lId !== undefined && selectedForDeletion.has(lId); | ||||
|                 lId !== undefined && selectedIds.has(lId); | ||||
|               const lGeom = l.getGeometry(); | ||||
|               let lineLengthText: string | null = null; | ||||
|               if (lGeom instanceof LineString) { | ||||
| @@ -1378,6 +1591,8 @@ const MapSightbar: React.FC<MapSightbarProps> = ({ | ||||
|               return ( | ||||
|                 <div | ||||
|                   key={String(lId)} | ||||
|                   data-feature-id={lId} | ||||
|                   data-feature-type="route" | ||||
|                   className={`flex items-start p-2 rounded-md group transition-colors duration-150 ${ | ||||
|                     isSelected | ||||
|                       ? "bg-orange-100 border border-orange-300" | ||||
| @@ -1457,20 +1672,23 @@ const MapSightbar: React.FC<MapSightbarProps> = ({ | ||||
|     }, | ||||
|     { | ||||
|       id: "sights", | ||||
|       title: `Достопримечательности (${sights.length})`, | ||||
|       title: `Достопримечательности (${sortedSights.length})`, | ||||
|       icon: <Landmark size={20} />, | ||||
|       count: sortedSights.length, | ||||
|       content: ( | ||||
|         <div className="space-y-1 max-h-60 overflow-y-auto pr-1"> | ||||
|           {sights.length > 0 ? ( | ||||
|             sights.map((s) => { | ||||
|           {sortedSights.length > 0 ? ( | ||||
|             sortedSights.map((s) => { | ||||
|               const sId = s.getId(); | ||||
|               const sName = (s.get("name") as string) || "Без названия"; | ||||
|               const isSelected = selectedFeature?.getId() === sId; | ||||
|               const isCheckedForDeletion = | ||||
|                 sId !== undefined && selectedForDeletion.has(sId); | ||||
|                 sId !== undefined && selectedIds.has(sId); | ||||
|               return ( | ||||
|                 <div | ||||
|                   key={String(sId)} | ||||
|                   data-feature-id={sId} | ||||
|                   data-feature-type="sight" | ||||
|                   className={`flex items-start p-2 rounded-md group transition-colors duration-150 ${ | ||||
|                     isSelected | ||||
|                       ? "bg-orange-100 border border-orange-300" | ||||
| @@ -1555,69 +1773,89 @@ const MapSightbar: React.FC<MapSightbarProps> = ({ | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     // --- ИЗМЕНЕНИЕ: Реструктуризация для футера с кнопками --- | ||||
|     <div className="w-72 relative md:w-80 bg-gray-50 shadow-lg flex flex-col border-l border-gray-200 h-[90vh]"> | ||||
|     <div className="w-[360px] relative bg-gray-50 shadow-lg flex flex-col border-l border-gray-200 h-[90vh]"> | ||||
|       <div className="p-4 bg-gray-700 text-white"> | ||||
|         <h2 className="text-lg font-semibold">Панель управления</h2> | ||||
|       </div> | ||||
|  | ||||
|       <div className="p-3 border-b border-gray-200 bg-white"> | ||||
|         <input | ||||
|           type="text" | ||||
|           placeholder="Поиск по названию..." | ||||
|           value={searchQuery} | ||||
|           onChange={(e) => setSearchQuery(e.target.value)} | ||||
|           className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" | ||||
|         /> | ||||
|       </div> | ||||
|  | ||||
|       <div className="flex-1 flex flex-col min-h-0"> | ||||
|         <div className="flex-1 overflow-y-auto"> | ||||
|           {sections.map((s) => ( | ||||
|             <div | ||||
|               key={s.id} | ||||
|               className="border-b border-gray-200 last:border-b-0" | ||||
|             > | ||||
|               <button | ||||
|                 onClick={() => toggleSection(s.id)} | ||||
|                 className={`w-full p-3 flex items-center justify-between text-left hover:bg-gray-100 transition-colors duration-150 focus:outline-none focus:bg-gray-100 ${ | ||||
|                   activeSection === s.id | ||||
|                     ? "bg-gray-100 text-blue-600" | ||||
|                     : "text-gray-700" | ||||
|                 }`} | ||||
|               > | ||||
|                 <div className="flex items-center space-x-3"> | ||||
|                   <span | ||||
|                     className={ | ||||
|                       activeSection === s.id ? "text-blue-600" : "text-gray-600" | ||||
|                     } | ||||
|                   > | ||||
|                     {s.icon} | ||||
|                   </span> | ||||
|                   <span className="font-medium text-sm">{s.title}</span> | ||||
|                 </div> | ||||
|                 <span | ||||
|                   className={`transform transition-transform duration-200 text-gray-500 ${ | ||||
|                     activeSection === s.id ? "rotate-180" : "" | ||||
|                   }`} | ||||
|                 > | ||||
|                   ▼ | ||||
|                 </span> | ||||
|               </button> | ||||
|               <div | ||||
|                 className={`overflow-hidden transition-all duration-300 ease-in-out ${ | ||||
|                   activeSection === s.id | ||||
|                     ? "max-h-[600px] opacity-100" | ||||
|                     : "max-h-0 opacity-0" | ||||
|                 }`} | ||||
|               > | ||||
|                 <div className="p-3 text-sm text-gray-600 bg-white border-t border-gray-100"> | ||||
|                   {s.content} | ||||
|                 </div> | ||||
|               </div> | ||||
|           {filteredFeatures.length === 0 && searchQuery ? ( | ||||
|             <div className="p-4 text-center text-gray-500"> | ||||
|               Ничего не найдено. | ||||
|             </div> | ||||
|           ))} | ||||
|           ) : ( | ||||
|             sections.map( | ||||
|               (s) => | ||||
|                 (s.count > 0 || !searchQuery) && ( | ||||
|                   <div | ||||
|                     key={s.id} | ||||
|                     className="border-b border-gray-200 last:border-b-0" | ||||
|                   > | ||||
|                     <button | ||||
|                       onClick={() => toggleSection(s.id)} | ||||
|                       className={`w-full p-3 flex items-center justify-between text-left hover:bg-gray-100 transition-colors duration-150 focus:outline-none focus:bg-gray-100 ${ | ||||
|                         activeSection === s.id | ||||
|                           ? "bg-gray-100 text-blue-600" | ||||
|                           : "text-gray-700" | ||||
|                       }`} | ||||
|                     > | ||||
|                       <div className="flex items-center space-x-3"> | ||||
|                         <span | ||||
|                           className={ | ||||
|                             activeSection === s.id | ||||
|                               ? "text-blue-600" | ||||
|                               : "text-gray-600" | ||||
|                           } | ||||
|                         > | ||||
|                           {s.icon} | ||||
|                         </span> | ||||
|                         <span className="font-medium text-sm">{s.title}</span> | ||||
|                       </div> | ||||
|                       <span | ||||
|                         className={`transform transition-transform duration-200 text-gray-500 ${ | ||||
|                           activeSection === s.id ? "rotate-180" : "" | ||||
|                         }`} | ||||
|                       > | ||||
|                         ▼ | ||||
|                       </span> | ||||
|                     </button> | ||||
|                     <div | ||||
|                       className={`overflow-hidden transition-all duration-300 ease-in-out ${ | ||||
|                         activeSection === s.id | ||||
|                           ? "max-h-[600px] opacity-100" | ||||
|                           : "max-h-0 opacity-0" | ||||
|                       }`} | ||||
|                     > | ||||
|                       <div className="p-3 text-sm text-gray-600 bg-white border-t border-gray-100"> | ||||
|                         {s.content} | ||||
|                       </div> | ||||
|                     </div> | ||||
|                   </div> | ||||
|                 ) | ||||
|             ) | ||||
|           )} | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       {/* --- НОВОЕ: Футер сайдбара с кнопками действий --- */} | ||||
|       <div className="p-3 border-t border-gray-200 bg-gray-50/95 space-y-2"> | ||||
|         {selectedForDeletion.size > 0 && ( | ||||
|         {selectedIds.size > 0 && ( | ||||
|           <button | ||||
|             onClick={handleBulkDelete} | ||||
|             className="w-full flex items-center justify-center px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors" | ||||
|           > | ||||
|             <Trash2 size={16} className="mr-2" /> | ||||
|             Удалить выбранное ({selectedForDeletion.size}) | ||||
|             Удалить выбранное ({selectedIds.size}) | ||||
|           </button> | ||||
|         )} | ||||
|         <button | ||||
| @@ -1650,6 +1888,14 @@ export const MapPage: React.FC = () => { | ||||
|   const [mapFeatures, setMapFeatures] = useState<Feature<Geometry>[]>([]); | ||||
|   const [selectedFeatureForSidebar, setSelectedFeatureForSidebar] = | ||||
|     useState<Feature<Geometry> | null>(null); | ||||
|   const [selectedIds, setSelectedIds] = useState<Set<string | number>>( | ||||
|     new Set() | ||||
|   ); | ||||
|   const [isLassoActive, setIsLassoActive] = useState<boolean>(false); | ||||
|   const [showHelp, setShowHelp] = useState<boolean>(false); | ||||
|   const [activeSectionFromParent, setActiveSectionFromParent] = useState< | ||||
|     string | null | ||||
|   >("layers"); | ||||
|  | ||||
|   const handleFeaturesChange = useCallback( | ||||
|     (feats: Feature<Geometry>[]) => setMapFeatures([...feats]), | ||||
| @@ -1658,10 +1904,42 @@ export const MapPage: React.FC = () => { | ||||
|   const handleFeatureSelectForSidebar = useCallback( | ||||
|     (feat: Feature<Geometry> | null) => { | ||||
|       setSelectedFeatureForSidebar(feat); | ||||
|  | ||||
|       if (feat) { | ||||
|         const featureType = feat.get("featureType"); | ||||
|         const sectionId = | ||||
|           featureType === "sight" | ||||
|             ? "sights" | ||||
|             : featureType === "route" | ||||
|             ? "lines" | ||||
|             : "layers"; | ||||
|  | ||||
|         setActiveSectionFromParent(sectionId); | ||||
|  | ||||
|         setTimeout(() => { | ||||
|           const element = document.querySelector( | ||||
|             `[data-feature-id="${feat.getId()}"]` | ||||
|           ); | ||||
|           if (element) { | ||||
|             element.scrollIntoView({ behavior: "smooth", block: "center" }); | ||||
|           } | ||||
|         }, 100); | ||||
|       } | ||||
|     }, | ||||
|     [] | ||||
|   ); | ||||
|  | ||||
|   const handleMapClick = useCallback( | ||||
|     (event: any) => { | ||||
|       if (!mapServiceInstance || isLassoActive) return; | ||||
|  | ||||
|       const ctrlKey = | ||||
|         event.originalEvent.ctrlKey || event.originalEvent.metaKey; | ||||
|       mapServiceInstance.handleMapClick(event, ctrlKey); | ||||
|     }, | ||||
|     [mapServiceInstance, isLassoActive] | ||||
|   ); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     let service: MapService | null = null; | ||||
|     if (mapRef.current && tooltipRef.current && !mapServiceInstance) { | ||||
| @@ -1698,7 +1976,8 @@ export const MapPage: React.FC = () => { | ||||
|           setCurrentMapMode, | ||||
|           handleFeaturesChange, | ||||
|           handleFeatureSelectForSidebar, | ||||
|           tooltipRef.current | ||||
|           tooltipRef.current, | ||||
|           setSelectedIds | ||||
|         ); | ||||
|         setMapServiceInstance(service); | ||||
|  | ||||
| @@ -1720,12 +1999,71 @@ export const MapPage: React.FC = () => { | ||||
|         setMapServiceInstance(null); | ||||
|       } | ||||
|     }; | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps | ||||
|   }, []); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (mapServiceInstance) { | ||||
|       const olMap = mapServiceInstance.getMap(); | ||||
|       if (olMap) { | ||||
|         olMap.on("click", handleMapClick); | ||||
|  | ||||
|         return () => { | ||||
|           if (olMap) { | ||||
|             olMap.un("click", handleMapClick); | ||||
|           } | ||||
|         }; | ||||
|       } | ||||
|     } | ||||
|   }, [mapServiceInstance, handleMapClick]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (mapServiceInstance) { | ||||
|       mapServiceInstance.setOnSelectionChange(setSelectedIds); | ||||
|     } | ||||
|   }, [mapServiceInstance]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const handleKeyDown = (e: KeyboardEvent) => { | ||||
|       if (e.key === "Shift" && mapServiceInstance) { | ||||
|         mapServiceInstance.activateLasso(); | ||||
|         setIsLassoActive(true); | ||||
|       } | ||||
|     }; | ||||
|     const handleKeyUp = (e: KeyboardEvent) => { | ||||
|       if (e.key === "Shift" && mapServiceInstance) { | ||||
|         mapServiceInstance.deactivateLasso(); | ||||
|         setIsLassoActive(false); | ||||
|       } | ||||
|     }; | ||||
|     window.addEventListener("keydown", handleKeyDown); | ||||
|     window.addEventListener("keyup", handleKeyUp); | ||||
|     return () => { | ||||
|       window.removeEventListener("keydown", handleKeyDown); | ||||
|       window.removeEventListener("keyup", handleKeyUp); | ||||
|     }; | ||||
|   }, [mapServiceInstance]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (mapServiceInstance) { | ||||
|       mapServiceInstance.toggleLasso = function () { | ||||
|         if (currentMapMode === "lasso") { | ||||
|           this.deactivateLasso(); | ||||
|           setIsLassoActive(false); | ||||
|         } else { | ||||
|           this.activateLasso(); | ||||
|           setIsLassoActive(true); | ||||
|         } | ||||
|       }; | ||||
|     } | ||||
|   }, [mapServiceInstance, currentMapMode, setIsLassoActive]); | ||||
|  | ||||
|   const showLoader = isMapLoading || isDataLoading; | ||||
|   const showContent = mapServiceInstance && !showLoader && !error; | ||||
|  | ||||
|   // --- ИЗМЕНЕНО --- Логика для определения, активна ли кнопка сброса | ||||
|   const isAnythingSelected = | ||||
|     selectedFeatureForSidebar !== null || selectedIds.size > 0; | ||||
|  | ||||
|   return ( | ||||
|     <div className="flex flex-col md:flex-row font-sans bg-gray-100 h-[90vh] overflow-hidden"> | ||||
|       <div className="relative flex-grow flex"> | ||||
| @@ -1762,19 +2100,83 @@ export const MapPage: React.FC = () => { | ||||
|               </button> | ||||
|             </div> | ||||
|           )} | ||||
|           {isLassoActive && ( | ||||
|             <div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-blue-600 text-white py-2 px-4 rounded-full shadow-lg text-sm font-medium z-20"> | ||||
|               Режим выделения области. Нарисуйте многоугольник для выбора | ||||
|               объектов. | ||||
|             </div> | ||||
|           )} | ||||
|         </div> | ||||
|         {showContent && ( | ||||
|           <MapControls | ||||
|             mapService={mapServiceInstance} | ||||
|             activeMode={currentMapMode} | ||||
|             isLassoActive={isLassoActive} | ||||
|             isUnselectDisabled={!isAnythingSelected} // --- ИЗМЕНЕНО --- Передаем состояние disabled | ||||
|           /> | ||||
|         )} | ||||
|  | ||||
|         {/* Help button */} | ||||
|         <button | ||||
|           onClick={() => setShowHelp(!showHelp)} | ||||
|           className="absolute bottom-4 right-4 z-20 p-2 bg-white rounded-full shadow-md hover:bg-gray-100" | ||||
|           title="Помощь по клавишам" | ||||
|         > | ||||
|           <InfoIcon size={20} /> | ||||
|         </button> | ||||
|  | ||||
|         {/* Help popup */} | ||||
|         {showHelp && ( | ||||
|           <div className="absolute bottom-16 right-4 z-20 p-4 bg-white rounded-lg shadow-xl max-w-xs"> | ||||
|             <h4 className="font-bold mb-2">Горячие клавиши:</h4> | ||||
|             <ul className="text-sm space-y-2"> | ||||
|               <li> | ||||
|                 <span className="font-mono bg-gray-100 px-1 rounded"> | ||||
|                   Shift | ||||
|                 </span>{" "} | ||||
|                 - Режим выделения области (лассо) | ||||
|               </li> | ||||
|               <li> | ||||
|                 <span className="font-mono bg-gray-100 px-1 rounded"> | ||||
|                   Ctrl + клик | ||||
|                 </span>{" "} | ||||
|                 - Добавить объект к выбранным | ||||
|               </li> | ||||
|               <li> | ||||
|                 <span className="font-mono bg-gray-100 px-1 rounded">Esc</span>{" "} | ||||
|                 - Отменить выделение | ||||
|               </li> | ||||
|               <li> | ||||
|                 <span className="font-mono bg-gray-100 px-1 rounded"> | ||||
|                   Ctrl+Z | ||||
|                 </span>{" "} | ||||
|                 - Отменить последнее действие | ||||
|               </li> | ||||
|               <li> | ||||
|                 <span className="font-mono bg-gray-100 px-1 rounded"> | ||||
|                   Ctrl+Y | ||||
|                 </span>{" "} | ||||
|                 - Повторить отменённое действие | ||||
|               </li> | ||||
|             </ul> | ||||
|             <button | ||||
|               onClick={() => setShowHelp(false)} | ||||
|               className="mt-3 text-xs text-blue-600 hover:text-blue-800" | ||||
|             > | ||||
|               Закрыть | ||||
|             </button> | ||||
|           </div> | ||||
|         )} | ||||
|       </div> | ||||
|       {showContent && ( | ||||
|         <MapSightbar | ||||
|           mapService={mapServiceInstance} | ||||
|           mapFeatures={mapFeatures} | ||||
|           selectedFeature={selectedFeatureForSidebar} | ||||
|           selectedIds={selectedIds} | ||||
|           setSelectedIds={setSelectedIds} | ||||
|           activeSection={activeSectionFromParent} | ||||
|           setActiveSection={setActiveSectionFromParent} | ||||
|         /> | ||||
|       )} | ||||
|     </div> | ||||
|   | ||||
| @@ -54,20 +54,15 @@ class MapStore { | ||||
|   sights: ApiSight[] = []; | ||||
|  | ||||
|   getRoutes = async () => { | ||||
|     const routes = await languageInstance("ru").get("/route"); | ||||
|     const routedIds = routes.data.map((route: any) => route.id); | ||||
|     const mappedRoutes: ApiRoute[] = []; | ||||
|     for (const routeId of routedIds) { | ||||
|       const responseSoloRoute = await languageInstance("ru").get( | ||||
|         `/route/${routeId}` | ||||
|       ); | ||||
|       const route = responseSoloRoute.data; | ||||
|       mappedRoutes.push({ | ||||
|         id: route.id, | ||||
|         route_number: route.route_number, | ||||
|         path: route.path, | ||||
|       }); | ||||
|     } | ||||
|     // ИСПРАВЛЕНО: Проблема N+1. | ||||
|     // Вместо цикла и множества запросов теперь выполняется один. | ||||
|     // Бэкенд по эндпоинту `/route` должен возвращать массив полных объектов маршрутов. | ||||
|     const response = await languageInstance("ru").get("/route"); | ||||
|     const mappedRoutes: ApiRoute[] = response.data.map((route: any) => ({ | ||||
|       id: route.id, | ||||
|       route_number: route.route_number, | ||||
|       path: route.path, | ||||
|     })); | ||||
|     this.routes = mappedRoutes.sort((a, b) => | ||||
|       a.route_number.localeCompare(b.route_number) | ||||
|     ); | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Eye, Trash2 } from "lucide-react"; | ||||
| import { Eye, Trash2, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { CreateButton, DeleteModal } from "@widgets"; | ||||
|  | ||||
| @@ -10,7 +10,9 @@ export const MediaListPage = observer(() => { | ||||
|   const { media, getMedia, deleteMedia } = mediaStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<string | null>(null); // Lifted state | ||||
|   const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<string | null>(null); | ||||
|   const [ids, setIds] = useState<string[]>([]); | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
| @@ -22,6 +24,17 @@ export const MediaListPage = observer(() => { | ||||
|       field: "media_name", | ||||
|       headerName: "Название", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "media_type", | ||||
| @@ -30,13 +43,15 @@ export const MediaListPage = observer(() => { | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <p> | ||||
|             { | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               MEDIA_TYPE_LABELS[ | ||||
|                 params.row.media_type as keyof typeof MEDIA_TYPE_LABELS | ||||
|               ] | ||||
|             } | ||||
|           </p> | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
| @@ -80,10 +95,28 @@ export const MediaListPage = observer(() => { | ||||
|           <h1 className="text-2xl">Медиа</h1> | ||||
|           <CreateButton label="Создать медиа" path="/media/create" /> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|           className="flex justify-end mb-5 duration-300" | ||||
|           style={{ opacity: ids.length > 0 ? 1 : 0 }} | ||||
|         > | ||||
|           <button | ||||
|             className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" | ||||
|             onClick={() => setIsBulkDeleteModalOpen(true)} | ||||
|           > | ||||
|             <Trash2 size={20} className="text-white" /> Удалить выбранные ( | ||||
|             {ids.length}) | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           hideFooterPagination | ||||
|           checkboxSelection | ||||
|           onRowSelectionModelChange={(newSelection) => { | ||||
|             setIds(Array.from(newSelection.ids) as string[]); | ||||
|           }} | ||||
|           hideFooter | ||||
|         /> | ||||
|       </div> | ||||
| @@ -103,6 +136,19 @@ export const MediaListPage = observer(() => { | ||||
|           setRowId(null); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isBulkDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           await Promise.all(ids.map((id) => deleteMedia(id))); | ||||
|           getMedia(); | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|           setIds([]); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -9,7 +9,7 @@ import { | ||||
|   Typography, | ||||
|   Box, | ||||
| } from "@mui/material"; | ||||
|  | ||||
| import { LanguageSwitcher } from "@widgets"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { ArrowLeft, Loader2, Save } from "lucide-react"; | ||||
| import { useEffect, useState } from "react"; | ||||
| @@ -93,134 +93,135 @@ export const RouteCreatePage = observer(() => { | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
|       <div className="flex justify-between items-center"> | ||||
|       <LanguageSwitcher /> | ||||
|       <div className="flex items-center gap-4"> | ||||
|         <button | ||||
|           className="flex items-center gap-2" | ||||
|           onClick={() => navigate(-1)} | ||||
|         > | ||||
|           <ArrowLeft size={20} /> | ||||
|           Маршруты / Создать | ||||
|           Назад | ||||
|         </button> | ||||
|       </div> | ||||
|       <Typography variant="h5" fontWeight={700}> | ||||
|         Создать маршрут | ||||
|       </Typography> | ||||
|       <Box className="flex flex-col gap-6 w-full"> | ||||
|         <FormControl fullWidth required> | ||||
|           <InputLabel>Выберите перевозчика</InputLabel> | ||||
|           <Select | ||||
|             value={carrier} | ||||
|             label="Выберите перевозчика" | ||||
|             onChange={(e) => setCarrier(e.target.value as string)} | ||||
|             disabled={carrierStore.carriers.data.length === 0} | ||||
|  | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <Box className="flex flex-col gap-6 w-full"> | ||||
|           <FormControl fullWidth required> | ||||
|             <InputLabel>Выберите перевозчика</InputLabel> | ||||
|             <Select | ||||
|               value={carrier} | ||||
|               label="Выберите перевозчика" | ||||
|               onChange={(e) => setCarrier(e.target.value as string)} | ||||
|               disabled={carrierStore.carriers.data.length === 0} | ||||
|             > | ||||
|               <MenuItem value="">Не выбрано</MenuItem> | ||||
|               {carrierStore.carriers.data.map( | ||||
|                 (c: (typeof carrierStore.carriers.data)[number]) => ( | ||||
|                   <MenuItem key={c.id} value={c.id}> | ||||
|                     {c.full_name} | ||||
|                   </MenuItem> | ||||
|                 ) | ||||
|               )} | ||||
|             </Select> | ||||
|           </FormControl> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Номер маршрута" | ||||
|             required | ||||
|             value={routeNumber} | ||||
|             onChange={(e) => setRouteNumber(e.target.value)} | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Координаты маршрута" | ||||
|             multiline | ||||
|             minRows={3} | ||||
|             value={routeCoords} | ||||
|             onChange={(e) => setRouteCoords(e.target.value)} | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Номер маршрута в Говорящем Городе" | ||||
|             required | ||||
|             value={govRouteNumber} | ||||
|             onChange={(e) => setGovRouteNumber(e.target.value)} | ||||
|           /> | ||||
|           <FormControl fullWidth required> | ||||
|             <InputLabel>Обращение губернатора</InputLabel> | ||||
|             <Select | ||||
|               value={governorAppeal} | ||||
|               label="Обращение губернатора" | ||||
|               onChange={(e) => setGovernorAppeal(e.target.value as string)} | ||||
|               disabled={articlesStore.articleList.ru.data.length === 0} | ||||
|             > | ||||
|               <MenuItem value="">Не выбрано</MenuItem> | ||||
|               {articlesStore.articleList.ru.data.map( | ||||
|                 (a: (typeof articlesStore.articleList.ru.data)[number]) => ( | ||||
|                   <MenuItem key={a.id} value={a.id}> | ||||
|                     {a.heading} | ||||
|                   </MenuItem> | ||||
|                 ) | ||||
|               )} | ||||
|             </Select> | ||||
|           </FormControl> | ||||
|           <FormControl fullWidth required> | ||||
|             <InputLabel>Прямой/обратный маршрут</InputLabel> | ||||
|             <Select | ||||
|               value={direction} | ||||
|               label="Прямой/обратный маршрут" | ||||
|               onChange={(e) => setDirection(e.target.value)} | ||||
|             > | ||||
|               <MenuItem value="forward">Прямой</MenuItem> | ||||
|               <MenuItem value="backward">Обратный</MenuItem> | ||||
|             </Select> | ||||
|           </FormControl> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Масштаб (мин)" | ||||
|             value={scaleMin} | ||||
|             onChange={(e) => setScaleMin(e.target.value)} | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Масштаб (макс)" | ||||
|             value={scaleMax} | ||||
|             onChange={(e) => setScaleMax(e.target.value)} | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Поворот" | ||||
|             value={turn} | ||||
|             onChange={(e) => setTurn(e.target.value)} | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Центр. широта" | ||||
|             value={centerLat} | ||||
|             onChange={(e) => setCenterLat(e.target.value)} | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Центр. долгота" | ||||
|             value={centerLng} | ||||
|             onChange={(e) => setCenterLng(e.target.value)} | ||||
|           /> | ||||
|         </Box> | ||||
|         <div className="flex w-full justify-end"> | ||||
|           <Button | ||||
|             variant="contained" | ||||
|             color="primary" | ||||
|             className="w-min flex gap-2 items-center" | ||||
|             startIcon={<Save size={20} />} | ||||
|             onClick={handleCreateRoute} | ||||
|             disabled={isLoading} | ||||
|           > | ||||
|             <MenuItem value="">Не выбрано</MenuItem> | ||||
|             {carrierStore.carriers.data.map( | ||||
|               (c: (typeof carrierStore.carriers.data)[number]) => ( | ||||
|                 <MenuItem key={c.id} value={c.id}> | ||||
|                   {c.full_name} | ||||
|                 </MenuItem> | ||||
|               ) | ||||
|             {isLoading ? ( | ||||
|               <Loader2 size={20} className="animate-spin" /> | ||||
|             ) : ( | ||||
|               "Сохранить" | ||||
|             )} | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Номер маршрута" | ||||
|           required | ||||
|           value={routeNumber} | ||||
|           onChange={(e) => setRouteNumber(e.target.value)} | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Координаты маршрута" | ||||
|           multiline | ||||
|           minRows={3} | ||||
|           value={routeCoords} | ||||
|           onChange={(e) => setRouteCoords(e.target.value)} | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Номер маршрута в Говорящем Городе" | ||||
|           required | ||||
|           value={govRouteNumber} | ||||
|           onChange={(e) => setGovRouteNumber(e.target.value)} | ||||
|         /> | ||||
|         <FormControl fullWidth required> | ||||
|           <InputLabel>Обращение губернатора</InputLabel> | ||||
|           <Select | ||||
|             value={governorAppeal} | ||||
|             label="Обращение губернатора" | ||||
|             onChange={(e) => setGovernorAppeal(e.target.value as string)} | ||||
|             disabled={articlesStore.articleList.ru.data.length === 0} | ||||
|           > | ||||
|             <MenuItem value="">Не выбрано</MenuItem> | ||||
|             {articlesStore.articleList.ru.data.map( | ||||
|               (a: (typeof articlesStore.articleList.ru.data)[number]) => ( | ||||
|                 <MenuItem key={a.id} value={a.id}> | ||||
|                   {a.heading} | ||||
|                 </MenuItem> | ||||
|               ) | ||||
|             )} | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|         <FormControl fullWidth required> | ||||
|           <InputLabel>Прямой/обратный маршрут</InputLabel> | ||||
|           <Select | ||||
|             value={direction} | ||||
|             label="Прямой/обратный маршрут" | ||||
|             onChange={(e) => setDirection(e.target.value)} | ||||
|           > | ||||
|             <MenuItem value="forward">Прямой</MenuItem> | ||||
|             <MenuItem value="backward">Обратный</MenuItem> | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Масштаб (мин)" | ||||
|           value={scaleMin} | ||||
|           onChange={(e) => setScaleMin(e.target.value)} | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Масштаб (макс)" | ||||
|           value={scaleMax} | ||||
|           onChange={(e) => setScaleMax(e.target.value)} | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Поворот" | ||||
|           value={turn} | ||||
|           onChange={(e) => setTurn(e.target.value)} | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Центр. широта" | ||||
|           value={centerLat} | ||||
|           onChange={(e) => setCenterLat(e.target.value)} | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Центр. долгота" | ||||
|           value={centerLng} | ||||
|           onChange={(e) => setCenterLng(e.target.value)} | ||||
|         /> | ||||
|       </Box> | ||||
|       <div className="flex w-full justify-end"> | ||||
|         <Button | ||||
|           variant="contained" | ||||
|           color="primary" | ||||
|           className="w-min flex gap-2 items-center" | ||||
|           startIcon={<Save size={20} />} | ||||
|           onClick={handleCreateRoute} | ||||
|           disabled={isLoading} | ||||
|         > | ||||
|           {isLoading ? ( | ||||
|             <Loader2 size={20} className="animate-spin" /> | ||||
|           ) : ( | ||||
|             "Сохранить" | ||||
|           )} | ||||
|         </Button> | ||||
|           </Button> | ||||
|         </div> | ||||
|       </div> | ||||
|     </Paper> | ||||
|   ); | ||||
|   | ||||
| @@ -9,7 +9,7 @@ import { | ||||
|   Typography, | ||||
|   Box, | ||||
| } from "@mui/material"; | ||||
|  | ||||
| import { LanguageSwitcher } from "@widgets"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { ArrowLeft, Save } from "lucide-react"; | ||||
| import { useEffect, useState } from "react"; | ||||
| @@ -45,180 +45,181 @@ export const RouteEditPage = observer(() => { | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
|       <div className="flex justify-between items-center"> | ||||
|       <LanguageSwitcher /> | ||||
|       <div className="flex items-center gap-4"> | ||||
|         <button | ||||
|           className="flex items-center gap-2" | ||||
|           onClick={() => navigate(-1)} | ||||
|         > | ||||
|           <ArrowLeft size={20} /> | ||||
|           Маршруты / Редактировать | ||||
|           Назад | ||||
|         </button> | ||||
|       </div> | ||||
|       <Typography variant="h5" fontWeight={700}> | ||||
|         Редактировать маршрут | ||||
|       </Typography> | ||||
|       <Box className="flex flex-col gap-6 w-full"> | ||||
|         <FormControl fullWidth required> | ||||
|           <InputLabel>Выберите перевозчика</InputLabel> | ||||
|           <Select | ||||
|             value={editRouteData.carrier_id} | ||||
|             label="Выберите перевозчика" | ||||
|  | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <Box className="flex flex-col gap-6 w-full"> | ||||
|           <FormControl fullWidth required> | ||||
|             <InputLabel>Выберите перевозчика</InputLabel> | ||||
|             <Select | ||||
|               value={editRouteData.carrier_id} | ||||
|               label="Выберите перевозчика" | ||||
|               onChange={(e) => | ||||
|                 routeStore.setEditRouteData({ | ||||
|                   carrier_id: Number(e.target.value), | ||||
|                   carrier: | ||||
|                     carrierStore.carriers.data.find( | ||||
|                       (c) => c.id === Number(e.target.value) | ||||
|                     )?.full_name || "", | ||||
|                 }) | ||||
|               } | ||||
|               disabled={carrierStore.carriers.data.length === 0} | ||||
|             > | ||||
|               <MenuItem value="">Не выбрано</MenuItem> | ||||
|               {carrierStore.carriers.data.map( | ||||
|                 (c: (typeof carrierStore.carriers.data)[number]) => ( | ||||
|                   <MenuItem key={c.id} value={c.id}> | ||||
|                     {c.full_name} | ||||
|                   </MenuItem> | ||||
|                 ) | ||||
|               )} | ||||
|             </Select> | ||||
|           </FormControl> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Номер маршрута" | ||||
|             required | ||||
|             value={editRouteData.route_number || ""} | ||||
|             onChange={(e) => | ||||
|               routeStore.setEditRouteData({ | ||||
|                 carrier_id: Number(e.target.value), | ||||
|                 carrier: | ||||
|                   carrierStore.carriers.data.find( | ||||
|                     (c) => c.id === Number(e.target.value) | ||||
|                   )?.full_name || "", | ||||
|                 route_number: e.target.value, | ||||
|               }) | ||||
|             } | ||||
|             disabled={carrierStore.carriers.data.length === 0} | ||||
|           > | ||||
|             <MenuItem value="">Не выбрано</MenuItem> | ||||
|             {carrierStore.carriers.data.map( | ||||
|               (c: (typeof carrierStore.carriers.data)[number]) => ( | ||||
|                 <MenuItem key={c.id} value={c.id}> | ||||
|                   {c.full_name} | ||||
|                 </MenuItem> | ||||
|               ) | ||||
|             )} | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Номер маршрута" | ||||
|           required | ||||
|           value={editRouteData.route_number || ""} | ||||
|           onChange={(e) => | ||||
|             routeStore.setEditRouteData({ | ||||
|               route_number: e.target.value, | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Координаты маршрута" | ||||
|           multiline | ||||
|           minRows={3} | ||||
|           value={editRouteData.path.map((p) => p.join(" ")).join("\n") || ""} | ||||
|           onChange={(e) => | ||||
|             routeStore.setEditRouteData({ | ||||
|               path: e.target.value | ||||
|                 .split("\n") | ||||
|                 .map((line) => line.split(" ").map(Number)), | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Номер маршрута в Говорящем Городе" | ||||
|           required | ||||
|           value={editRouteData.route_sys_number || ""} | ||||
|           onChange={(e) => | ||||
|             routeStore.setEditRouteData({ | ||||
|               route_sys_number: e.target.value, | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|         <FormControl fullWidth required> | ||||
|           <InputLabel>Обращение губернатора</InputLabel> | ||||
|           <Select | ||||
|             value={editRouteData.governor_appeal || ""} | ||||
|             label="Обращение губернатора" | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Координаты маршрута" | ||||
|             multiline | ||||
|             minRows={3} | ||||
|             value={editRouteData.path.map((p) => p.join(" ")).join("\n") || ""} | ||||
|             onChange={(e) => | ||||
|               routeStore.setEditRouteData({ | ||||
|                 governor_appeal: Number(e.target.value), | ||||
|                 path: e.target.value | ||||
|                   .split("\n") | ||||
|                   .map((line) => line.split(" ").map(Number)), | ||||
|               }) | ||||
|             } | ||||
|             disabled={articlesStore.articleList.ru.data.length === 0} | ||||
|           > | ||||
|             <MenuItem value="">Не выбрано</MenuItem> | ||||
|             {articlesStore.articleList.ru.data.map( | ||||
|               (a: (typeof articlesStore.articleList.ru.data)[number]) => ( | ||||
|                 <MenuItem key={a.id} value={a.id}> | ||||
|                   {a.heading} | ||||
|                 </MenuItem> | ||||
|               ) | ||||
|             )} | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|         <FormControl fullWidth required> | ||||
|           <InputLabel>Прямой/обратный маршрут</InputLabel> | ||||
|           <Select | ||||
|             value={editRouteData.route_direction ? "forward" : "backward"} | ||||
|             label="Прямой/обратный маршрут" | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Номер маршрута в Говорящем Городе" | ||||
|             required | ||||
|             value={editRouteData.route_sys_number || ""} | ||||
|             onChange={(e) => | ||||
|               routeStore.setEditRouteData({ | ||||
|                 route_direction: e.target.value === "forward", | ||||
|                 route_sys_number: e.target.value, | ||||
|               }) | ||||
|             } | ||||
|           /> | ||||
|           <FormControl fullWidth required> | ||||
|             <InputLabel>Обращение губернатора</InputLabel> | ||||
|             <Select | ||||
|               value={editRouteData.governor_appeal || ""} | ||||
|               label="Обращение губернатора" | ||||
|               onChange={(e) => | ||||
|                 routeStore.setEditRouteData({ | ||||
|                   governor_appeal: Number(e.target.value), | ||||
|                 }) | ||||
|               } | ||||
|               disabled={articlesStore.articleList.ru.data.length === 0} | ||||
|             > | ||||
|               <MenuItem value="">Не выбрано</MenuItem> | ||||
|               {articlesStore.articleList.ru.data.map( | ||||
|                 (a: (typeof articlesStore.articleList.ru.data)[number]) => ( | ||||
|                   <MenuItem key={a.id} value={a.id}> | ||||
|                     {a.heading} | ||||
|                   </MenuItem> | ||||
|                 ) | ||||
|               )} | ||||
|             </Select> | ||||
|           </FormControl> | ||||
|           <FormControl fullWidth required> | ||||
|             <InputLabel>Прямой/обратный маршрут</InputLabel> | ||||
|             <Select | ||||
|               value={editRouteData.route_direction ? "forward" : "backward"} | ||||
|               label="Прямой/обратный маршрут" | ||||
|               onChange={(e) => | ||||
|                 routeStore.setEditRouteData({ | ||||
|                   route_direction: e.target.value === "forward", | ||||
|                 }) | ||||
|               } | ||||
|             > | ||||
|               <MenuItem value="forward">Прямой</MenuItem> | ||||
|               <MenuItem value="backward">Обратный</MenuItem> | ||||
|             </Select> | ||||
|           </FormControl> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Масштаб (мин)" | ||||
|             value={editRouteData.scale_min || ""} | ||||
|             onChange={(e) => | ||||
|               routeStore.setEditRouteData({ | ||||
|                 scale_min: Number(e.target.value), | ||||
|               }) | ||||
|             } | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Масштаб (макс)" | ||||
|             value={editRouteData.scale_max || ""} | ||||
|             onChange={(e) => | ||||
|               routeStore.setEditRouteData({ | ||||
|                 scale_max: Number(e.target.value), | ||||
|               }) | ||||
|             } | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Поворот" | ||||
|             value={editRouteData.rotate || ""} | ||||
|             onChange={(e) => | ||||
|               routeStore.setEditRouteData({ | ||||
|                 rotate: Number(e.target.value), | ||||
|               }) | ||||
|             } | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Центр. широта" | ||||
|             value={editRouteData.center_latitude || ""} | ||||
|             onChange={(e) => | ||||
|               routeStore.setEditRouteData({ | ||||
|                 center_latitude: Number(e.target.value), | ||||
|               }) | ||||
|             } | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Центр. долгота" | ||||
|             value={editRouteData.center_longitude || ""} | ||||
|             onChange={(e) => | ||||
|               routeStore.setEditRouteData({ | ||||
|                 center_longitude: Number(e.target.value), | ||||
|               }) | ||||
|             } | ||||
|           /> | ||||
|         </Box> | ||||
|         <div className="flex w-full justify-end"> | ||||
|           <Button | ||||
|             variant="contained" | ||||
|             color="primary" | ||||
|             className="w-min flex gap-2 items-center" | ||||
|             startIcon={<Save size={20} />} | ||||
|             onClick={handleSave} | ||||
|             disabled={isLoading} | ||||
|           > | ||||
|             <MenuItem value="forward">Прямой</MenuItem> | ||||
|             <MenuItem value="backward">Обратный</MenuItem> | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Масштаб (мин)" | ||||
|           value={editRouteData.scale_min || ""} | ||||
|           onChange={(e) => | ||||
|             routeStore.setEditRouteData({ | ||||
|               scale_min: Number(e.target.value), | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Масштаб (макс)" | ||||
|           value={editRouteData.scale_max || ""} | ||||
|           onChange={(e) => | ||||
|             routeStore.setEditRouteData({ | ||||
|               scale_max: Number(e.target.value), | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Поворот" | ||||
|           value={editRouteData.rotate || ""} | ||||
|           onChange={(e) => | ||||
|             routeStore.setEditRouteData({ | ||||
|               rotate: Number(e.target.value), | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Центр. широта" | ||||
|           value={editRouteData.center_latitude || ""} | ||||
|           onChange={(e) => | ||||
|             routeStore.setEditRouteData({ | ||||
|               center_latitude: Number(e.target.value), | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Центр. долгота" | ||||
|           value={editRouteData.center_longitude || ""} | ||||
|           onChange={(e) => | ||||
|             routeStore.setEditRouteData({ | ||||
|               center_longitude: Number(e.target.value), | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|       </Box> | ||||
|       <div className="flex w-full justify-end"> | ||||
|         <Button | ||||
|           variant="contained" | ||||
|           color="primary" | ||||
|           className="w-min flex gap-2 items-center" | ||||
|           startIcon={<Save size={20} />} | ||||
|           onClick={handleSave} | ||||
|           disabled={isLoading} | ||||
|         > | ||||
|           Сохранить | ||||
|         </Button> | ||||
|             Сохранить | ||||
|           </Button> | ||||
|         </div> | ||||
|       </div> | ||||
|     </Paper> | ||||
|   ); | ||||
|   | ||||
| @@ -2,15 +2,18 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { languageStore, routeStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Map, Pencil, Trash2 } from "lucide-react"; | ||||
| import { Map, Pencil, Trash2, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { CreateButton, DeleteModal } from "@widgets"; | ||||
| import { LanguageSwitcher } from "@widgets"; | ||||
|  | ||||
| export const RouteListPage = observer(() => { | ||||
|   const { routes, getRoutes, deleteRoute } = routeStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); // Lifted state | ||||
|   const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); | ||||
|   const [ids, setIds] = useState<number[]>([]); | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
| @@ -22,11 +25,33 @@ export const RouteListPage = observer(() => { | ||||
|       field: "carrier", | ||||
|       headerName: "Перевозчик", | ||||
|       width: 250, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "route_number", | ||||
|       headerName: "Номер маршрута", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "route_direction", | ||||
| @@ -87,15 +112,35 @@ export const RouteListPage = observer(() => { | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <LanguageSwitcher /> | ||||
|  | ||||
|       <div style={{ width: "100%" }}> | ||||
|         <div className="flex justify-between items-center mb-10"> | ||||
|           <h1 className="text-2xl">Маршруты</h1> | ||||
|           <CreateButton label="Создать маршрут" path="/route/create" /> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|           className="flex justify-end mb-5 duration-300" | ||||
|           style={{ opacity: ids.length > 0 ? 1 : 0 }} | ||||
|         > | ||||
|           <button | ||||
|             className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" | ||||
|             onClick={() => setIsBulkDeleteModalOpen(true)} | ||||
|           > | ||||
|             <Trash2 size={20} className="text-white" /> Удалить выбранные ( | ||||
|             {ids.length}) | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           hideFooterPagination | ||||
|           checkboxSelection | ||||
|           onRowSelectionModelChange={(newSelection) => { | ||||
|             setIds(Array.from(newSelection.ids) as number[]); | ||||
|           }} | ||||
|           hideFooter | ||||
|         /> | ||||
|       </div> | ||||
| @@ -114,6 +159,19 @@ export const RouteListPage = observer(() => { | ||||
|           setRowId(null); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isBulkDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           await Promise.all(ids.map((id) => deleteRoute(id))); | ||||
|           getRoutes(); | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|           setIds([]); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { languageStore, sightsStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Pencil, Trash2 } from "lucide-react"; | ||||
| import { Pencil, Trash2, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; | ||||
|  | ||||
| @@ -10,7 +10,9 @@ export const SightListPage = observer(() => { | ||||
|   const { sights, getSights, deleteListSight } = sightsStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<string | null>(null); // Lifted state | ||||
|   const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<string | null>(null); | ||||
|   const [ids, setIds] = useState<number[]>([]); | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
| @@ -22,13 +24,34 @@ export const SightListPage = observer(() => { | ||||
|       field: "name", | ||||
|       headerName: "Имя", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "city", | ||||
|       headerName: "Город", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|  | ||||
|     { | ||||
|       field: "actions", | ||||
|       headerName: "Действия", | ||||
| @@ -76,10 +99,28 @@ export const SightListPage = observer(() => { | ||||
|             path="/sight/create" | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|           className="flex justify-end mb-5 duration-300" | ||||
|           style={{ opacity: ids.length > 0 ? 1 : 0 }} | ||||
|         > | ||||
|           <button | ||||
|             className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" | ||||
|             onClick={() => setIsBulkDeleteModalOpen(true)} | ||||
|           > | ||||
|             <Trash2 size={20} className="text-white" /> Удалить выбранные ( | ||||
|             {ids.length}) | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           hideFooterPagination | ||||
|           checkboxSelection | ||||
|           onRowSelectionModelChange={(newSelection) => { | ||||
|             setIds(Array.from(newSelection.ids) as number[]); | ||||
|           }} | ||||
|           hideFooter | ||||
|         /> | ||||
|       </div> | ||||
| @@ -98,6 +139,19 @@ export const SightListPage = observer(() => { | ||||
|           setRowId(null); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isBulkDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           await Promise.all(ids.map((id) => deleteListSight(id))); | ||||
|           getSights(); | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|           setIds([]); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -13,6 +13,7 @@ import { useNavigate } from "react-router-dom"; | ||||
| import { toast } from "react-toastify"; | ||||
| import { stationsStore } from "@shared"; | ||||
| import { useState } from "react"; | ||||
| import { LanguageSwitcher } from "@widgets"; | ||||
|  | ||||
| export const StationCreatePage = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
| @@ -36,7 +37,8 @@ export const StationCreatePage = observer(() => { | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
|       <div className="flex justify-between items-center"> | ||||
|       <LanguageSwitcher /> | ||||
|       <div className="flex items-center gap-4"> | ||||
|         <button | ||||
|           className="flex items-center gap-2" | ||||
|           onClick={() => navigate(-1)} | ||||
| @@ -45,8 +47,10 @@ export const StationCreatePage = observer(() => { | ||||
|           Назад | ||||
|         </button> | ||||
|       </div> | ||||
|       <h1 className="text-2xl font-bold">Создание станции</h1> | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <div className="flex gap-10 items-center mb-5 max-w-[80%]"> | ||||
|           <h1 className="text-3xl break-words">Создание станции</h1> | ||||
|         </div> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Название" | ||||
|   | ||||
| @@ -49,11 +49,13 @@ export const StationEditPage = observer(() => { | ||||
|  | ||||
|       const stationId = Number(id); | ||||
|       await getEditStation(stationId); | ||||
|       await getCities(language); | ||||
|       await getCities("ru"); | ||||
|       await getCities("en"); | ||||
|       await getCities("zh"); | ||||
|     }; | ||||
|  | ||||
|     fetchAndSetStationData(); | ||||
|   }, [id, language]); | ||||
|   }, [id]); | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
| @@ -69,6 +71,9 @@ export const StationEditPage = observer(() => { | ||||
|       </div> | ||||
|  | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <div className="flex gap-10 items-center mb-5 max-w-[80%]"> | ||||
|           <h1 className="text-3xl break-words">{editStationData.ru.name}</h1> | ||||
|         </div> | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Название" | ||||
| @@ -141,7 +146,7 @@ export const StationEditPage = observer(() => { | ||||
|             value={editStationData.common.city_id || ""} | ||||
|             label="Город" | ||||
|             onChange={(e) => { | ||||
|               const selectedCity = cities[language].find( | ||||
|               const selectedCity = cities[language].data.find( | ||||
|                 (city) => city.id === e.target.value | ||||
|               ); | ||||
|               setEditCommonData({ | ||||
| @@ -150,7 +155,7 @@ export const StationEditPage = observer(() => { | ||||
|               }); | ||||
|             }} | ||||
|           > | ||||
|             {cities[language].map((city) => ( | ||||
|             {cities[language].data.map((city) => ( | ||||
|               <MenuItem key={city.id} value={city.id}> | ||||
|                 {city.name} | ||||
|               </MenuItem> | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { languageStore, stationsStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Eye, Pencil, Trash2 } from "lucide-react"; | ||||
| import { Eye, Pencil, Trash2, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; | ||||
|  | ||||
| @@ -10,7 +10,9 @@ export const StationListPage = observer(() => { | ||||
|   const { stationLists, getStationList, deleteStation } = stationsStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); // Lifted state | ||||
|   const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); | ||||
|   const [ids, setIds] = useState<number[]>([]); | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
| @@ -22,11 +24,33 @@ export const StationListPage = observer(() => { | ||||
|       field: "name", | ||||
|       headerName: "Название", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "system_name", | ||||
|       headerName: "Системное название", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "direction", | ||||
| @@ -88,15 +112,33 @@ export const StationListPage = observer(() => { | ||||
|     <> | ||||
|       <LanguageSwitcher /> | ||||
|  | ||||
|       <div style={{ width: "100%" }}> | ||||
|       <div className="w-full"> | ||||
|         <div className="flex justify-between items-center mb-10"> | ||||
|           <h1 className="text-2xl">Станции</h1> | ||||
|           <CreateButton label="Создать станцию" path="/station/create" /> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|           className="flex justify-end mb-5 duration-300" | ||||
|           style={{ opacity: ids.length > 0 ? 1 : 0 }} | ||||
|         > | ||||
|           <button | ||||
|             className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" | ||||
|             onClick={() => setIsBulkDeleteModalOpen(true)} | ||||
|           > | ||||
|             <Trash2 size={20} className="text-white" /> Удалить выбранные ( | ||||
|             {ids.length}) | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           hideFooterPagination | ||||
|           checkboxSelection | ||||
|           onRowSelectionModelChange={(newSelection) => { | ||||
|             setIds(Array.from(newSelection.ids) as number[]); | ||||
|           }} | ||||
|           hideFooter | ||||
|         /> | ||||
|       </div> | ||||
| @@ -115,6 +157,19 @@ export const StationListPage = observer(() => { | ||||
|           setRowId(null); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isBulkDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           await Promise.all(ids.map((id) => deleteStation(id))); | ||||
|           getStationList(); | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|           setIds([]); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { userStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Pencil, Trash2 } from "lucide-react"; | ||||
| import { Pencil, Trash2, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
|  | ||||
| import { CreateButton, DeleteModal } from "@widgets"; | ||||
| @@ -11,7 +11,9 @@ export const UserListPage = observer(() => { | ||||
|   const { users, getUsers, deleteUser } = userStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); // Lifted state | ||||
|   const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); | ||||
|   const [ids, setIds] = useState<number[]>([]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     getUsers(); | ||||
| @@ -22,11 +24,33 @@ export const UserListPage = observer(() => { | ||||
|       field: "name", | ||||
|       headerName: "Имя", | ||||
|       width: 400, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "email", | ||||
|       headerName: "Email", | ||||
|       width: 400, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "is_admin", | ||||
| @@ -93,10 +117,28 @@ export const UserListPage = observer(() => { | ||||
|           <h1 className="text-2xl">Пользователи</h1> | ||||
|           <CreateButton label="Создать пользователя" path="/user/create" /> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|           className="flex justify-end mb-5 duration-300" | ||||
|           style={{ opacity: ids.length > 0 ? 1 : 0 }} | ||||
|         > | ||||
|           <button | ||||
|             className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" | ||||
|             onClick={() => setIsBulkDeleteModalOpen(true)} | ||||
|           > | ||||
|             <Trash2 size={20} className="text-white" /> Удалить выбранные ( | ||||
|             {ids.length}) | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           hideFooterPagination | ||||
|           checkboxSelection | ||||
|           onRowSelectionModelChange={(newSelection) => { | ||||
|             setIds(Array.from(newSelection.ids) as number[]); | ||||
|           }} | ||||
|           hideFooter | ||||
|         /> | ||||
|       </div> | ||||
| @@ -107,7 +149,6 @@ export const UserListPage = observer(() => { | ||||
|           if (rowId) { | ||||
|             await deleteUser(rowId); | ||||
|           } | ||||
|  | ||||
|           setIsDeleteModalOpen(false); | ||||
|           setRowId(null); | ||||
|         }} | ||||
| @@ -116,6 +157,19 @@ export const UserListPage = observer(() => { | ||||
|           setRowId(null); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isBulkDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           await Promise.all(ids.map((id) => deleteUser(id))); | ||||
|           getUsers(); | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|           setIds([]); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { carrierStore, languageStore, vehicleStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Eye, Pencil, Trash2 } from "lucide-react"; | ||||
| import { Eye, Pencil, Trash2, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { CreateButton, DeleteModal } from "@widgets"; | ||||
| import { VEHICLE_TYPES } from "@shared"; | ||||
| @@ -12,7 +12,9 @@ export const VehicleListPage = observer(() => { | ||||
|   const { carriers, getCarriers } = carrierStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); | ||||
|   const [ids, setIds] = useState<number[]>([]); | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
| @@ -25,17 +27,31 @@ export const VehicleListPage = observer(() => { | ||||
|       field: "tail_number", | ||||
|       headerName: "Бортовой номер", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "type", | ||||
|       headerName: "Тип", | ||||
|       flex: 1, | ||||
|  | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="flex h-full gap-7  items-center"> | ||||
|             {VEHICLE_TYPES.find((type) => type.value === params.row.type) | ||||
|               ?.label || params.row.type} | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               VEHICLE_TYPES.find((type) => type.value === params.row.type) | ||||
|                 ?.label || params.row.type | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
| @@ -44,13 +60,34 @@ export const VehicleListPage = observer(() => { | ||||
|       field: "carrier", | ||||
|       headerName: "Перевозчик", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "city", | ||||
|       headerName: "Город", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|  | ||||
|     { | ||||
|       field: "actions", | ||||
|       headerName: "Действия", | ||||
| @@ -101,10 +138,28 @@ export const VehicleListPage = observer(() => { | ||||
|             path="/vehicle/create" | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|           className="flex justify-end mb-5 duration-300" | ||||
|           style={{ opacity: ids.length > 0 ? 1 : 0 }} | ||||
|         > | ||||
|           <button | ||||
|             className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" | ||||
|             onClick={() => setIsBulkDeleteModalOpen(true)} | ||||
|           > | ||||
|             <Trash2 size={20} className="text-white" /> Удалить выбранные ( | ||||
|             {ids.length}) | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           hideFooterPagination | ||||
|           checkboxSelection | ||||
|           onRowSelectionModelChange={(newSelection) => { | ||||
|             setIds(Array.from(newSelection.ids) as number[]); | ||||
|           }} | ||||
|           hideFooter | ||||
|         /> | ||||
|       </div> | ||||
| @@ -123,6 +178,19 @@ export const VehicleListPage = observer(() => { | ||||
|           setRowId(null); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isBulkDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           await Promise.all(ids.map((id) => deleteVehicle(id))); | ||||
|           getVehicles(); | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|           setIds([]); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
							
								
								
									
										62
									
								
								src/shared/config/CarrierSvg.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								src/shared/config/CarrierSvg.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | ||||
| export const CarrierSvg = () => { | ||||
|   return ( | ||||
|     <svg | ||||
|       fill="#000000" | ||||
|       height="26px" | ||||
|       width="26px" | ||||
|       version="1.1" | ||||
|       id="Capa_1" | ||||
|       xmlns="http://www.w3.org/2000/svg" | ||||
|       xmlnsXlink="http://www.w3.org/1999/xlink" | ||||
|       viewBox="0 0 489.785 489.785" | ||||
|     > | ||||
|       <g id="XMLID_196_"> | ||||
|         <path | ||||
|           id="XMLID_203_" | ||||
|           d="M409.772,379.327l-81.359-124.975c-5.884-9.054-15.925-13.119-25.987-13.119 | ||||
| 		c-2.082,0-6.392,0.05-11.051,0.115c-0.363-0.61-0.742-1.215-1.355-1.627l-20.492-13.609c-2.364-1.569-5.434-1.486-7.701,0.182 | ||||
| 		l-16.948,12.508l-16.959-12.508c-2.285-1.668-5.337-1.751-7.72-0.182l-20.455,13.609c-0.578,0.377-0.945,0.907-1.282,1.461 | ||||
| 		c-4.828,0.031-9.327,0.057-11.222,0.057c-10.016,0-20.011,4.119-25.859,13.113L80.022,379.327 | ||||
| 		c-8.65,13.267-5.149,31.008,7.896,39.992l18.06,12.449c10.887-25.926,28.868-48.094,51.45-64.279l4.657-7.162v3.861 | ||||
| 		c16.364-10.811,34.941-18.477,54.885-22.234c-5.926-13.152-10.899-28.819-14.546-43.586c-4.249-17.232-6.741-33.201-6.741-42.245 | ||||
| 		c0-3.351,0.433-6.579,1.09-9.727l14.8,48.975c0.766,2.565,2.984,4.417,5.641,4.73c0.268,0.03,0.529,0.046,0.784,0.046 | ||||
| 		c2.365,0,4.602-1.25,5.818-3.34l11.538-19.873l3.246,3.235c-7.768,10.276-10.82,39.199-12.005,60.314 | ||||
| 		c5.994-0.734,12.066-1.222,18.254-1.222c6.201,0,12.292,0.497,18.304,1.23c-1.186-21.114-4.237-50.037-12.024-60.322l3.246-3.255 | ||||
| 		l11.574,19.892c1.216,2.09,3.422,3.34,5.805,3.34c0.255,0,0.522-0.016,0.779-0.046c2.655-0.314,4.874-2.166,5.659-4.73 | ||||
| 		l14.791-48.872c0.634,3.116,1.051,6.313,1.051,9.624c0,16.806-8.425,57.342-21.276,85.831 | ||||
| 		c19.981,3.768,38.588,11.453,54.953,22.291v-3.899l4.735,7.256c22.504,16.193,40.436,38.324,51.293,64.206l18.139-12.488 | ||||
| 		C414.919,410.335,418.403,392.594,409.772,379.327z M219.962,276.685l-8.613-28.53l12.388-8.24l12.322,9.088L219.962,276.685z | ||||
| 		 M269.783,276.685l-16.079-27.683l12.31-9.088l12.401,8.24L269.783,276.685z" | ||||
|         /> | ||||
|         <path | ||||
|           id="XMLID_202_" | ||||
|           d="M202.716,424.721l14.705,19.349c8.151-4.914,17.598-7.607,27.427-7.607c9.848,0,19.313,2.692,27.464,7.615 | ||||
| 		l14.705-19.363c-11.465-10.799-26.346-16.721-42.15-16.721C229.055,407.994,214.156,413.925,202.716,424.721z" | ||||
|         /> | ||||
|         <path | ||||
|           id="XMLID_201_" | ||||
|           d="M176.693,160.576c0.499,25.456,14.96,47.266,36.03,58.591c9.622,5.18,20.473,8.384,32.174,8.384 | ||||
| 		c11.683,0,22.503-3.198,32.114-8.368c21.063-11.311,35.579-33.117,36.06-58.582c-17.379,12.075-41.896,19.923-68.174,19.923 | ||||
| 		S194.096,172.676,176.693,160.576z" | ||||
|         /> | ||||
|         <path | ||||
|           id="XMLID_200_" | ||||
|           d="M174.741,100.132l-0.225,20.205c0.037,15.991,31.524,36.82,70.38,36.82 | ||||
| 		c38.855,0,70.314-20.829,70.331-36.82l-0.207-20.195c10.224-2.662,18.158-6.617,23.239-12.301 | ||||
| 		c3.981-4.434,6.267-9.902,6.267-16.783C344.528,39.883,299.879,0,244.897,0c-55.031,0-99.631,39.883-99.631,71.058 | ||||
| 		c0,6.881,2.273,12.34,6.236,16.783C156.585,93.524,164.529,97.479,174.741,100.132z" | ||||
|         /> | ||||
|         <path | ||||
|           id="XMLID_197_" | ||||
|           d="M244.848,356.925c-73.255,0-132.858,59.605-132.858,132.86h33.47c0-0.048,0-0.114,0-0.161v-0.031 | ||||
| 		c1.088-6.557,6.711-11.334,13.313-11.334c0.115,0,0.243,0.01,0.37,0.01l51.707,1.341c-0.973,3.247-1.648,6.619-1.648,10.176h71.322 | ||||
| 		c0-3.557-0.669-6.929-1.66-10.176l51.724-1.341c0.109,0,0.219-0.01,0.353-0.01c6.595,0,12.243,4.777,13.324,11.334v0.031 | ||||
| 		c0,0.047,0,0.113,0,0.161h33.44C377.706,416.53,318.122,356.925,244.848,356.925z M302.201,433.91l-27.562,36.317 | ||||
| 		c-6.389-9.687-17.325-16.104-29.792-16.104c-12.437,0-23.385,6.411-29.762,16.098l-27.555-36.3 | ||||
| 		c-4.699-6.194-4.11-14.923,1.392-20.424c15.452-15.443,35.689-23.166,55.943-23.166c20.249,0,40.484,7.723,55.961,23.179 | ||||
| 		C306.322,419.007,306.901,427.719,302.201,433.91z" | ||||
|         /> | ||||
|       </g> | ||||
|     </svg> | ||||
|   ); | ||||
| }; | ||||
| @@ -7,22 +7,24 @@ import { | ||||
|   Users, | ||||
|   Earth, | ||||
|   Landmark, | ||||
|   BusFront, | ||||
|   GitBranch, | ||||
|   Car, | ||||
|   Table, | ||||
|   Notebook, | ||||
|   Split, | ||||
|   Newspaper, | ||||
|   PersonStanding, | ||||
|   Cpu, | ||||
|   BookImage, | ||||
| } from "lucide-react"; | ||||
| import { CarrierSvg } from "./CarrierSvg"; | ||||
|  | ||||
| export const DRAWER_WIDTH = 300; | ||||
|  | ||||
| interface NavigationItem { | ||||
|   id: string; | ||||
|   label: string; | ||||
|   icon: LucideIcon; | ||||
|   icon?: LucideIcon | React.ReactNode; | ||||
|   path?: string; | ||||
|   onClick?: () => void; | ||||
|   nestedItems?: NavigationItem[]; | ||||
| @@ -34,43 +36,6 @@ export const NAVIGATION_ITEMS: { | ||||
|   secondary: NavigationItem[]; | ||||
| } = { | ||||
|   primary: [ | ||||
|     { | ||||
|       id: "countries", | ||||
|       label: "Страны", | ||||
|       icon: Earth, | ||||
|       path: "/country", | ||||
|     }, | ||||
|     { | ||||
|       id: "cities", | ||||
|       label: "Города", | ||||
|       icon: Building2, | ||||
|       path: "/city", | ||||
|     }, | ||||
|     { | ||||
|       id: "carriers", | ||||
|       label: "Перевозчики", | ||||
|       icon: BusFront, | ||||
|       path: "/carrier", | ||||
|     }, | ||||
|  | ||||
|     { | ||||
|       id: "snapshots", | ||||
|       label: "Снапшоты", | ||||
|       icon: GitBranch, | ||||
|       path: "/snapshot", | ||||
|     }, | ||||
|     { | ||||
|       id: "map", | ||||
|       label: "Карта", | ||||
|       icon: Map, | ||||
|       path: "/map", | ||||
|     }, | ||||
|     { | ||||
|       id: "devices", | ||||
|       label: "Устройства", | ||||
|       icon: Cpu, | ||||
|       path: "/devices", | ||||
|     }, | ||||
|     { | ||||
|       id: "all", | ||||
|       label: "Все сущности", | ||||
| @@ -106,15 +71,58 @@ export const NAVIGATION_ITEMS: { | ||||
|           icon: Split, | ||||
|           path: "/route", | ||||
|         }, | ||||
|         { | ||||
|           id: "reference", | ||||
|           label: "Справочник", | ||||
|           icon: Notebook, | ||||
|           nestedItems: [ | ||||
|             { | ||||
|               id: "countries", | ||||
|               label: "Страны", | ||||
|               icon: Earth, | ||||
|               path: "/country", | ||||
|             }, | ||||
|             { | ||||
|               id: "cities", | ||||
|               label: "Города", | ||||
|               icon: Building2, | ||||
|               path: "/city", | ||||
|             }, | ||||
|             { | ||||
|               id: "carriers", | ||||
|               label: "Перевозчики", | ||||
|               // @ts-ignore | ||||
|               icon: CarrierSvg, | ||||
|               path: "/carrier", | ||||
|             }, | ||||
|           ], | ||||
|         }, | ||||
|       ], | ||||
|     }, | ||||
|  | ||||
|     { | ||||
|       id: "vehicles", | ||||
|       label: "Транспорт", | ||||
|       icon: Car, | ||||
|       path: "/vehicle", | ||||
|       id: "snapshots", | ||||
|       label: "Снапшоты", | ||||
|       icon: GitBranch, | ||||
|       path: "/snapshot", | ||||
|     }, | ||||
|     { | ||||
|       id: "map", | ||||
|       label: "Карта", | ||||
|       icon: Map, | ||||
|       path: "/map", | ||||
|     }, | ||||
|     { | ||||
|       id: "devices", | ||||
|       label: "Устройства", | ||||
|       icon: Cpu, | ||||
|       path: "/devices", | ||||
|     }, | ||||
|     // { | ||||
|     //   id: "vehicles", | ||||
|     //   label: "Транспорт", | ||||
|     //   icon: Car, | ||||
|     //   path: "/vehicle", | ||||
|     // }, | ||||
|     { | ||||
|       id: "users", | ||||
|       label: "Пользователи", | ||||
|   | ||||
| @@ -120,7 +120,6 @@ export const PreviewMediaDialog = observer( | ||||
|                   disabled={isLoading} | ||||
|                 /> | ||||
|               </Box> | ||||
|  | ||||
|               <TextField | ||||
|                 fullWidth | ||||
|                 label="Тип медиа" | ||||
| @@ -133,7 +132,7 @@ export const PreviewMediaDialog = observer( | ||||
|                 sx={{ width: "50%" }} | ||||
|               /> | ||||
|  | ||||
|               <Box className="flex gap-4 h-full"> | ||||
|               <Box className="flex gap-4"> | ||||
|                 <Paper | ||||
|                   elevation={2} | ||||
|                   sx={{ | ||||
| @@ -142,8 +141,8 @@ export const PreviewMediaDialog = observer( | ||||
|                     display: "flex", | ||||
|                     alignItems: "center", | ||||
|                     justifyContent: "center", | ||||
|                     minHeight: 400, | ||||
|                   }} | ||||
|                   className="max-h-[40vh]" | ||||
|                 > | ||||
|                   <MediaViewer | ||||
|                     media={{ | ||||
| @@ -151,6 +150,7 @@ export const PreviewMediaDialog = observer( | ||||
|                       media_type: media.media_type, | ||||
|                       filename: media.filename, | ||||
|                     }} | ||||
|                     fullHeight | ||||
|                   /> | ||||
|                 </Paper> | ||||
|  | ||||
|   | ||||
| @@ -188,7 +188,7 @@ export const UploadMediaDialog = observer( | ||||
|                 </Select> | ||||
|               </FormControl> | ||||
|  | ||||
|               <Box className="flex gap-4 h-full"> | ||||
|               <Box className="flex gap-4 h-[40vh]"> | ||||
|                 <Paper | ||||
|                   elevation={2} | ||||
|                   sx={{ | ||||
| @@ -197,7 +197,7 @@ export const UploadMediaDialog = observer( | ||||
|                     display: "flex", | ||||
|                     alignItems: "center", | ||||
|                     justifyContent: "center", | ||||
|                     minHeight: 400, | ||||
|                     height: "100%", | ||||
|                   }} | ||||
|                 > | ||||
|                   {/* <MediaViewer | ||||
|   | ||||
| @@ -1,4 +1,10 @@ | ||||
| import { authInstance, editSightStore, Language, languageStore } from "@shared"; | ||||
| import { | ||||
|   authInstance, | ||||
|   editSightStore, | ||||
|   Language, | ||||
|   languageStore, | ||||
|   languageInstance, | ||||
| } from "@shared"; | ||||
| import { computed, makeAutoObservable, runInAction } from "mobx"; | ||||
|  | ||||
| export type Article = { | ||||
| @@ -6,6 +12,18 @@ export type Article = { | ||||
|   heading: string; | ||||
|   body: string; | ||||
|   service_name: string; | ||||
|   ru?: { | ||||
|     heading: string; | ||||
|     body: string; | ||||
|   }; | ||||
|   en?: { | ||||
|     heading: string; | ||||
|     body: string; | ||||
|   }; | ||||
|   zh?: { | ||||
|     heading: string; | ||||
|     body: string; | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| type Media = { | ||||
| @@ -99,13 +117,25 @@ class ArticlesStore { | ||||
|     this.articleLoading = false; | ||||
|   }; | ||||
|  | ||||
|   getArticle = async (id: number) => { | ||||
|   getArticle = async (id: number, language?: Language) => { | ||||
|     this.articleLoading = true; | ||||
|     const response = await authInstance.get(`/article/${id}`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.articleData = response.data; | ||||
|     }); | ||||
|     if (language) { | ||||
|       const response = await languageInstance(language).get(`/article/${id}`); | ||||
|       runInAction(() => { | ||||
|         if (!this.articleData) { | ||||
|           this.articleData = { id, heading: "", body: "", service_name: "" }; | ||||
|         } | ||||
|         this.articleData[language] = { | ||||
|           heading: response.data.heading, | ||||
|           body: response.data.body, | ||||
|         }; | ||||
|       }); | ||||
|     } else { | ||||
|       const response = await authInstance.get(`/article/${id}`); | ||||
|       runInAction(() => { | ||||
|         this.articleData = response.data; | ||||
|       }); | ||||
|     } | ||||
|     this.articleLoading = false; | ||||
|   }; | ||||
|  | ||||
| @@ -137,6 +167,20 @@ class ArticlesStore { | ||||
|     } | ||||
|     return null; | ||||
|   }); | ||||
|  | ||||
|   deleteArticles = async (ids: number[]) => { | ||||
|     for (const id of ids) { | ||||
|       await authInstance.delete(`/article/${id}`); | ||||
|     } | ||||
|  | ||||
|     for (const id of ["ru", "en", "zh"] as Language[]) { | ||||
|       runInAction(() => { | ||||
|         this.articleList[id].data = this.articleList[id].data.filter( | ||||
|           (article) => !ids.includes(article.id) | ||||
|         ); | ||||
|       }); | ||||
|     } | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export const articlesStore = new ArticlesStore(); | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { authInstance } from "@shared"; | ||||
| import { authInstance, cityStore, languageStore } from "@shared"; | ||||
| import { makeAutoObservable, runInAction } from "mobx"; | ||||
|  | ||||
| export type Carrier = { | ||||
| @@ -9,9 +9,9 @@ export type Carrier = { | ||||
|   city: string; | ||||
|   city_id: number; | ||||
|   logo: string; | ||||
|   main_color: string; | ||||
|   left_color: string; | ||||
|   right_color: string; | ||||
|   // main_color: string; | ||||
|   // left_color: string; | ||||
|   // right_color: string; | ||||
| }; | ||||
|  | ||||
| type Carriers = { | ||||
| @@ -68,9 +68,9 @@ class CarrierStore { | ||||
|           city: "", | ||||
|           city_id: 0, | ||||
|           logo: "", | ||||
|           main_color: "", | ||||
|           left_color: "", | ||||
|           right_color: "", | ||||
|           // main_color: "", | ||||
|           // left_color: "", | ||||
|           // right_color: "", | ||||
|         }; | ||||
|       } | ||||
|       this.carrier[id] = response.data; | ||||
| @@ -81,22 +81,22 @@ class CarrierStore { | ||||
|   createCarrier = async ( | ||||
|     fullName: string, | ||||
|     shortName: string, | ||||
|     city: string, | ||||
|  | ||||
|     cityId: number, | ||||
|     main_color: string, | ||||
|     left_color: string, | ||||
|     right_color: string, | ||||
|     // main_color: string, | ||||
|     // left_color: string, | ||||
|     // right_color: string, | ||||
|     slogan: string, | ||||
|     logoId: string | ||||
|   ) => { | ||||
|     const response = await authInstance.post("/carrier", { | ||||
|       full_name: fullName, | ||||
|       short_name: shortName, | ||||
|       city, | ||||
|       city: "", | ||||
|       city_id: cityId, | ||||
|       main_color, | ||||
|       left_color, | ||||
|       right_color, | ||||
|       // main_color, | ||||
|       // left_color, | ||||
|       // right_color, | ||||
|       slogan, | ||||
|       logo: logoId, | ||||
|     }); | ||||
| @@ -108,11 +108,11 @@ class CarrierStore { | ||||
|   editCarrierData = { | ||||
|     full_name: "", | ||||
|     short_name: "", | ||||
|     city: "", | ||||
|  | ||||
|     city_id: 0, | ||||
|     main_color: "", | ||||
|     left_color: "", | ||||
|     right_color: "", | ||||
|     // main_color: "", | ||||
|     // left_color: "", | ||||
|     // right_color: "", | ||||
|     slogan: "", | ||||
|     logo: "", | ||||
|   }; | ||||
| @@ -120,32 +120,35 @@ class CarrierStore { | ||||
|   setEditCarrierData = ( | ||||
|     fullName: string, | ||||
|     shortName: string, | ||||
|     city: string, | ||||
|  | ||||
|     cityId: number, | ||||
|     main_color: string, | ||||
|     left_color: string, | ||||
|     right_color: string, | ||||
|     // main_color: string, | ||||
|     // left_color: string, | ||||
|     // right_color: string, | ||||
|     slogan: string, | ||||
|     logoId: string | ||||
|   ) => { | ||||
|     this.editCarrierData = { | ||||
|       full_name: fullName, | ||||
|       short_name: shortName, | ||||
|       city, | ||||
|  | ||||
|       city_id: cityId, | ||||
|       main_color: main_color, | ||||
|       left_color: left_color, | ||||
|       right_color: right_color, | ||||
|       // main_color: main_color, | ||||
|       // left_color: left_color, | ||||
|       // right_color: right_color, | ||||
|       slogan: slogan, | ||||
|       logo: logoId, | ||||
|     }; | ||||
|   }; | ||||
|  | ||||
|   editCarrier = async (id: number) => { | ||||
|     const response = await authInstance.patch( | ||||
|       `/carrier/${id}`, | ||||
|       this.editCarrierData | ||||
|     ); | ||||
|     const { language } = languageStore; | ||||
|     const response = await authInstance.patch(`/carrier/${id}`, { | ||||
|       ...this.editCarrierData, | ||||
|       city: cityStore.cities[language].data.find( | ||||
|         (city) => city.id === this.editCarrierData.city_id | ||||
|       )?.name, | ||||
|     }); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.carriers.data = this.carriers.data.map((carrier) => | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import { | ||||
|   Language, | ||||
|   languageStore, | ||||
|   countryStore, | ||||
|   CashedCountries, | ||||
| } from "@shared"; | ||||
| import { makeAutoObservable, runInAction } from "mobx"; | ||||
|  | ||||
| @@ -16,9 +17,18 @@ export type City = { | ||||
| }; | ||||
|  | ||||
| export type CashedCities = { | ||||
|   ru: City[]; | ||||
|   en: City[]; | ||||
|   zh: City[]; | ||||
|   ru: { | ||||
|     data: City[]; | ||||
|     loaded: boolean; | ||||
|   }; | ||||
|   en: { | ||||
|     data: City[]; | ||||
|     loaded: boolean; | ||||
|   }; | ||||
|   zh: { | ||||
|     data: City[]; | ||||
|     loaded: boolean; | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export type CashedCity = { | ||||
| @@ -29,9 +39,18 @@ export type CashedCity = { | ||||
|  | ||||
| class CityStore { | ||||
|   cities: CashedCities = { | ||||
|     ru: [], | ||||
|     en: [], | ||||
|     zh: [], | ||||
|     ru: { | ||||
|       data: [], | ||||
|       loaded: false, | ||||
|     }, | ||||
|     en: { | ||||
|       data: [], | ||||
|       loaded: false, | ||||
|     }, | ||||
|     zh: { | ||||
|       data: [], | ||||
|       loaded: false, | ||||
|     }, | ||||
|   }; | ||||
|  | ||||
|   city: Record<string, CashedCity> = {}; | ||||
| @@ -40,25 +59,37 @@ class CityStore { | ||||
|     makeAutoObservable(this); | ||||
|   } | ||||
|  | ||||
|   ruCities: City[] = []; | ||||
|   ruCities: { | ||||
|     data: City[]; | ||||
|     loaded: boolean; | ||||
|   } = { | ||||
|     data: [], | ||||
|     loaded: false, | ||||
|   }; | ||||
|  | ||||
|   getRuCities = async () => { | ||||
|     if (this.ruCities.loaded) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const response = await languageInstance("ru").get(`/city`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.ruCities = response.data; | ||||
|       this.ruCities.data = response.data; | ||||
|       this.ruCities.loaded = true; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   getCities = async (language: keyof CashedCities) => { | ||||
|     if (this.cities[language] && this.cities[language].length > 0) { | ||||
|     if (this.cities[language].loaded) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const response = await authInstance.get(`/city`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.cities[language] = response.data; | ||||
|       this.cities[language].data = response.data; | ||||
|       this.cities[language].loaded = true; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
| @@ -83,19 +114,22 @@ class CityStore { | ||||
|     return response.data; | ||||
|   }; | ||||
|  | ||||
|   deleteCity = async (code: string, language: keyof CashedCities) => { | ||||
|   deleteCity = async (code: string) => { | ||||
|     await authInstance.delete(`/city/${code}`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.cities[language] = this.cities[language].filter( | ||||
|         (city) => city.country_code !== code | ||||
|       ); | ||||
|       this.city[code][language] = null; | ||||
|       for (const secondaryLanguage of ["ru", "en", "zh"] as Language[]) { | ||||
|         this.cities[secondaryLanguage].data = this.cities[ | ||||
|           secondaryLanguage | ||||
|         ].data.filter((city) => city.id !== Number(code)); | ||||
|         if (this.city[code]) { | ||||
|           this.city[code][secondaryLanguage] = null; | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   createCityData = { | ||||
|     country: "", | ||||
|     country_code: "", | ||||
|     arms: "", | ||||
|     ru: { | ||||
| @@ -111,14 +145,12 @@ class CityStore { | ||||
|  | ||||
|   setCreateCityData = ( | ||||
|     name: string, | ||||
|     country: string, | ||||
|     country_code: string, | ||||
|     arms: string, | ||||
|     language: keyof CashedCities | ||||
|   ) => { | ||||
|     this.createCityData = { | ||||
|       ...this.createCityData, | ||||
|       country: country, | ||||
|       country_code: country_code, | ||||
|       arms: arms, | ||||
|       [language]: { | ||||
| @@ -127,73 +159,84 @@ class CityStore { | ||||
|     }; | ||||
|   }; | ||||
|  | ||||
|   createCity = async () => { | ||||
|     const { language } = languageStore; | ||||
|     const { country, country_code, arms } = this.createCityData; | ||||
|     const { name } = this.createCityData[language as keyof CashedCities]; | ||||
|   async createCity() { | ||||
|     const language = languageStore.language as Language; | ||||
|     const { country_code, arms } = this.createCityData; | ||||
|     const { name } = this.createCityData[language]; | ||||
|  | ||||
|     if (name && country && country_code && arms) { | ||||
|       const cityResponse = await languageInstance(language as Language).post( | ||||
|         "/city", | ||||
|         { | ||||
|           name: name, | ||||
|           country: country, | ||||
|           country_code: country_code, | ||||
|           arms: arms, | ||||
|         } | ||||
|       ); | ||||
|     if (!name || !country_code) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|       runInAction(() => { | ||||
|         this.cities[language as keyof CashedCities] = [ | ||||
|           ...this.cities[language as keyof CashedCities], | ||||
|           cityResponse.data, | ||||
|         ]; | ||||
|     try { | ||||
|       // Create city in primary language | ||||
|       const cityResponse = await languageInstance(language).post("/city", { | ||||
|         name, | ||||
|         country: | ||||
|           countryStore.countries[language as keyof CashedCountries]?.data.find( | ||||
|             (c) => c.code === country_code | ||||
|           )?.name || "", | ||||
|         country_code, | ||||
|         arms: arms || "", | ||||
|       }); | ||||
|  | ||||
|       for (const secondaryLanguage of ["ru", "en", "zh"].filter( | ||||
|       const cityId = cityResponse.data.id; | ||||
|  | ||||
|       // Create/update other language versions | ||||
|       for (const secondaryLanguage of (["ru", "en", "zh"] as Language[]).filter( | ||||
|         (l) => l !== language | ||||
|       )) { | ||||
|         const { name } = | ||||
|           this.createCityData[secondaryLanguage as keyof CashedCities]; | ||||
|         const { name: secondaryName } = this.createCityData[secondaryLanguage]; | ||||
|  | ||||
|         const patchResponse = await languageInstance( | ||||
|           secondaryLanguage as Language | ||||
|         ).patch(`/city/${cityResponse.data.id}`, { | ||||
|           name: name, | ||||
|           country: country, | ||||
|           country_code: country_code, | ||||
|           arms: arms, | ||||
|         }); | ||||
|         // Get country name in secondary language | ||||
|         const countryName = | ||||
|           countryStore.countries[secondaryLanguage]?.data.find( | ||||
|             (c) => c.code === country_code | ||||
|           )?.name || ""; | ||||
|  | ||||
|         const patchResponse = await languageInstance(secondaryLanguage).patch( | ||||
|           `/city/${cityId}`, | ||||
|           { | ||||
|             name: secondaryName || "", | ||||
|             country: countryName, | ||||
|             country_code: country_code || "", | ||||
|             arms: arms || "", | ||||
|           } | ||||
|         ); | ||||
|  | ||||
|         runInAction(() => { | ||||
|           this.cities[secondaryLanguage as keyof CashedCities] = [ | ||||
|             ...this.cities[secondaryLanguage as keyof CashedCities], | ||||
|           this.cities[secondaryLanguage].data = [ | ||||
|             ...this.cities[secondaryLanguage].data, | ||||
|             patchResponse.data, | ||||
|           ]; | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.createCityData = { | ||||
|         country: "", | ||||
|         country_code: "", | ||||
|         arms: "", | ||||
|         ru: { | ||||
|           name: "", | ||||
|         }, | ||||
|         en: { | ||||
|           name: "", | ||||
|         }, | ||||
|         zh: { | ||||
|           name: "", | ||||
|         }, | ||||
|       }; | ||||
|     }); | ||||
|   }; | ||||
|       // Update primary language data | ||||
|       runInAction(() => { | ||||
|         this.cities[language].data = [ | ||||
|           ...this.cities[language].data, | ||||
|           cityResponse.data, | ||||
|         ]; | ||||
|       }); | ||||
|  | ||||
|       // Reset form data | ||||
|       runInAction(() => { | ||||
|         this.createCityData = { | ||||
|           country_code: "", | ||||
|           arms: "", | ||||
|           ru: { name: "" }, | ||||
|           en: { name: "" }, | ||||
|           zh: { name: "" }, | ||||
|         }; | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       console.error("Error creating city:", error); | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   editCityData = { | ||||
|     country: "", | ||||
|     country_code: "", | ||||
|     arms: "", | ||||
|     ru: { | ||||
| @@ -209,14 +252,12 @@ class CityStore { | ||||
|  | ||||
|   setEditCityData = ( | ||||
|     name: string, | ||||
|     country: string, | ||||
|     country_code: string, | ||||
|     arms: string, | ||||
|     language: keyof CashedCities | ||||
|   ) => { | ||||
|     this.editCityData = { | ||||
|       ...this.editCityData, | ||||
|       country: country, | ||||
|       country_code: country_code, | ||||
|       arms: arms, | ||||
|  | ||||
| @@ -232,7 +273,7 @@ class CityStore { | ||||
|       const { name } = this.editCityData[language as keyof CashedCities]; | ||||
|       const { countries } = countryStore; | ||||
|  | ||||
|       const country = countries[language as keyof CashedCities].find( | ||||
|       const country = countries[language as keyof CashedCities].data.find( | ||||
|         (country) => country.code === country_code | ||||
|       ); | ||||
|  | ||||
| @@ -255,9 +296,9 @@ class CityStore { | ||||
|           } | ||||
|  | ||||
|           if (this.cities[language as keyof CashedCities]) { | ||||
|             this.cities[language as keyof CashedCities] = this.cities[ | ||||
|             this.cities[language as keyof CashedCities].data = this.cities[ | ||||
|               language as keyof CashedCities | ||||
|             ].map((city) => | ||||
|             ].data.map((city) => | ||||
|               city.id === Number(code) | ||||
|                 ? { | ||||
|                     id: city.id, | ||||
|   | ||||
| @@ -12,9 +12,18 @@ export type Country = { | ||||
| }; | ||||
|  | ||||
| export type CashedCountries = { | ||||
|   ru: Country[]; | ||||
|   en: Country[]; | ||||
|   zh: Country[]; | ||||
|   ru: { | ||||
|     data: Country[]; | ||||
|     loaded: boolean; | ||||
|   }; | ||||
|   en: { | ||||
|     data: Country[]; | ||||
|     loaded: boolean; | ||||
|   }; | ||||
|   zh: { | ||||
|     data: Country[]; | ||||
|     loaded: boolean; | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export type CashedCountry = { | ||||
| @@ -25,9 +34,18 @@ export type CashedCountry = { | ||||
|  | ||||
| class CountryStore { | ||||
|   countries: CashedCountries = { | ||||
|     ru: [], | ||||
|     en: [], | ||||
|     zh: [], | ||||
|     ru: { | ||||
|       data: [], | ||||
|       loaded: false, | ||||
|     }, | ||||
|     en: { | ||||
|       data: [], | ||||
|       loaded: false, | ||||
|     }, | ||||
|     zh: { | ||||
|       data: [], | ||||
|       loaded: false, | ||||
|     }, | ||||
|   }; | ||||
|  | ||||
|   country: Record<string, CashedCountry> = {}; | ||||
| @@ -37,14 +55,15 @@ class CountryStore { | ||||
|   } | ||||
|  | ||||
|   getCountries = async (language: keyof CashedCountries) => { | ||||
|     if (this.countries[language] && this.countries[language].length > 0) { | ||||
|     if (this.countries[language].loaded) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const response = await authInstance.get(`/country`); | ||||
|     const response = await languageInstance(language).get(`/country`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.countries[language] = response.data; | ||||
|       this.countries[language].data = response.data; | ||||
|       this.countries[language].loaded = true; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
| @@ -76,10 +95,15 @@ class CountryStore { | ||||
|     await authInstance.delete(`/country/${code}`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.countries[language] = this.countries[language].filter( | ||||
|       this.countries[language].data = this.countries[language].data.filter( | ||||
|         (country) => country.code !== code | ||||
|       ); | ||||
|       this.country[code][language] = null; | ||||
|       this.countries[language].loaded = true; | ||||
|       this.country[code] = { | ||||
|         ru: null, | ||||
|         en: null, | ||||
|         zh: null, | ||||
|       }; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
| @@ -121,8 +145,8 @@ class CountryStore { | ||||
|       }); | ||||
|  | ||||
|       runInAction(() => { | ||||
|         this.countries[language as keyof CashedCountries] = [ | ||||
|           ...this.countries[language as keyof CashedCountries], | ||||
|         this.countries[language as keyof CashedCountries].data = [ | ||||
|           ...this.countries[language as keyof CashedCountries].data, | ||||
|           { code: code, name: name }, | ||||
|         ]; | ||||
|       }); | ||||
| @@ -142,8 +166,8 @@ class CountryStore { | ||||
|           ); | ||||
|         } | ||||
|         runInAction(() => { | ||||
|           this.countries[secondaryLanguage as keyof CashedCountries] = [ | ||||
|             ...this.countries[secondaryLanguage as keyof CashedCountries], | ||||
|           this.countries[secondaryLanguage as keyof CashedCountries].data = [ | ||||
|             ...this.countries[secondaryLanguage as keyof CashedCountries].data, | ||||
|             { code: code, name: name }, | ||||
|           ]; | ||||
|         }); | ||||
| @@ -204,11 +228,10 @@ class CountryStore { | ||||
|             }; | ||||
|           } | ||||
|           if (this.countries[language as keyof CashedCountries]) { | ||||
|             this.countries[language as keyof CashedCountries] = this.countries[ | ||||
|               language as keyof CashedCountries | ||||
|             ].map((country) => | ||||
|               country.code === code ? { code, name } : country | ||||
|             ); | ||||
|             this.countries[language as keyof CashedCountries].data = | ||||
|               this.countries[language as keyof CashedCountries].data.map( | ||||
|                 (country) => (country.code === code ? { code, name } : country) | ||||
|               ); | ||||
|           } | ||||
|         }); | ||||
|       } | ||||
|   | ||||
| @@ -2,15 +2,20 @@ import { Canvas } from "@react-three/fiber"; | ||||
| import { OrbitControls, Stage, useGLTF } from "@react-three/drei"; | ||||
|  | ||||
| type ModelViewerProps = { | ||||
|   width?: string; | ||||
|   fileUrl: string; | ||||
|   height?: string; | ||||
| }; | ||||
|  | ||||
| export const ThreeView = ({ fileUrl, height = "100%" }: ModelViewerProps) => { | ||||
| export const ThreeView = ({ | ||||
|   fileUrl, | ||||
|   height = "100%", | ||||
|   width = "100%", | ||||
| }: ModelViewerProps) => { | ||||
|   const { scene } = useGLTF(fileUrl); | ||||
|  | ||||
|   return ( | ||||
|     <Canvas style={{ width: "100%", height: height }}> | ||||
|     <Canvas style={{ height: height, width: width }}> | ||||
|       <ambientLight /> | ||||
|       <directionalLight /> | ||||
|       <Stage environment="city" intensity={0.6}> | ||||
|   | ||||
| @@ -13,16 +13,30 @@ export function MediaViewer({ | ||||
|   media, | ||||
|   className, | ||||
|   fullWidth, | ||||
| }: Readonly<{ media?: MediaData; className?: string; fullWidth?: boolean }>) { | ||||
|   fullHeight, | ||||
| }: Readonly<{ | ||||
|   media?: MediaData; | ||||
|   className?: string; | ||||
|   fullWidth?: boolean; | ||||
|   fullHeight?: boolean; | ||||
| }>) { | ||||
|   const token = localStorage.getItem("token"); | ||||
|   return ( | ||||
|     <Box className={className} width={fullWidth ? "100%" : "auto"}> | ||||
|     <Box | ||||
|       className={className} | ||||
|       width={fullWidth ? "100%" : "auto"} | ||||
|       height={fullHeight ? "100%" : "auto"} | ||||
|     > | ||||
|       {media?.media_type === 1 && ( | ||||
|         <img | ||||
|           src={`${import.meta.env.VITE_KRBL_MEDIA}${ | ||||
|             media?.id | ||||
|           }/download?token=${token}`} | ||||
|           alt={media?.filename} | ||||
|           style={{ | ||||
|             height: fullHeight ? "100%" : "auto", | ||||
|             width: fullWidth ? "100%" : "auto", | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
| @@ -48,6 +62,10 @@ export function MediaViewer({ | ||||
|             media?.id | ||||
|           }/download?token=${token}`} | ||||
|           alt={media?.filename} | ||||
|           style={{ | ||||
|             height: fullHeight ? "100%" : "auto", | ||||
|             width: fullWidth ? "100%" : "auto", | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|       {media?.media_type === 4 && ( | ||||
| @@ -78,6 +96,7 @@ export function MediaViewer({ | ||||
|             media?.id | ||||
|           }/download?token=${token}`} | ||||
|           height="100%" | ||||
|           width="1000px" | ||||
|         /> | ||||
|       )} | ||||
|     </Box> | ||||
|   | ||||
| @@ -163,17 +163,22 @@ export const InformationTab = observer( | ||||
|                 /> | ||||
|  | ||||
|                 <Autocomplete | ||||
|                   options={ruCities ?? []} | ||||
|                   options={ruCities?.data ?? []} | ||||
|                   value={ | ||||
|                     ruCities.find((city) => city.id === sight.common.city_id) ?? | ||||
|                     null | ||||
|                     ruCities?.data?.find( | ||||
|                       (city) => city.id === sight.common.city_id | ||||
|                     ) ?? null | ||||
|                   } | ||||
|                   getOptionLabel={(option) => option.name} | ||||
|                   onChange={(_, value) => { | ||||
|                     setCity(value?.id ?? 0); | ||||
|                     handleChange(language as Language, { | ||||
|                       city_id: value?.id ?? 0, | ||||
|                     }); | ||||
|                     handleChange( | ||||
|                       language as Language, | ||||
|                       { | ||||
|                         city_id: value?.id ?? 0, | ||||
|                       }, | ||||
|                       true | ||||
|                     ); | ||||
|                   }} | ||||
|                   renderInput={(params) => ( | ||||
|                     <TextField {...params} label="Город" /> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user