feat: Add snapshots page
All checks were successful
release-tag / release-image (push) Successful in 2m23s

This commit is contained in:
2025-05-21 18:31:19 +03:00
parent bd19f1dc88
commit 28826123ec
8 changed files with 791 additions and 552 deletions

View File

@ -77,378 +77,397 @@ import { AdminOnly } from "./components/AdminOnly";
//import { LoadingProvider } from "@mt/utils";
import { KBarProvider, RefineKbar } from "@refinedev/kbar";
import { GitBranch } from "lucide-react";
import { SnapshotList, SnapshotCreate, SnapshotShow } from "./pages/snapshot";
function App() {
return (
<BrowserRouter>
<ColorModeContextProvider>
<CssBaseline />
<GlobalStyles styles={{ html: { WebkitFontSmoothing: "auto" } }} />
<RefineSnackbarProvider>
<DevtoolsProvider>
<Refine
dataProvider={customDataProvider}
notificationProvider={useNotificationProvider}
routerProvider={routerBindings}
authProvider={authProvider}
i18nProvider={i18nProvider}
resources={[
{
name: "country",
list: "/country",
create: "/country/create",
edit: "/country/edit/:id",
show: "/country/show/:id",
meta: {
canDelete: true,
label: "Страны",
icon: <CountryIcon />,
},
<BrowserRouter>
<ColorModeContextProvider>
<CssBaseline />
<GlobalStyles styles={{ html: { WebkitFontSmoothing: "auto" } }} />
<RefineSnackbarProvider>
<DevtoolsProvider>
<Refine
dataProvider={customDataProvider}
notificationProvider={useNotificationProvider}
routerProvider={routerBindings}
authProvider={authProvider}
i18nProvider={i18nProvider}
resources={[
{
name: "country",
list: "/country",
create: "/country/create",
edit: "/country/edit/:id",
show: "/country/show/:id",
meta: {
canDelete: true,
label: "Страны",
icon: <CountryIcon />,
},
{
name: "city",
list: "/city",
create: "/city/create",
edit: "/city/edit/:id",
show: "/city/show/:id",
meta: {
canDelete: true,
label: "Города",
icon: <CityIcon />,
},
},
{
name: "city",
list: "/city",
create: "/city/create",
edit: "/city/edit/:id",
show: "/city/show/:id",
meta: {
canDelete: true,
label: "Города",
icon: <CityIcon />,
},
{
name: "carrier",
list: "/carrier",
create: "/carrier/create",
edit: "/carrier/edit/:id",
show: "/carrier/show/:id",
meta: {
canDelete: true,
label: "Перевозчики",
icon: <CarrierIcon />,
},
},
{
name: "carrier",
list: "/carrier",
create: "/carrier/create",
edit: "/carrier/edit/:id",
show: "/carrier/show/:id",
meta: {
canDelete: true,
label: "Перевозчики",
icon: <CarrierIcon />,
},
{
name: "media",
list: "/media",
create: "/media/create",
edit: "/media/edit/:id",
show: "/media/show/:id",
meta: {
canDelete: true,
label: "Медиа",
icon: <MediaIcon />,
},
},
{
name: "media",
list: "/media",
create: "/media/create",
edit: "/media/edit/:id",
show: "/media/show/:id",
meta: {
canDelete: true,
label: "Медиа",
icon: <MediaIcon />,
},
{
name: "article",
list: "/article",
create: "/article/create",
edit: "/article/edit/:id",
show: "/article/show/:id",
meta: {
canDelete: true,
label: "Статьи",
icon: <ArticleIcon />,
},
},
{
name: "article",
list: "/article",
create: "/article/create",
edit: "/article/edit/:id",
show: "/article/show/:id",
meta: {
canDelete: true,
label: "Статьи",
icon: <ArticleIcon />,
},
{
name: "sight",
list: "/sight",
create: "/sight/create",
edit: "/sight/edit/:id",
show: "/sight/show/:id",
meta: {
canDelete: true,
label: "Достопримечательности",
icon: <SightIcon />,
},
},
{
name: "sight",
list: "/sight",
create: "/sight/create",
edit: "/sight/edit/:id",
show: "/sight/show/:id",
meta: {
canDelete: true,
label: "Достопримечательности",
icon: <SightIcon />,
},
{
name: "station",
list: "/station",
create: "/station/create",
edit: "/station/edit/:id",
show: "/station/show/:id",
meta: {
canDelete: true,
label: "Остановки",
icon: <StationIcon />,
},
},
{
name: "station",
list: "/station",
create: "/station/create",
edit: "/station/edit/:id",
show: "/station/show/:id",
meta: {
canDelete: true,
label: "Остановки",
icon: <StationIcon />,
},
{
name: "stationmodal",
show: "/route/:id/station",
edit: "/route/:id/station/",
meta: {
hide: true,
canDelete: true,
label: "Маршруты",
icon: <RouteIcon />,
},
},
{
name: "snapshots",
list: "/snapshot",
create: "/snapshot/create",
show: "/snapshot/show/:id",
meta: {
canDelete: true,
label: "Снапшоты",
icon: <GitBranch />,
},
{
name: "vehicle",
list: "/vehicle",
create: "/vehicle/create",
edit: "/vehicle/edit/:id",
show: "/vehicle/show/:id",
meta: {
canDelete: true,
label: "Транспорт",
icon: <VehicleIcon />,
},
},
{
name: "vehicle",
list: "/vehicle",
create: "/vehicle/create",
edit: "/vehicle/edit/:id",
show: "/vehicle/show/:id",
meta: {
canDelete: true,
label: "Транспорт",
icon: <VehicleIcon />,
},
{
name: "route",
list: "/route",
create: "/route/create",
edit: "/route/edit/:id",
show: "/route/show/:id",
meta: {
canDelete: true,
label: "Маршруты",
icon: <RouteIcon />,
},
},
{
name: "route",
list: "/route",
create: "/route/create",
edit: "/route/edit/:id",
show: "/route/show/:id",
meta: {
canDelete: true,
label: "Маршруты",
icon: <RouteIcon />,
},
{
name: "route-preview",
list: "/route",
show: "/route/:id/station",
meta: {
hide: true,
stations: "route/:id/station"
},
},
{
name: "route-preview",
list: "/route",
show: "/route/:id/station",
meta: {
hide: true,
stations: "route/:id/station",
},
{
name: "user",
list: "/user",
create: "/user/create",
edit: "/user/edit/:id",
show: "/user/show/:id",
meta: {
canDelete: true,
label: "Пользователи",
icon: <UsersIcon />,
},
},
{
name: "user",
list: "/user",
create: "/user/create",
edit: "/user/edit/:id",
show: "/user/show/:id",
meta: {
canDelete: true,
label: "Пользователи",
icon: <UsersIcon />,
},
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true, // Включаем глобально
useNewQueryKeys: true,
projectId: "Wv044J-t53S3s-PcbJGe",
}}
>
<KBarProvider>
<Routes>
<Route path="/route-preview">
<Route path=":id" element={<RoutePreview />} />
},
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true, // Включаем глобально
useNewQueryKeys: true,
projectId: "Wv044J-t53S3s-PcbJGe",
}}
>
<KBarProvider>
<Routes>
<Route path="/route-preview">
<Route path=":id" element={<RoutePreview />} />
</Route>
<Route
element={
<Authenticated
key="authenticated-inner"
fallback={<CatchAllNavigate to="/login" />}
>
<ThemedLayoutV2 Header={Header} Title={SidebarTitle}>
<Outlet />
</ThemedLayoutV2>
</Authenticated>
}
>
<Route
index
element={<NavigateToResource resource="country" />}
/>
<Route path="/country">
<Route index element={<CountryList />} />
<Route
path="create"
element={
<AdminOnly>
<CountryCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<CountryEdit />
</AdminOnly>
}
/>
<Route path="show/:id" element={<CountryShow />} />
</Route>
<Route
element={
<Authenticated
key="authenticated-inner"
fallback={<CatchAllNavigate to="/login" />}
>
<ThemedLayoutV2 Header={Header} Title={SidebarTitle}>
<Outlet />
</ThemedLayoutV2>
</Authenticated>
}
>
<Route path="/snapshot">
<Route index element={<SnapshotList />} />
<Route
index
element={<NavigateToResource resource="country" />}
path="create"
element={
<AdminOnly>
<SnapshotCreate />
</AdminOnly>
}
/>
<Route path="/country">
<Route index element={<CountryList />} />
<Route
path="create"
element={
<AdminOnly>
<CountryCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<CountryEdit />
</AdminOnly>
}
/>
<Route path="show/:id" element={<CountryShow />} />
</Route>
<Route path="/city">
<Route index element={<CityList />} />
<Route
path="create"
element={
<AdminOnly>
<CityCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<CityEdit />
</AdminOnly>
}
/>
<Route path="show/:id" element={<CityShow />} />
</Route>
<Route path="/carrier">
<Route index element={<CarrierList />} />
<Route path="create" element={<CarrierCreate />} />
<Route path="edit/:id" element={<CarrierEdit />} />
<Route path="show/:id" element={<CarrierShow />} />
</Route>
<Route path="/media">
<Route index element={<MediaList />} />
<Route path="create" element={<MediaCreate />} />
<Route path="edit/:id" element={<MediaEdit />} />
<Route path="show/:id" element={<MediaShow />} />
</Route>
<Route path="/article">
<Route index element={<ArticleList />} />
<Route path="create" element={<ArticleCreate />} />
<Route path="edit/:id" element={<ArticleEdit />} />
<Route path="show/:id" element={<ArticleShow />} />
</Route>
<Route path="/sight">
<Route index element={<SightList />} />
<Route path="create" element={<SightCreate />} />
<Route path="edit/:id" element={<SightEdit />} />
<Route path="show/:id" element={<SightShow />} />
</Route>
<Route path="/station">
<Route index element={<StationList />} />
<Route
path="create"
element={
<AdminOnly>
<StationCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<StationEdit />
</AdminOnly>
}
/>
<Route path="show/:id" element={<StationShow />} />
</Route>
<Route path="/vehicle">
<Route index element={<VehicleList />} />
<Route path="create" element={<VehicleCreate />} />
<Route path="edit/:id" element={<VehicleEdit />} />
<Route path="show/:id" element={<VehicleShow />} />
</Route>
<Route path="/route">
<Route index element={<RouteList />} />
<Route
path="create"
element={
<AdminOnly>
<RouteCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<RouteEdit />
</AdminOnly>
}
/>
<Route path="show/:id" element={<RouteShow />} />
</Route>
<Route path="/user">
<Route
index
element={
<AdminOnly>
<UserList />
</AdminOnly>
}
/>
<Route
path="create"
element={
<AdminOnly>
<UserCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<UserEdit />
</AdminOnly>
}
/>
<Route
path="show/:id"
element={
<AdminOnly>
<UserShow />
</AdminOnly>
}
/>
</Route>
<Route path="*" element={<ErrorComponent />} />
<Route path="show/:id" element={<SnapshotShow />} />
</Route>
<Route
element={
<Authenticated
key="authenticated-outer"
fallback={<Outlet />}
>
<NavigateToResource />
</Authenticated>
}
>
<Route path="/login" element={<Login />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler
handler={() => {
// const cleanedTitle = title.autoGeneratedTitle.split('|')[0].trim()
// return `${cleanedTitle} — Белые ночи`
return "Белые ночи";
}}
/>
<RefineKbar />
</KBarProvider>
</Refine>
<DevtoolsPanel />
</DevtoolsProvider>
</RefineSnackbarProvider>
</ColorModeContextProvider>
</BrowserRouter>
<Route path="/city">
<Route index element={<CityList />} />
<Route
path="create"
element={
<AdminOnly>
<CityCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<CityEdit />
</AdminOnly>
}
/>
<Route path="show/:id" element={<CityShow />} />
</Route>
<Route path="/carrier">
<Route index element={<CarrierList />} />
<Route path="create" element={<CarrierCreate />} />
<Route path="edit/:id" element={<CarrierEdit />} />
<Route path="show/:id" element={<CarrierShow />} />
</Route>
<Route path="/media">
<Route index element={<MediaList />} />
<Route path="create" element={<MediaCreate />} />
<Route path="edit/:id" element={<MediaEdit />} />
<Route path="show/:id" element={<MediaShow />} />
</Route>
<Route path="/article">
<Route index element={<ArticleList />} />
<Route path="create" element={<ArticleCreate />} />
<Route path="edit/:id" element={<ArticleEdit />} />
<Route path="show/:id" element={<ArticleShow />} />
</Route>
<Route path="/sight">
<Route index element={<SightList />} />
<Route path="create" element={<SightCreate />} />
<Route path="edit/:id" element={<SightEdit />} />
<Route path="show/:id" element={<SightShow />} />
</Route>
<Route path="/station">
<Route index element={<StationList />} />
<Route
path="create"
element={
<AdminOnly>
<StationCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<StationEdit />
</AdminOnly>
}
/>
<Route path="show/:id" element={<StationShow />} />
</Route>
<Route path="/vehicle">
<Route index element={<VehicleList />} />
<Route path="create" element={<VehicleCreate />} />
<Route path="edit/:id" element={<VehicleEdit />} />
<Route path="show/:id" element={<VehicleShow />} />
</Route>
<Route path="/route">
<Route index element={<RouteList />} />
<Route
path="create"
element={
<AdminOnly>
<RouteCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<RouteEdit />
</AdminOnly>
}
/>
<Route path="show/:id" element={<RouteShow />} />
</Route>
<Route path="/user">
<Route
index
element={
<AdminOnly>
<UserList />
</AdminOnly>
}
/>
<Route
path="create"
element={
<AdminOnly>
<UserCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<UserEdit />
</AdminOnly>
}
/>
<Route
path="show/:id"
element={
<AdminOnly>
<UserShow />
</AdminOnly>
}
/>
</Route>
<Route path="*" element={<ErrorComponent />} />
</Route>
<Route
element={
<Authenticated
key="authenticated-outer"
fallback={<Outlet />}
>
<NavigateToResource />
</Authenticated>
}
>
<Route path="/login" element={<Login />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler
handler={() => {
// const cleanedTitle = title.autoGeneratedTitle.split('|')[0].trim()
// return `${cleanedTitle} — Белые ночи`
return "Белые ночи";
}}
/>
<RefineKbar />
</KBarProvider>
</Refine>
<DevtoolsPanel />
</DevtoolsProvider>
</RefineSnackbarProvider>
</ColorModeContextProvider>
</BrowserRouter>
);
}

View File

@ -99,6 +99,13 @@
"show": "Показать станцию"
}
},
"snapshots": {
"titles": {
"create": "Создать снапшот",
"show": "Показать снапшот"
}
},
"vehicle": {
"titles": {
"create": "Создать транспорт",

View File

@ -0,0 +1,61 @@
import { Box, TextField, Typography, Paper } from "@mui/material";
import { Create } from "@refinedev/mui";
import { useForm } from "@refinedev/react-hook-form";
import { Controller, FieldValues } from "react-hook-form";
import React, { useState, useEffect } from "react";
import ReactMarkdown from "react-markdown";
import { MarkdownEditor } from "../../components/MarkdownEditor";
import "easymde/dist/easymde.min.css";
import { LanguageSelector } from "@ui";
import { observer } from "mobx-react-lite";
import {
EVERY_LANGUAGE,
Languages,
languageStore,
META_LANGUAGE,
} from "@stores";
import rehypeRaw from "rehype-raw";
const MemoizedSimpleMDE = React.memo(MarkdownEditor);
export const SnapshotCreate = observer(() => {
const {
saveButtonProps,
refineCore: { formLoading, onFinish },
register,
control,
watch,
formState: { errors },
setValue,
handleSubmit,
} = useForm();
return (
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
<Box sx={{ display: "flex", flex: 1, gap: 2 }}>
{/* Форма создания */}
<Box sx={{ display: "flex", flex: 1, flexDirection: "column", gap: 2 }}>
<Box
component="form"
sx={{ flex: 1, display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<TextField
{...register("Name", {
required: "Это поле является обязательным",
})}
error={!!(errors as any)?.name}
helperText={(errors as any)?.name?.message}
margin="normal"
fullWidth
InputLabelProps={{ shrink: true }}
type="text"
label={"Название *"}
name="Name"
/>
</Box>
</Box>
</Box>
</Create>
);
});

View File

@ -0,0 +1,3 @@
export * from "./create";
export * from "./list";
export * from "./show";

109
src/pages/snapshot/list.tsx Normal file
View File

@ -0,0 +1,109 @@
import React from "react";
import { type GridColDef } from "@mui/x-data-grid";
import {
DeleteButton,
EditButton,
List,
ShowButton,
useDataGrid,
} from "@refinedev/mui";
import { Stack } from "@mui/material";
import { CustomDataGrid } from "@components";
import { localeText } from "../../locales/ru/localeText";
import { observer } from "mobx-react-lite";
import { useMany } from "@refinedev/core";
export const SnapshotList = observer(() => {
const { dataGridProps } = useDataGrid({
resource: "snapshots",
hasPagination: false,
});
// Получаем список уникальных ParentID
const parentIds = React.useMemo(() => {
return (
dataGridProps?.rows
?.map((row: any) => row.ParentID)
.filter((id) => id !== null && id !== undefined)
.filter((value, index, self) => self.indexOf(value) === index) || []
);
}, [dataGridProps?.rows]);
// Загружаем родительские снапшоты
const { data: parentsData } = useMany({
resource: "snapshots",
ids: parentIds,
queryOptions: {
enabled: parentIds.length > 0,
},
});
// Создаем мапу ID → Name
const parentNameMap = React.useMemo(() => {
const map: Record<number, string> = {};
parentsData?.data?.forEach((parent) => {
map[parent.ID] = parent.Name;
});
return map;
}, [parentsData]);
const columns = React.useMemo<GridColDef[]>(
() => [
{
field: "Name",
headerName: "Название",
type: "string",
minWidth: 150,
flex: 1,
align: "left",
headerAlign: "left",
},
{
field: "ParentID",
headerName: "Родитель",
minWidth: 150,
flex: 1,
renderCell: ({ value }) => parentNameMap[value] || "—",
align: "left",
headerAlign: "left",
},
{
field: "actions",
headerName: "Действия",
minWidth: 150,
display: "flex",
align: "center",
headerAlign: "center",
sortable: false,
filterable: false,
disableColumnMenu: true,
renderCell: function render({ row }) {
return (
<>
<ShowButton hideText recordItemId={row.ID} />
<DeleteButton
hideText
confirmTitle="Вы уверены?"
recordItemId={row.ID}
/>
</>
);
},
},
],
[parentNameMap]
);
return (
<List>
<Stack gap={2.5}>
<CustomDataGrid
{...dataGridProps}
columns={columns}
localeText={localeText}
getRowId={(row: any) => row.ID}
/>
</Stack>
</List>
);
});

View File

@ -0,0 +1,26 @@
import { Stack, Typography } from "@mui/material";
import { useShow } from "@refinedev/core";
import { Show, TextFieldComponent } from "@refinedev/mui";
export const SnapshotShow = () => {
const { query } = useShow({});
const { data, isLoading } = query;
const record = data?.data;
const fields = [{ label: "Название", data: "Name" }];
return (
<Show isLoading={isLoading} canEdit={false}>
<Stack gap={4}>
{fields.map(({ label, data }) => (
<Stack key={data} gap={1}>
<Typography variant="body1" fontWeight="bold">
{label}
</Typography>
<TextFieldComponent value={record?.[data]} />
</Stack>
))}
</Stack>
</Show>
);
};