feat: Add more pages

This commit is contained in:
2025-06-06 16:08:15 +03:00
parent f2aab1ab33
commit d74789a0d8
67 changed files with 3491 additions and 787 deletions

1
src/App.tsx Normal file
View File

@ -0,0 +1 @@

View File

@ -8,7 +8,6 @@ import { ToastContainer } from "react-toastify";
export const App: React.FC = () => (
<ThemeProvider theme={CustomTheme.Light}>
<ToastContainer />
<Router />
</ThemeProvider>
);

View File

@ -4,11 +4,31 @@ import {
EditSightPage,
LoginPage,
MainPage,
SightPage,
SightListPage,
MapPage,
MediaListPage,
PreviewMediaPage,
EditMediaPage,
MediaPreviewPage,
MediaEditPage,
CountryListPage,
CityListPage,
RouteListPage,
UserListPage,
SnapshotListPage,
CarrierListPage,
StationListPage,
VehicleListPage,
ArticleListPage,
CityPreviewPage,
UserPreviewPage,
CountryPreviewPage,
SnapshotPreviewPage,
VehiclePreviewPage,
CarrierPreviewPage,
SnapshotCreatePage,
CountryCreatePage,
CityCreatePage,
// CarrierCreatePage,
VehicleCreatePage,
} from "@pages";
import { authStore, createSightStore, editSightStore } from "@shared";
import { Layout } from "@widgets";
@ -82,14 +102,59 @@ const router = createBrowserRouter([
),
children: [
{ index: true, element: <MainPage /> },
{ path: "sight", element: <SightPage /> },
// Sight
{ path: "sight", element: <SightListPage /> },
{ path: "sight/create", element: <CreateSightPage /> },
{ path: "sight/:id", element: <EditSightPage /> },
// Device
{ path: "devices", element: <DevicesPage /> },
// Map
{ path: "map", element: <MapPage /> },
// Media
{ path: "media", element: <MediaListPage /> },
{ path: "media/:id", element: <PreviewMediaPage /> },
{ path: "media/:id/edit", element: <EditMediaPage /> },
{ path: "media/:id", element: <MediaPreviewPage /> },
{ path: "media/:id/edit", element: <MediaEditPage /> },
// Country
{ path: "country", element: <CountryListPage /> },
{ path: "country/create", element: <CountryCreatePage /> },
{ path: "country/:id", element: <CountryPreviewPage /> },
// City
{ path: "city", element: <CityListPage /> },
{ path: "city/create", element: <CityCreatePage /> },
{ path: "city/:id", element: <CityPreviewPage /> },
// Route
{ path: "route", element: <RouteListPage /> },
// User
{ path: "user", element: <UserListPage /> },
{ path: "user/:id", element: <UserPreviewPage /> },
// Snapshot
{ path: "snapshot", element: <SnapshotListPage /> },
{ path: "snapshot/create", element: <SnapshotCreatePage /> },
{ path: "snapshot/:id", element: <SnapshotPreviewPage /> },
// Carrier
{ path: "carrier", element: <CarrierListPage /> },
// { path: "carrier/create", element: <CarrierCreatePage /> },
{ path: "carrier/:id", element: <CarrierPreviewPage /> },
// Station
{ path: "station", element: <StationListPage /> },
// Vehicle
{ path: "vehicle", element: <VehicleListPage /> },
{ path: "vehicle/create", element: <VehicleCreatePage /> },
{ path: "vehicle/:id", element: <VehiclePreviewPage /> },
// Article
{ path: "article", element: <ArticleListPage /> },
// { path: "media/create", element: <CreateMediaPage /> },
],
},

View File

@ -1,6 +1,7 @@
import { createRoot } from "react-dom/client";
import { App } from "./app";
import "./index.css";
import { App } from "./app/index";
const container = document.getElementById("root");
const root = createRoot(container!);

View File

@ -0,0 +1,86 @@
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, FileText } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { DeleteModal, LanguageSwitcher } from "@widgets";
export const ArticleListPage = observer(() => {
const { articleList, getArticleList } = articlesStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
const { language } = languageStore;
useEffect(() => {
getArticleList();
}, [language]);
const columns: GridColDef[] = [
{
field: "heading",
headerName: "Название",
flex: 1,
},
{
field: "actions",
headerName: "Действия",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/media/${params.row.id}`)}>
<FileText size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = articleList.map((article) => ({
id: article.id,
heading: article.heading,
body: article.body,
}));
return (
<>
<LanguageSwitcher />
<div className="w-full">
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
getArticleList();
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

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

View File

@ -0,0 +1,202 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
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 { useState, useEffect } from "react";
import { LanguageSwitcher, MediaViewer } from "@widgets";
import { HexColorPicker } from "react-colorful";
export const CarrierCreatePage = observer(() => {
const navigate = useNavigate();
const [fullName, setFullName] = useState("");
const [shortName, setShortName] = useState("");
const [cityId, setCityId] = useState<number | null>(null);
const [primaryColor, setPrimaryColor] = useState("#000000");
const [secondaryColor, setSecondaryColor] = useState("#ffffff");
const [accentColor, setAccentColor] = useState("#ff0000");
const [slogan, setSlogan] = useState("");
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
cityStore.getCities();
mediaStore.getMedia();
}, []);
const handleCreate = async () => {
try {
setIsLoading(true);
await carrierStore.createCarrier(
fullName,
shortName,
cityStore.cities.find((c) => c.id === cityId)?.name!,
cityId!,
primaryColor,
secondaryColor,
accentColor,
slogan,
selectedMediaId!
);
toast.success("Перевозчик успешно создан");
navigate("/carrier");
} catch (error) {
toast.error("Ошибка при создании перевозчика");
} finally {
setIsLoading(false);
}
};
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"
onClick={() => navigate("/carrier")}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<FormControl fullWidth>
<InputLabel>Город</InputLabel>
<Select
value={cityId || ""}
label="Город"
required
onChange={(e) => setCityId(e.target.value as number)}
>
{cityStore.cities.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
label="Полное название"
value={fullName}
required
onChange={(e) => setFullName(e.target.value)}
/>
<TextField
fullWidth
label="Короткое название"
value={shortName}
required
onChange={(e) => setShortName(e.target.value)}
/>
<div className="w-full flex flex-col gap-4">
<div className="flex items-center gap-4">
<span className="w-32">Основной цвет:</span>
<Box
sx={{
width: 40,
height: 40,
backgroundColor: primaryColor,
border: "1px solid #ccc",
cursor: "pointer",
}}
/>
<HexColorPicker color={primaryColor} onChange={setPrimaryColor} />
</div>
<div className="flex items-center gap-4">
<span className="w-32">Вторичный цвет:</span>
<Box
sx={{
width: 40,
height: 40,
backgroundColor: secondaryColor,
border: "1px solid #ccc",
cursor: "pointer",
}}
/>
<HexColorPicker
color={secondaryColor}
onChange={setSecondaryColor}
/>
</div>
<div className="flex items-center gap-4">
<span className="w-32">Акцентный цвет:</span>
<Box
sx={{
width: 40,
height: 40,
backgroundColor: accentColor,
border: "1px solid #ccc",
cursor: "pointer",
}}
/>
<HexColorPicker color={accentColor} onChange={setAccentColor} />
</div>
</div>
<TextField
fullWidth
label="Слоган"
value={slogan}
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.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>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={
isLoading || !fullName || !shortName || !cityId || !selectedMediaId
}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,101 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { carrierStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { DeleteModal, LanguageSwitcher } from "@widgets";
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 { language } = languageStore;
useEffect(() => {
getCarriers();
}, [language]);
const columns: GridColDef[] = [
{
field: "full_name",
headerName: "Полное имя",
width: 300,
},
{
field: "short_name",
headerName: "Короткое имя",
width: 200,
},
{
field: "city",
headerName: "Город",
flex: 1,
},
{
field: "actions",
headerName: "Действия",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/carrier/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = carriers.map((carrier) => ({
id: carrier.id,
full_name: carrier.full_name,
short_name: carrier.short_name,
city: carrier.city,
}));
return (
<>
<LanguageSwitcher />
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Перевозчики</h1>
{/* <CreateButton label="Создать перевозчика" path="/carrier/create" /> */}
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
deleteCarrier(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

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

View File

@ -0,0 +1,3 @@
export * from "./CarrierListPage";
export * from "./CarrierPreviewPage";
export * from "./CarrierCreatePage";

View File

@ -0,0 +1,166 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save, ImagePlus } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { cityStore, countryStore, mediaStore } from "@shared";
import { useState, useEffect } from "react";
import { LanguageSwitcher, MediaViewer } from "@widgets";
import { SelectMediaDialog } from "@shared";
export const CityCreatePage = observer(() => {
const navigate = useNavigate();
const [name, setName] = useState("");
const [countryCode, setCountryCode] = useState("");
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
useEffect(() => {
countryStore.getCountries();
mediaStore.getMedia();
}, []);
const handleCreate = async () => {
try {
setIsLoading(true);
await cityStore.createCity(
name,
countryStore.countries.find((c) => c.code === countryCode)?.name!,
countryCode,
selectedMediaId!
);
toast.success("Город успешно создан");
navigate("/city");
} catch (error) {
toast.error("Ошибка при создании города");
} finally {
setIsLoading(false);
}
};
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"
onClick={() => navigate("/city")}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
fullWidth
label="Название города"
value={name}
required
onChange={(e) => setName(e.target.value)}
/>
<FormControl fullWidth>
<InputLabel>Страна</InputLabel>
<Select
value={countryCode}
label="Страна"
required
onChange={(e) => setCountryCode(e.target.value)}
>
{countryStore.countries.map((country) => (
<MenuItem key={country.code} value={country.code}>
{country.name}
</MenuItem>
))}
</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>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !name || !countryCode}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={3} // Тип медиа для иконок
/>
</Paper>
);
});

View File

@ -0,0 +1,96 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { cityStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
export const CityListPage = observer(() => {
const { cities, getCities, deleteCity } = cityStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const { language } = languageStore;
useEffect(() => {
getCities();
}, [language]);
const columns: GridColDef[] = [
{
field: "country",
headerName: "Страна",
width: 150,
},
{
field: "name",
headerName: "Название",
flex: 1,
},
{
field: "actions",
headerName: "Действия",
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/city/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = cities.map((city) => ({
id: city.id,
name: city.name,
country: city.country,
}));
return (
<>
<LanguageSwitcher />
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Города</h1>
<CreateButton label="Создать город" path="/city/create" />
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
deleteCity(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

@ -0,0 +1,76 @@
import { Paper } from "@mui/material";
import { cityStore, mediaStore } from "@shared";
import { MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
export const CityPreviewPage = observer(() => {
const { id } = useParams();
const { getCity, city } = cityStore;
const { oneMedia, getOneMedia } = mediaStore;
const navigate = useNavigate();
useEffect(() => {
(async () => {
const cityResponse = await getCity(id as string);
await getOneMedia(cityResponse.arms as string);
})();
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
{/* <div className="flex gap-2">
<Button
variant="contained"
color="primary"
onClick={() => navigate(`/city/${id}/edit`)}
startIcon={<Pencil size={20} />}
>
Редактировать
</Button>
<Button
variant="contained"
color="error"
onClick={() => navigate(`/city/${id}/edit`)}
startIcon={<Trash2 size={20} />}
>
Удалить
</Button>
</div> */}
</div>
<div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Название</h1>
<p>{city?.name}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Страна</h1>
<p>{city?.country}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Герб</h1>
<div className="w-[300px] h-[200px]">
<MediaViewer
media={{
id: oneMedia?.id as string,
media_type: oneMedia?.media_type as number,
filename: oneMedia?.filename,
}}
/>
</div>
</div>
</div>
</Paper>
);
});

3
src/pages/City/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from "./CityListPage";
export * from "./CityPreviewPage";
export * from "./CityCreatePage";

View File

@ -0,0 +1,75 @@
import { Button, Paper, TextField } from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { countryStore } from "@shared";
import { useState } from "react";
import { LanguageSwitcher } from "@widgets";
export const CountryCreatePage = observer(() => {
const navigate = useNavigate();
const [name, setName] = useState("");
const [code, setCode] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleCreate = async () => {
try {
setIsLoading(true);
await countryStore.createCountry(code, name);
toast.success("Страна успешно создана");
navigate("/country");
} catch (error) {
toast.error("Ошибка при создании страны");
} finally {
setIsLoading(false);
}
};
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"
onClick={() => navigate("/country")}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
fullWidth
label="Код страны"
value={code}
required
onChange={(e) => setCode(e.target.value)}
/>
<TextField
fullWidth
label="Название"
value={name}
required
onChange={(e) => setName(e.target.value)}
/>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !name || !code}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,86 @@
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, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
export const CountryListPage = observer(() => {
const { countries, getCountries } = countryStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
const { language } = languageStore;
useEffect(() => {
getCountries();
}, [language]);
const columns: GridColDef[] = [
{
field: "name",
headerName: "Название",
flex: 1,
},
{
field: "actions",
headerName: "Действия",
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/country/${params.row.code}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.code);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = countries.map((country) => ({
id: country.code,
code: country.code,
name: country.name,
}));
return (
<>
<LanguageSwitcher />
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Страны</h1>
<CreateButton label="Создать страну" path="/country/create" />
</div>
<DataGrid rows={rows} columns={columns} hideFooter />
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await countryStore.deleteCountry(rowId);
getCountries(); // Refresh the list after deletion
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

@ -0,0 +1,58 @@
import { Paper } from "@mui/material";
import { countryStore } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
export const CountryPreviewPage = observer(() => {
const { id } = useParams();
const { getCountry, country } = countryStore;
const navigate = useNavigate();
useEffect(() => {
(async () => {
await getCountry(id as string);
})();
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
{/* <div className="flex gap-2">
<Button
variant="contained"
color="primary"
onClick={() => navigate(`/user/${id}/edit`)}
startIcon={<Pencil size={20} />}
>
Редактировать
</Button>
<Button
variant="contained"
color="error"
onClick={() => navigate(`/user/${id}/delete`)}
startIcon={<Trash2 size={20} />}
>
Удалить
</Button>
</div> */}
</div>
{country && (
<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?.name}</p>
</div>
</div>
)}
</Paper>
);
});

View File

@ -0,0 +1,3 @@
export * from "./CountryListPage";
export * from "./CountryPreviewPage";
export * from "./CountryCreatePage";

View File

@ -1,126 +0,0 @@
// import { Button, Paper, Typography, Box, Alert, Snackbar } from "@mui/material";
// import { useNavigate } from "react-router-dom";
// import { ArrowLeft, Upload } from "lucide-react";
// import { observer } from "mobx-react-lite";
// import { useState, DragEvent, useRef, useEffect } from "react";
// import { editSightStore, UploadMediaDialog } from "@shared";
// export const CreateMediaPage = observer(() => {
// const navigate = useNavigate();
// const [isDragging, setIsDragging] = useState(false);
// const [error, setError] = useState<string | null>(null);
// const [success, setSuccess] = useState(false);
// const fileInputRef = useRef<HTMLInputElement>(null);
// const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
// const handleDrop = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// e.stopPropagation();
// setIsDragging(false);
// const files = Array.from(e.dataTransfer.files);
// if (files.length > 0) {
// editSightStore.fileToUpload = files[0];
// setUploadDialogOpen(true);
// }
// };
// const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// setIsDragging(true);
// };
// const handleDragLeave = () => {
// setIsDragging(false);
// };
// const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
// const files = e.target.files;
// if (files && files.length > 0) {
// editSightStore.fileToUpload = files[0];
// setUploadDialogOpen(true);
// }
// };
// const handleUploadSuccess = () => {
// setSuccess(true);
// setUploadDialogOpen(false);
// };
// return (
// <div className="w-full h-full p-8">
// <div className="flex items-center gap-4 mb-8">
// <Button
// variant="outlined"
// startIcon={<ArrowLeft size={20} />}
// onClick={() => navigate("/media")}
// >
// Назад
// </Button>
// <Typography variant="h5">Загрузка медиафайла</Typography>
// </div>
// <Paper
// elevation={3}
// className={`w-full h-[60vh] flex flex-col items-center justify-center p-8 transition-colors ${
// isDragging ? "bg-blue-50 border-2 border-blue-500" : "bg-gray-50"
// }`}
// onDrop={handleDrop}
// onDragOver={handleDragOver}
// onDragLeave={handleDragLeave}
// >
// <input
// type="file"
// ref={fileInputRef}
// className="hidden"
// onChange={handleFileSelect}
// accept="image/*,video/*,.glb,.gltf"
// />
// <Box className="flex flex-col items-center gap-4 text-center">
// <Upload size={48} className="text-gray-400" />
// <Typography variant="h6" className="text-gray-600">
// Перетащите файл сюда или
// </Typography>
// <Button
// variant="contained"
// onClick={() => fileInputRef.current?.click()}
// startIcon={<Upload size={20} />}
// >
// Выберите файл
// </Button>
// <Typography variant="body2" className="text-gray-500 mt-4">
// Поддерживаемые форматы: JPG, PNG, GIF, MP4, WebM, GLB, GLTF
// </Typography>
// </Box>
// </Paper>
// <UploadMediaDialog
// open={uploadDialogOpen}
// onClose={() => setUploadDialogOpen(false)}
// afterUpload={handleUploadSuccess}
// />
// <Snackbar
// open={success}
// autoHideDuration={2000}
// onClose={() => setSuccess(false)}
// >
// <Alert severity="success" onClose={() => setSuccess(false)}>
// Медиафайл успешно загружен
// </Alert>
// </Snackbar>
// <Snackbar
// open={!!error}
// autoHideDuration={6000}
// onClose={() => setError(null)}
// >
// <Alert severity="error" onClose={() => setError(null)}>
// {error}
// </Alert>
// </Snackbar>
// </div>
// );
// });

View File

@ -0,0 +1,89 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
export const MediaCreatePage = observer(() => {
const navigate = useNavigate();
const [name, setName] = useState("");
const [type, setType] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleCreate = async () => {
try {
setIsLoading(true);
await mediaStore.createMedia(name, type);
toast.success("Медиа успешно создано");
navigate("/media");
} catch (error) {
toast.error("Ошибка при создании медиа");
} finally {
setIsLoading(false);
}
};
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<h1 className="text-2xl font-bold">Создание медиа</h1>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
className="w-full"
label="Название"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
<FormControl fullWidth>
<InputLabel>Тип</InputLabel>
<Select
value={type}
label="Тип"
onChange={(e) => setType(e.target.value)}
required
>
{Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !name || !type}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -19,7 +19,7 @@ import { Save, ArrowLeft } from "lucide-react";
import { authInstance, mediaStore, MEDIA_TYPE_LABELS } from "@shared";
import { MediaViewer } from "@widgets";
export const EditMediaPage = observer(() => {
export const MediaEditPage = observer(() => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);

View File

@ -0,0 +1,110 @@
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 { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
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 { language } = languageStore;
useEffect(() => {
getMedia();
}, [language]);
const columns: GridColDef[] = [
{
field: "media_name",
headerName: "Название",
flex: 1,
},
{
field: "media_type",
headerName: "Тип",
width: 200,
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<p>
{
MEDIA_TYPE_LABELS[
params.row.media_type as keyof typeof MEDIA_TYPE_LABELS
]
}
</p>
);
},
},
{
field: "actions",
headerName: "Действия",
flex: 1,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/media/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = media.map((media) => ({
id: media.id,
media_name: media.media_name,
media_type: media.media_type,
}));
return (
<>
<LanguageSwitcher />
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Медиа</h1>
<CreateButton label="Создать медиа" path="/media/create" />
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
deleteMedia(rowId.toString());
getMedia();
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

@ -1,12 +1,12 @@
import { mediaStore } from "@shared";
import { useEffect } from "react";
import { useParams } from "react-router-dom";
import { MediaViewer } from "../../widgets/MediaViewer/index";
import { MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite";
import { Download } from "lucide-react";
import { Button } from "@mui/material";
export const PreviewMediaPage = observer(() => {
export const MediaPreviewPage = observer(() => {
const { id } = useParams();
const { oneMedia, getOneMedia } = mediaStore;

3
src/pages/Media/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from "./MediaEditPage";
export * from "./MediaListPage";
export * from "./MediaPreviewPage";

View File

@ -1,88 +0,0 @@
import { TableBody } from "@mui/material";
import { TableRow, TableCell } from "@mui/material";
import { Table, TableHead } from "@mui/material";
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { DeleteModal } from "@widgets";
const rows = (media: any[]) => {
return media.map((row) => ({
id: row.id,
media_name: row.media_name,
media_type:
MEDIA_TYPE_LABELS[row.media_type as keyof typeof MEDIA_TYPE_LABELS],
}));
};
export const MediaListPage = observer(() => {
const { media, getMedia, deleteMedia } = mediaStore;
const navigate = useNavigate();
useEffect(() => {
getMedia();
}, []);
const currentRows = rows(media);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
return (
<>
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell align="center">Название</TableCell>
<TableCell align="center">Тип</TableCell>
<TableCell align="center">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{currentRows.map((row) => (
<TableRow
key={row.media_name}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell align="center">{row.media_name}</TableCell>
<TableCell align="center">{row.media_type}</TableCell>
<TableCell align="center">
<div className="flex gap-7 justify-center">
<button onClick={() => navigate(`/media/${row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button onClick={() => navigate(`/media/${row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await deleteMedia(rowId.toString());
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

@ -0,0 +1,86 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
export const RouteCreatePage = observer(() => {
const navigate = useNavigate();
const [routeNumber, setRouteNumber] = useState("");
const [direction, setDirection] = useState("");
const [isLoading, setIsLoading] = useState(false);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<h1 className="text-2xl font-bold">Создание маршрута</h1>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
className="w-full"
label="Номер маршрута"
required
value={routeNumber}
onChange={(e) => setRouteNumber(e.target.value)}
/>
<FormControl fullWidth>
<InputLabel>Направление</InputLabel>
<Select
value={direction}
label="Направление"
onChange={(e) => setDirection(e.target.value)}
required
>
<MenuItem value="forward">Прямое</MenuItem>
<MenuItem value="backward">Обратное</MenuItem>
</Select>
</FormControl>
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={async () => {
try {
setIsLoading(true);
// await createRoute(routeNumber, direction === "forward");
setIsLoading(false);
toast.success("Маршрут успешно создан");
navigate(-1);
} catch (error) {
console.error(error);
toast.error("Произошла ошибка");
} finally {
setIsLoading(false);
}
}}
disabled={isLoading}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,115 @@
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 { Eye, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, 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 { language } = languageStore;
useEffect(() => {
getRoutes();
}, [language]);
const columns: GridColDef[] = [
{
field: "carrier",
headerName: "Перевозчик",
width: 250,
},
{
field: "route_number",
headerName: "Номер маршрута",
flex: 1,
},
{
field: "route_direction",
headerName: "Направление",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<p
className={
params.row.route_direction === "Прямой"
? "text-green-500"
: "text-red-500"
}
>
{params.row.route_direction}
</p>
);
},
},
{
field: "actions",
headerName: "Действия",
width: 140,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/route/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = routes.map((route) => ({
id: route.id,
carrier: route.carrier,
route_number: route.route_number,
route_direction: route.route_direction ? "Прямой" : "Обратный",
}));
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>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
deleteRoute(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

1
src/pages/Route/index.ts Normal file
View File

@ -0,0 +1 @@
export * from "./RouteListPage";

View File

@ -0,0 +1,96 @@
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 { useNavigate } from "react-router-dom";
import { DeleteModal, LanguageSwitcher } from "@widgets";
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 { language } = languageStore;
useEffect(() => {
getSights();
}, [language]);
const columns: GridColDef[] = [
{
field: "name",
headerName: "Имя",
flex: 1,
},
{
field: "city",
headerName: "Город",
flex: 1,
},
{
field: "actions",
headerName: "Действия",
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/sight/${params.row.id}`)}>
<Pencil size={20} className="text-blue-500" />
</button>
{/* <button onClick={() => navigate(`/sight/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = sights.map((sight) => ({
id: sight.id,
name: sight.name,
city: sight.city,
}));
return (
<>
<LanguageSwitcher />
<div className="w-full">
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await deleteListSight(Number(rowId));
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

1
src/pages/Sight/index.ts Normal file
View File

@ -0,0 +1 @@
export * from "./SightListPage";

View File

@ -0,0 +1,69 @@
import { Button, Paper, TextField } from "@mui/material";
import { snapshotStore } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
export const SnapshotCreatePage = observer(() => {
const { id } = useParams();
const { getSnapshot, createSnapshot } = snapshotStore;
const navigate = useNavigate();
const [name, setName] = useState("");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
(async () => {
await getSnapshot(id as string);
})();
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<h1 className="text-2xl font-bold">Создание снапшота</h1>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
className="w-full"
label="Название"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={async () => {
try {
setIsLoading(true);
await createSnapshot(name);
setIsLoading(false);
toast.success("Снапшот успешно создан");
} catch (error) {
console.error(error);
}
}}
disabled={isLoading}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,127 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, snapshotStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { DatabaseBackup, Eye, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import {
CreateButton,
DeleteModal,
LanguageSwitcher,
SnapshotRestore,
} from "@widgets";
export const SnapshotListPage = observer(() => {
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
snapshotStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
const { language } = languageStore;
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
useEffect(() => {
getSnapshots();
}, [language]);
const columns: GridColDef[] = [
{
field: "name",
headerName: "Название",
flex: 1,
},
{
field: "parent",
headerName: "Родитель",
flex: 1,
},
{
field: "actions",
headerName: "Действия",
width: 300,
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button
onClick={() => {
setIsRestoreModalOpen(true);
setRowId(params.row.id);
}}
>
<DatabaseBackup size={20} className="text-blue-500" />
</button>
<button onClick={() => navigate(`/snapshot/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = snapshots.map((snapshot) => ({
id: snapshot.ID,
name: snapshot.Name,
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
}));
return (
<>
<LanguageSwitcher />
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl ">Снапшоты</h1>
<CreateButton label="Создать снапшот" path="/snapshot/create" />
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await deleteSnapshot(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
<SnapshotRestore
open={isRestoreModalOpen}
onDelete={async () => {
if (rowId) {
await restoreSnapshot(rowId);
}
setIsRestoreModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsRestoreModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

@ -0,0 +1,58 @@
import { Paper } from "@mui/material";
import { snapshotStore } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
export const SnapshotPreviewPage = observer(() => {
const { id } = useParams();
const { getSnapshot, snapshot } = snapshotStore;
const navigate = useNavigate();
useEffect(() => {
(async () => {
await getSnapshot(id as string);
})();
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
{/* <div className="flex gap-2">
<Button
variant="contained"
color="primary"
onClick={() => navigate(`/snapshot/${id}/edit`)}
startIcon={<Pencil size={20} />}
>
Редактировать
</Button>
<Button
variant="contained"
color="error"
onClick={() => navigate(`/snapshot/${id}/delete`)}
startIcon={<Trash2 size={20} />}
>
Удалить
</Button>
</div> */}
</div>
{snapshot && (
<div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Название</h1>
<p>{snapshot?.Name}</p>
</div>
</div>
)}
</Paper>
);
});

View File

@ -0,0 +1,3 @@
export * from "./SnapshotListPage";
export * from "./SnapshotPreviewPage";
export * from "./SnapshotCreatePage";

View File

@ -0,0 +1,94 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { stationsStore } from "@shared";
import { useState } from "react";
export const StationCreatePage = observer(() => {
const navigate = useNavigate();
const [name, setName] = useState("");
const [systemName, setSystemName] = useState("");
const [direction, setDirection] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleCreate = async () => {
try {
setIsLoading(true);
await stationsStore.createStation(name, systemName, direction);
toast.success("Станция успешно создана");
navigate("/station");
} catch (error) {
toast.error("Ошибка при создании станции");
} finally {
setIsLoading(false);
}
};
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<h1 className="text-2xl font-bold">Создание станции</h1>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
className="w-full"
label="Название"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
<TextField
className="w-full"
label="Системное название"
required
value={systemName}
onChange={(e) => setSystemName(e.target.value)}
/>
<FormControl fullWidth>
<InputLabel>Направление</InputLabel>
<Select
value={direction}
label="Направление"
onChange={(e) => setDirection(e.target.value)}
required
>
<MenuItem value="forward">Прямое</MenuItem>
<MenuItem value="backward">Обратное</MenuItem>
</Select>
</FormControl>
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !name || !systemName || !direction}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,117 @@
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, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
export const StationListPage = observer(() => {
const { stations, getStations, deleteStation } = stationsStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
const { language } = languageStore;
useEffect(() => {
getStations();
}, [language]);
const columns: GridColDef[] = [
{
field: "name",
headerName: "Название",
flex: 1,
},
{
field: "system_name",
headerName: "Системное название",
flex: 1,
},
{
field: "direction",
headerName: "Направление",
width: 200,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<p
className={
params.row.direction === true ? "text-green-500" : "text-red-500"
}
>
{params.row.direction ? "Прямой" : "Обратный"}
</p>
);
},
},
{
field: "actions",
headerName: "Действия",
width: 140,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/station/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = stations.map((station) => ({
id: station.id,
name: station.name,
system_name: station.system_name,
direction: station.direction,
}));
return (
<>
<LanguageSwitcher />
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Станции</h1>
<CreateButton label="Создать станцию" path="/station/create" />
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
deleteStation(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

@ -0,0 +1 @@
export * from "./StationListPage";

View File

@ -0,0 +1,112 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, userStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { DeleteModal, LanguageSwitcher } from "@widgets";
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 { language } = languageStore;
useEffect(() => {
getUsers();
}, [language]);
const columns: GridColDef[] = [
{
field: "name",
headerName: "Имя",
width: 400,
},
{
field: "email",
headerName: "Email",
width: 400,
},
{
field: "is_admin",
headerName: "Роль",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<p
className={
params.row.is_admin === true ? "text-green-500" : "text-red-500"
}
>
{params.row.is_admin ? "Администратор" : "Пользователь"}
</p>
);
},
},
{
field: "actions",
headerName: "Действия",
flex: 1,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/user/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = users.map((user) => ({
id: user.id,
email: user.email,
is_admin: user.is_admin,
name: user.name,
}));
return (
<>
<LanguageSwitcher />
<div style={{ width: "100%" }}>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await deleteUser(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

@ -0,0 +1,74 @@
import { Paper } from "@mui/material";
import { userStore } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
export const UserPreviewPage = observer(() => {
const { id } = useParams();
const { getUser, user } = userStore;
const navigate = useNavigate();
useEffect(() => {
(async () => {
await getUser(Number(id));
})();
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
{/* <div className="flex gap-2">
<Button
variant="contained"
color="primary"
onClick={() => navigate(`/user/${id}/edit`)}
startIcon={<Pencil size={20} />}
>
Редактировать
</Button>
<Button
variant="contained"
color="error"
onClick={() => navigate(`/user/${id}/delete`)}
startIcon={<Trash2 size={20} />}
>
Удалить
</Button>
</div> */}
</div>
{user && (
<div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Название</h1>
<p>{user?.name}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Email</h1>
<p>{user?.email}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Роль</h1>
<p
className={
user?.is_admin === true ? "text-green-500" : "text-red-500"
}
>
{user?.is_admin ? "Администратор" : "Пользователь"}
</p>
</div>
</div>
)}
</Paper>
);
});

2
src/pages/User/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from "./UserListPage";
export * from "./UserPreviewPage";

View File

@ -0,0 +1,117 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
import { vehicleStore, VEHICLE_TYPES, carrierStore } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { LanguageSwitcher } from "@widgets";
export const VehicleCreatePage = observer(() => {
const navigate = useNavigate();
const [tailNumber, setTailNumber] = useState("");
const [type, setType] = useState("");
const [carrierId, setCarrierId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
carrierStore.getCarriers();
}, []);
const handleCreate = async () => {
try {
setIsLoading(true);
await vehicleStore.createVehicle(
Number(tailNumber),
type,
carrierStore.carriers.find((c) => c.id === carrierId)?.full_name!,
carrierId!
);
toast.success("Транспорт успешно создан");
} catch (error) {
toast.error("Ошибка при создании транспорта");
} finally {
setIsLoading(false);
}
};
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"
onClick={() => navigate("/vehicle")}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
fullWidth
label="Бортовой номер"
value={tailNumber}
required
onChange={(e) => setTailNumber(e.target.value)}
/>
<FormControl fullWidth>
<InputLabel>Тип</InputLabel>
<Select
value={type}
label="Тип"
required
onChange={(e) => setType(e.target.value)}
>
{VEHICLE_TYPES.map((type) => (
<MenuItem key={type.value} value={type.value}>
{type.label}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Перевозчик</InputLabel>
<Select
value={carrierId || ""}
label="Перевозчик"
required
onChange={(e) => setCarrierId(e.target.value as number)}
>
{carrierStore.carriers.map((carrier) => (
<MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !tailNumber || !type || !carrierId}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,133 @@
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, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { VEHICLE_TYPES } from "@shared";
export const VehicleListPage = observer(() => {
const { vehicles, getVehicles, deleteVehicle } = vehicleStore;
const { carriers, getCarriers } = carrierStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const { language } = languageStore;
useEffect(() => {
getVehicles();
getCarriers();
}, [language]);
const columns: GridColDef[] = [
{
field: "tail_number",
headerName: "Бортовой номер",
flex: 1,
align: "center",
headerAlign: "center",
},
{
field: "type",
headerName: "Тип",
flex: 1,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
{VEHICLE_TYPES.find((type) => type.value === params.row.type)
?.label || params.row.type}
</div>
);
},
},
{
field: "carrier",
headerName: "Перевозчик",
flex: 1,
align: "center",
headerAlign: "center",
},
{
field: "city",
headerName: "Город",
flex: 1,
align: "center",
headerAlign: "center",
},
{
field: "actions",
headerName: "Действия",
flex: 1,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/vehicle/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = vehicles.map((vehicle) => ({
id: vehicle.vehicle.id,
tail_number: vehicle.vehicle.tail_number,
type: vehicle.vehicle.type,
carrier: vehicle.vehicle.carrier,
city: carriers.find((carrier) => carrier.id === vehicle.vehicle.carrier_id)
?.city,
}));
return (
<>
<LanguageSwitcher />
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Транспортные средства</h1>
<CreateButton
label="Создать транспортное средство"
path="/vehicle/create"
/>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await deleteVehicle(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

@ -0,0 +1,70 @@
import { Paper } from "@mui/material";
import { vehicleStore, VEHICLE_TYPES } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
export const VehiclePreviewPage = observer(() => {
const { id } = useParams();
const { getVehicle, vehicle } = vehicleStore;
const navigate = useNavigate();
useEffect(() => {
(async () => {
await getVehicle(Number(id));
})();
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
{/* <div className="flex gap-2">
<Button
variant="contained"
color="primary"
onClick={() => navigate(`/vehicle/${id}/edit`)}
startIcon={<Pencil size={20} />}
>
Редактировать
</Button>
<Button
variant="contained"
color="error"
onClick={() => navigate(`/vehicle/${id}/delete`)}
startIcon={<Trash2 size={20} />}
>
Удалить
</Button>
</div> */}
</div>
{vehicle && (
<div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Системный номер</h1>
<p>{vehicle?.vehicle.tail_number}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Тип транспортного средства</h1>
<p>
{VEHICLE_TYPES.find(
(type) => type.value === vehicle?.vehicle.type
)?.label || vehicle?.vehicle.type}
</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Перевозчик</h1>
<p>{vehicle?.vehicle.carrier}</p>
</div>
</div>
)}
</Paper>
);
});

View File

@ -0,0 +1,3 @@
export * from "./VehicleListPage";
export * from "./VehiclePreviewPage";
export * from "./VehicleCreatePage";

View File

@ -5,7 +5,15 @@ export * from "./DevicesPage";
export * from "./SightPage";
export * from "./CreateSightPage";
export * from "./MapPage";
export * from "./MediaListPage";
export * from "./PreviewMediaPage";
export * from "./EditMediaPage";
export * from "./Media";
// export * from "./CreateMediaPage";
export * from "./Country";
export * from "./City";
export * from "./Route";
export * from "./User";
export * from "./Snapshot";
export * from "./Carrier";
export * from "./Station";
export * from "./Vehicle";
export * from "./Article";
export * from "./Sight";

View File

@ -5,7 +5,12 @@ import {
Building2,
MonitorSmartphone,
Map,
BookImage,
Users,
Earth,
Landmark,
BusFront,
Bus,
GitBranch,
} from "lucide-react";
export const DRAWER_WIDTH = 300;
@ -22,12 +27,54 @@ 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: "media",
// label: "Медиа",
// icon: BookImage,
// path: "/media",
// },
// {
// id: "articles",
// label: "Статьи",
// icon: Newspaper,
// path: "/article",
// },
{
id: "attractions",
label: "Достопримечательности",
icon: Building2,
icon: Landmark,
path: "/sight",
},
// {
// id: "stations",
// label: "Остановки",
// icon: PersonStanding,
// path: "/station",
// },
{
id: "snapshots",
label: "Снапшоты",
icon: GitBranch,
path: "/snapshot",
},
{
id: "map",
label: "Карта",
@ -41,10 +88,22 @@ export const NAVIGATION_ITEMS: {
path: "/devices",
},
{
id: "media",
label: "Медиа",
icon: BookImage,
path: "/media",
id: "vehicles",
label: "Транспорт",
icon: Bus,
path: "/vehicle",
},
// {
// id: "routes",
// label: "Маршруты",
// icon: Split,
// path: "/route",
// },
{
id: "users",
label: "Пользователи",
icon: Users,
path: "/user",
},
// {
// id: "articles",
@ -65,3 +124,8 @@ export const NAVIGATION_ITEMS: {
},
],
};
export const VEHICLE_TYPES = [
{ label: "Трамвай", value: 1 },
{ label: "Троллейбус", value: 2 },
];

View File

@ -25,10 +25,19 @@ class ArticlesStore {
en: [],
zh: [],
};
articleList: Article[] = [];
articleData: Article | null = null;
articleMedia: Media | null = null;
articleLoading: boolean = false;
getArticleList = async () => {
const response = await authInstance.get("/article");
runInAction(() => {
this.articleList = response.data;
});
};
getArticles = async (language: Language) => {
this.articleLoading = true;
const response = await authInstance.get("/article");

View File

@ -0,0 +1,76 @@
import { authInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
export type Carrier = {
id: number;
short_name: string;
full_name: string;
slogan: string;
city: string;
city_id: number;
logo: string;
main_color: string;
left_color: string;
right_color: string;
};
class CarrierStore {
carriers: Carrier[] = [];
carrier: Carrier | null = null;
constructor() {
makeAutoObservable(this);
}
getCarriers = async () => {
const response = await authInstance.get("/carrier");
runInAction(() => {
this.carriers = response.data;
});
};
deleteCarrier = async (id: number) => {
await authInstance.delete(`/carrier/${id}`);
runInAction(() => {
this.carriers = this.carriers.filter((carrier) => carrier.id !== id);
});
};
getCarrier = async (id: number) => {
const response = await authInstance.get(`/carrier/${id}`);
runInAction(() => {
this.carrier = response.data;
});
return response.data;
};
createCarrier = async (
fullName: string,
shortName: string,
city: string,
cityId: number,
primaryColor: string,
secondaryColor: string,
accentColor: string,
slogan: string,
logoId: string
) => {
const response = await authInstance.post("/carrier", {
full_name: fullName,
short_name: shortName,
city,
city_id: cityId,
primary_color: primaryColor,
secondary_color: secondaryColor,
accent_color: accentColor,
slogan,
logo: logoId,
});
runInAction(() => {
this.carriers.push(response.data);
});
};
}
export const carrierStore = new CarrierStore();

View File

@ -11,19 +11,53 @@ type City = {
class CityStore {
cities: City[] = [];
city: City | null = null;
constructor() {
makeAutoObservable(this);
}
getCities = async () => {
if (this.cities.length !== 0) return;
const response = await authInstance.get("/city");
runInAction(() => {
this.cities = response.data;
});
};
deleteCity = async (id: number) => {
await authInstance.delete(`/city/${id}`);
runInAction(() => {
this.cities = this.cities.filter((city) => city.id !== id);
});
};
getCity = async (id: string) => {
const response = await authInstance.get(`/city/${id}`);
runInAction(() => {
this.city = response.data;
});
return response.data;
};
createCity = async (
name: string,
country: string,
countryCode: string,
mediaId: string
) => {
const response = await authInstance.post("/city", {
name: name,
country: country,
country_code: countryCode,
arms: mediaId,
});
runInAction(() => {
this.cities.push(response.data);
});
};
}
export const cityStore = new CityStore();

View File

@ -0,0 +1,49 @@
import { authInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
export type Country = {
code: string;
name: string;
};
class CountryStore {
countries: Country[] = [];
country: Country | null = null;
constructor() {
makeAutoObservable(this);
}
getCountries = async () => {
const response = await authInstance.get("/country");
runInAction(() => {
this.countries = response.data;
});
};
getCountry = async (code: string) => {
const response = await authInstance.get(`/country/${code}`);
runInAction(() => {
this.country = response.data;
});
};
deleteCountry = async (code: string) => {
await authInstance.delete(`/country/${code}`);
runInAction(() => {
this.countries = this.countries.filter(
(country) => country.code !== code
);
});
};
createCountry = async (code: string, name: string) => {
await authInstance.post("/country", { code: code, name: name });
await this.getCountries();
};
}
export const countryStore = new CountryStore();

View File

@ -76,6 +76,16 @@ class MediaStore {
});
return response.data;
};
createMedia = async (name: string, type: string) => {
const response = await authInstance.post("/media", {
media_name: name,
media_type: type,
});
runInAction(() => {
this.media.push(response.data);
});
};
}
export const mediaStore = new MediaStore();

View File

@ -0,0 +1,45 @@
import { makeAutoObservable, runInAction } from "mobx";
import { authInstance } from "@shared";
export type Route = {
carrier: string;
carrier_id: number;
center_latitude: number;
center_longitude: number;
governor_appeal: number;
id: number;
path: number[][];
rotate: number;
route_direction: boolean;
route_number: string;
route_sys_number: string;
scale_max: number;
scale_min: number;
video_preview: string;
};
class RouteStore {
routes: Route[] = [];
constructor() {
makeAutoObservable(this);
}
getRoutes = async () => {
const response = await authInstance.get("/route");
runInAction(() => {
this.routes = response.data;
});
};
deleteRoute = async (id: number) => {
await authInstance.delete(`/route/${id}`);
runInAction(() => {
this.routes = this.routes.filter((route) => route.id !== id);
});
};
}
export const routeStore = new RouteStore();

View File

@ -219,6 +219,11 @@ class SightsStore {
},
};
});
deleteListSight = async (id: number) => {
await authInstance.delete(`/sight/${id}`);
this.sights = this.sights.filter((sight) => sight.id !== id);
};
}
export const sightsStore = new SightsStore();

View File

@ -1,6 +1,6 @@
import { authInstance } from "@shared";
import { API_URL } from "@shared";
import { makeAutoObservable } from "mobx";
import { makeAutoObservable, runInAction } from "mobx";
type Snapshot = {
ID: string;
@ -11,14 +11,42 @@ type Snapshot = {
class SnapshotStore {
snapshots: Snapshot[] = [];
snapshot: Snapshot | null = null;
constructor() {
makeAutoObservable(this);
}
getSnapshots = async () => {
const response = await authInstance.get(`${API_URL}/snapshots`);
this.snapshots = response.data;
const response = await authInstance.get(`/snapshots`);
runInAction(() => {
this.snapshots = response.data;
});
};
deleteSnapshot = async (id: string) => {
await authInstance.delete(`/snapshots/${id}`);
runInAction(() => {
this.snapshots = this.snapshots.filter((snapshot) => snapshot.ID !== id);
});
};
getSnapshot = async (id: string) => {
const response = await authInstance.get(`/snapshots/${id}`);
runInAction(() => {
this.snapshot = response.data;
});
};
restoreSnapshot = async (id: string) => {
await authInstance.post(`/snapshots/${id}/restore`);
};
createSnapshot = async (name: string) => {
await authInstance.post(`/snapshots`, { name });
};
}

View File

@ -0,0 +1,76 @@
import { authInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
type Station = {
id: number;
address: string;
city: string;
city_id: number;
description: string;
direction: boolean;
icon: string;
latitude: number;
longitude: number;
name: string;
offset_x: number;
offset_y: number;
system_name: string;
transfers: {
bus: string;
metro_blue: string;
metro_green: string;
metro_orange: string;
metro_purple: string;
metro_red: string;
train: string;
tram: string;
trolleybus: string;
};
};
class StationsStore {
stations: Station[] = [];
station: Station | null = null;
constructor() {
makeAutoObservable(this);
}
getStations = async () => {
const response = await authInstance.get("/station");
runInAction(() => {
this.stations = response.data;
});
};
deleteStation = async (id: number) => {
await authInstance.delete(`/station/${id}`);
runInAction(() => {
this.stations = this.stations.filter((station) => station.id !== id);
});
};
getStation = async (id: number) => {
const response = await authInstance.get(`/station/${id}`);
this.station = response.data;
};
createStation = async (
name: string,
systemName: string,
direction: string
) => {
const response = await authInstance.post("/station", {
station_name: name,
system_name: systemName,
direction,
});
runInAction(() => {
this.stations.push(response.data);
});
};
}
export const stationsStore = new StationsStore();

View File

@ -0,0 +1,44 @@
import { authInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
export type User = {
id: number;
email: string;
is_admin: boolean;
name: string;
};
class UserStore {
users: User[] = [];
user: User | null = null;
constructor() {
makeAutoObservable(this);
}
getUsers = async () => {
const response = await authInstance.get("/user");
runInAction(() => {
this.users = response.data;
});
};
getUser = async (id: number) => {
const response = await authInstance.get(`/user/${id}`);
runInAction(() => {
this.user = response.data as User;
});
};
deleteUser = async (id: number) => {
await authInstance.delete(`/users/${id}`);
runInAction(() => {
this.users = this.users.filter((user) => user.id !== id);
});
};
}
export const userStore = new UserStore();

View File

@ -1,4 +1,4 @@
import { API_URL, authInstance } from "@shared";
import { authInstance } from "@shared";
import { makeAutoObservable } from "mobx";
export type Vehicle = {
@ -22,15 +22,43 @@ export type Vehicle = {
class VehicleStore {
vehicles: Vehicle[] = [];
vehicle: Vehicle | null = null;
constructor() {
makeAutoObservable(this);
}
getVehicles = async () => {
const response = await authInstance.get(`${API_URL}/vehicle`);
const response = await authInstance.get(`/vehicle`);
this.vehicles = response.data;
};
deleteVehicle = async (id: number) => {
await authInstance.delete(`/vehicle/${id}`);
this.vehicles = this.vehicles.filter(
(vehicle) => vehicle.vehicle.id !== id
);
};
getVehicle = async (id: number) => {
const response = await authInstance.get(`/vehicle/${id}`);
this.vehicle = response.data;
};
createVehicle = async (
tailNumber: number,
type: string,
carrier: string,
carrierId: number
) => {
await authInstance.post("/vehicle", {
tail_number: tailNumber,
type,
carrier,
carrier_id: carrierId,
});
await this.getVehicles();
};
}
export const vehicleStore = new VehicleStore();

View File

@ -9,3 +9,8 @@ export * from "./ArticlesStore";
export * from "./EditSightStore";
export * from "./MediaStore";
export * from "./CreateSightStore";
export * from "./CountryStore";
export * from "./RouteStore";
export * from "./UserStore";
export * from "./CarrierStore";
export * from "./StationsStore";

View File

@ -0,0 +1,25 @@
import { Button } from "@mui/material";
import { Plus } from "lucide-react";
import { useNavigate } from "react-router-dom";
interface CreateButtonProps {
label: string;
path: string;
}
export const CreateButton = ({ label, path }: CreateButtonProps) => {
const navigate = useNavigate();
return (
<Button
variant="contained"
color="primary"
onClick={() => {
navigate(path);
}}
startIcon={<Plus size={20} />}
>
{label}
</Button>
);
};

View File

@ -9,7 +9,6 @@ type Language = (typeof LANGUAGES)[number];
export const LanguageSwitcher = observer(() => {
const { language, setLanguage } = languageStore;
// Memoize getLanguageLabel for consistent rendering
const getLanguageLabel = useCallback((lang: Language) => {
switch (lang) {
case "ru":
@ -45,7 +44,7 @@ export const LanguageSwitcher = observer(() => {
};
return (
<div className="fixed top-1/2 -translate-y-1/2 right-0 flex flex-col gap-2 p-4 z-10 ">
<div className="fixed bottom-0 left-1/2 -translate-x-1/2 flex gap-2 p-4 z-10 ">
{/* Added some styling for better visualization */}
{LANGUAGES.map((lang) => (
<Button

View File

@ -21,7 +21,6 @@ export function MediaViewer({
height: "100%",
display: "flex",
flexGrow: 1,
justifyContent: "center",
}}
className={className}
>
@ -33,8 +32,8 @@ export function MediaViewer({
alt={media?.filename}
style={{
width: "100%",
objectFit: "cover",
height: "100%",
objectFit: "contain",
}}
/>
)}
@ -47,7 +46,7 @@ export function MediaViewer({
style={{
width: "100%",
height: "100%",
objectFit: "cover",
objectFit: "contain",
borderRadius: 8,
}}
controls
@ -64,7 +63,7 @@ export function MediaViewer({
style={{
width: "100%",
height: "100%",
objectFit: "cover",
objectFit: "contain",
borderRadius: 8,
}}
/>
@ -78,7 +77,7 @@ export function MediaViewer({
style={{
width: "100%",
height: "100%",
objectFit: "cover",
objectFit: "contain",
borderRadius: 8,
}}
/>

View File

@ -0,0 +1,34 @@
import { Button } from "@mui/material";
export const SnapshotRestore = ({
onDelete,
onCancel,
open,
}: {
onDelete: () => void;
onCancel: () => void;
open: boolean;
}) => {
return (
<div
className={`fixed top-0 left-0 w-screen h-screen flex justify-center items-center z-10000 bg-black/30 transition-all duration-300 ${
open ? "block" : "hidden"
}`}
>
<div className="bg-white p-4 w-140 rounded-lg flex flex-col gap-4 items-center">
<p className="text-black w-110 text-center">
Вы уверены, что хотите восстановить этот снапшот?
</p>
<p className="text-black w-100 text-center">
Это действие нельзя будет отменить.
</p>
<div className="flex gap-4 justify-center">
<Button variant="contained" color="primary" onClick={onDelete}>
Да
</Button>
<Button onClick={onCancel}>Нет</Button>
</div>
</div>
</div>
);
};

View File

@ -14,3 +14,5 @@ export * from "./MediaAreaForSight";
export * from "./ImageUploadCard";
export * from "./LeaveAgree";
export * from "./DeleteModal";
export * from "./SnapshotRestore";
export * from "./CreateButton";