Initial commit: svg frontend
This commit is contained in:
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
VITE_FRONTEND_PORT=28080
|
||||
VITE_APP_TITLE=SVG Service Admin UI
|
||||
VITE_API_BASE_URL=http://127.0.0.1:9020
|
||||
VITE_API_KEY=admin-local-dev-key
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# node
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# test / coverage
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# editors / os
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VITE_FRONTEND_PORT=28080
|
||||
ENV VITE_FRONTEND_PORT=${VITE_FRONTEND_PORT}
|
||||
|
||||
EXPOSE 28080
|
||||
|
||||
CMD ["sh", "-c", "npm run dev"]
|
||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
svg-service-front:
|
||||
container_name: svg-service-front
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_FRONTEND_PORT: ${VITE_FRONTEND_PORT}
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "${VITE_FRONTEND_PORT}:${VITE_FRONTEND_PORT}"
|
||||
restart: unless-stopped
|
||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SVG Service Front</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "svg-service-front",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port ${VITE_FRONTEND_PORT:-28080}",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0 --port ${VITE_FRONTEND_PORT:-28080}"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"axios": "^1.8.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.2.1"
|
||||
}
|
||||
}
|
||||
12
src/api/client.ts
Normal file
12
src/api/client.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import axios from "axios";
|
||||
import { getAppConfig } from "../shared/config/env";
|
||||
|
||||
const config = getAppConfig();
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: config.apiBaseUrl,
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
"X-API-Key": config.apiKey
|
||||
}
|
||||
});
|
||||
411
src/api/queries.ts
Normal file
411
src/api/queries.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { apiClient } from "./client";
|
||||
import type {
|
||||
ApiErrorPayload,
|
||||
AuthMeResponse,
|
||||
CreatePriceRuleRequest,
|
||||
CreatePricingCategoryRequest,
|
||||
DisplaySvgMetaResponse,
|
||||
LifecycleActionResponse,
|
||||
ManifestResponse,
|
||||
PriceRuleItem,
|
||||
PricingCategoryItem,
|
||||
SchemeAuditResponse,
|
||||
SchemeCurrentVersionResponse,
|
||||
SchemeDetailResponse,
|
||||
SchemeGroupsResponse,
|
||||
SchemePricingResponse,
|
||||
SchemeSeatsResponse,
|
||||
SchemeSectorsResponse,
|
||||
SchemeVersionsResponse,
|
||||
SchemesListResponse,
|
||||
TestSeatPreviewResponse,
|
||||
UpdatePriceRuleRequest,
|
||||
UpdatePricingCategoryRequest,
|
||||
UploadSchemeResponse
|
||||
} from "../shared/types/api";
|
||||
|
||||
export function getApiErrorMessage(error: unknown): string {
|
||||
if (axios.isAxiosError<ApiErrorPayload>(error)) {
|
||||
const status = error.response?.status;
|
||||
const detail = typeof error.response?.data?.detail === "string" ? error.response.data.detail : undefined;
|
||||
|
||||
if (!error.response) return "Сетевой сбой или блокировка cross-origin запроса";
|
||||
if (status === 400) return detail || "Некорректный запрос";
|
||||
if (status === 401) return "Отсутствует API key";
|
||||
if (status === 403) return "Неверный API key";
|
||||
if (status === 404) return detail || "Ресурс не найден";
|
||||
if (status === 409) return detail || "Конфликт состояния";
|
||||
if (status === 413) return "Файл превышает допустимый лимит";
|
||||
if (status === 422) return detail || "Ошибка валидации";
|
||||
if (status && status >= 500) return "Backend вернул внутреннюю ошибку";
|
||||
if (detail) return detail;
|
||||
if (error.code === "ECONNABORTED") return "Таймаут запроса к backend";
|
||||
return "Ошибка запроса к backend";
|
||||
}
|
||||
|
||||
if (error instanceof Error) return error.message;
|
||||
return "Неизвестная ошибка";
|
||||
}
|
||||
|
||||
export function getApiFieldErrors(error: unknown): Record<string, string> {
|
||||
if (!axios.isAxiosError<ApiErrorPayload>(error)) return {};
|
||||
const errors = error.response?.data?.errors;
|
||||
if (!Array.isArray(errors)) return {};
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const item of errors) {
|
||||
if (item && typeof item.field === "string" && typeof item.message === "string") {
|
||||
result[item.field] = item.message;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getItems<T>(payload: T[] | { items?: T[] } | null | undefined): T[] {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (payload && Array.isArray(payload.items)) return payload.items;
|
||||
return [];
|
||||
}
|
||||
|
||||
export function useAuthMeQuery() {
|
||||
return useQuery({
|
||||
queryKey: ["auth-me"],
|
||||
queryFn: async () => (await apiClient.get<AuthMeResponse>("/api/v1/auth/me")).data,
|
||||
retry: false
|
||||
});
|
||||
}
|
||||
|
||||
export function useManifestQuery() {
|
||||
return useQuery({
|
||||
queryKey: ["manifest"],
|
||||
queryFn: async () => (await apiClient.get<ManifestResponse>("/api/v1/manifest")).data,
|
||||
retry: false
|
||||
});
|
||||
}
|
||||
|
||||
export function useSchemesQuery() {
|
||||
return useQuery({
|
||||
queryKey: ["schemes"],
|
||||
queryFn: async () => (await apiClient.get<SchemesListResponse>("/api/v1/schemes")).data
|
||||
});
|
||||
}
|
||||
|
||||
export function useSchemeDetailQuery(schemeId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["scheme-detail", schemeId],
|
||||
enabled: Boolean(schemeId),
|
||||
queryFn: async () => (await apiClient.get<SchemeDetailResponse>(`/api/v1/schemes/${schemeId}`)).data
|
||||
});
|
||||
}
|
||||
|
||||
export function useSchemeCurrentQuery(schemeId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["scheme-current", schemeId],
|
||||
enabled: Boolean(schemeId),
|
||||
queryFn: async () => (await apiClient.get<SchemeCurrentVersionResponse>(`/api/v1/schemes/${schemeId}/current`)).data
|
||||
});
|
||||
}
|
||||
|
||||
export function useSchemeVersionsQuery(schemeId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["scheme-versions", schemeId],
|
||||
enabled: Boolean(schemeId),
|
||||
queryFn: async () => getItems((await apiClient.get<SchemeVersionsResponse>(`/api/v1/schemes/${schemeId}/versions`)).data)
|
||||
});
|
||||
}
|
||||
|
||||
export function useSchemeSectorsQuery(schemeId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["scheme-sectors", schemeId],
|
||||
enabled: Boolean(schemeId),
|
||||
queryFn: async () => getItems((await apiClient.get<SchemeSectorsResponse>(`/api/v1/schemes/${schemeId}/current/sectors`)).data)
|
||||
});
|
||||
}
|
||||
|
||||
export function useSchemeGroupsQuery(schemeId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["scheme-groups", schemeId],
|
||||
enabled: Boolean(schemeId),
|
||||
queryFn: async () => getItems((await apiClient.get<SchemeGroupsResponse>(`/api/v1/schemes/${schemeId}/current/groups`)).data)
|
||||
});
|
||||
}
|
||||
|
||||
export function useSchemeSeatsQuery(schemeId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["scheme-seats", schemeId],
|
||||
enabled: Boolean(schemeId),
|
||||
queryFn: async () => getItems((await apiClient.get<SchemeSeatsResponse>(`/api/v1/schemes/${schemeId}/current/seats`)).data)
|
||||
});
|
||||
}
|
||||
|
||||
export function useSchemePricingQuery(schemeId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["scheme-pricing", schemeId],
|
||||
enabled: Boolean(schemeId),
|
||||
queryFn: async () => {
|
||||
const data = (await apiClient.get<SchemePricingResponse>(`/api/v1/schemes/${schemeId}/pricing`)).data;
|
||||
return { categories: data.categories ?? [], rules: data.rules ?? [] };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useSchemeAuditQuery(schemeId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["scheme-audit", schemeId],
|
||||
enabled: Boolean(schemeId),
|
||||
queryFn: async () => getItems((await apiClient.get<SchemeAuditResponse>(`/api/v1/schemes/${schemeId}/audit`)).data)
|
||||
});
|
||||
}
|
||||
|
||||
export function useDisplaySvgMetaQuery(schemeId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["scheme-display-meta", schemeId, "passthrough"],
|
||||
enabled: Boolean(schemeId),
|
||||
retry: false,
|
||||
queryFn: async () =>
|
||||
(
|
||||
await apiClient.get<DisplaySvgMetaResponse>(`/api/v1/schemes/${schemeId}/current/svg/display/meta`, {
|
||||
params: { mode: "passthrough" }
|
||||
})
|
||||
).data
|
||||
});
|
||||
}
|
||||
|
||||
export function useDisplaySvgQuery(schemeId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["scheme-display-svg", schemeId, "passthrough"],
|
||||
enabled: Boolean(schemeId),
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<string>(`/api/v1/schemes/${schemeId}/current/svg/display`, {
|
||||
params: { mode: "passthrough" },
|
||||
responseType: "text",
|
||||
transformResponse: [(data) => data]
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useLegacySvgQuery(schemeId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["scheme-legacy-svg", schemeId],
|
||||
enabled: Boolean(schemeId),
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<string>(`/api/v1/schemes/${schemeId}/current/svg`, {
|
||||
responseType: "text",
|
||||
transformResponse: [(data) => data]
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestSeatPreviewQuery(schemeId: string | undefined, seatId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["scheme-test-seat", schemeId, seatId],
|
||||
enabled: Boolean(schemeId && seatId),
|
||||
retry: false,
|
||||
queryFn: async () => (await apiClient.get<TestSeatPreviewResponse>(`/api/v1/schemes/${schemeId}/test/seats/${seatId}`)).data
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadSchemeMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return (
|
||||
await apiClient.post<UploadSchemeResponse>("/api/v1/schemes/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" }
|
||||
})
|
||||
).data;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["schemes"] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function refetchSchemeBundle(queryClient: ReturnType<typeof useQueryClient>, schemeId: string) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["schemes"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["scheme-detail", schemeId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["scheme-current", schemeId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["scheme-versions", schemeId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["scheme-sectors", schemeId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["scheme-groups", schemeId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["scheme-seats", schemeId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["scheme-display-meta", schemeId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["scheme-display-svg", schemeId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["scheme-legacy-svg", schemeId] })
|
||||
]);
|
||||
}
|
||||
|
||||
export function useCreateNextVersionMutation(schemeId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!schemeId) throw new Error("schemeId is required");
|
||||
return (await apiClient.post<LifecycleActionResponse>(`/api/v1/schemes/${schemeId}/versions`)).data;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
if (schemeId) await refetchSchemeBundle(queryClient, schemeId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function usePublishSchemeMutation(schemeId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!schemeId) throw new Error("schemeId is required");
|
||||
return (await apiClient.post<LifecycleActionResponse>(`/api/v1/schemes/${schemeId}/publish`)).data;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
if (schemeId) await refetchSchemeBundle(queryClient, schemeId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnpublishSchemeMutation(schemeId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!schemeId) throw new Error("schemeId is required");
|
||||
return (await apiClient.post<LifecycleActionResponse>(`/api/v1/schemes/${schemeId}/unpublish`)).data;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
if (schemeId) await refetchSchemeBundle(queryClient, schemeId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useRollbackSchemeMutation(schemeId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (targetVersionNumber: number) => {
|
||||
if (!schemeId) throw new Error("schemeId is required");
|
||||
return (
|
||||
await apiClient.post<LifecycleActionResponse>(`/api/v1/schemes/${schemeId}/rollback`, {
|
||||
target_version_number: targetVersionNumber
|
||||
})
|
||||
).data;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
if (schemeId) await refetchSchemeBundle(queryClient, schemeId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePricingCategoryMutation(schemeId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (payload: CreatePricingCategoryRequest) => {
|
||||
if (!schemeId) throw new Error("schemeId is required");
|
||||
return (await apiClient.post<PricingCategoryItem>(`/api/v1/schemes/${schemeId}/pricing/categories`, payload)).data;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
if (schemeId) {
|
||||
await queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePricingCategoryMutation(schemeId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (params: { pricingCategoryId: string; payload: UpdatePricingCategoryRequest }) => {
|
||||
if (!schemeId) throw new Error("schemeId is required");
|
||||
return (
|
||||
await apiClient.put<PricingCategoryItem>(
|
||||
`/api/v1/schemes/${schemeId}/pricing/categories/${params.pricingCategoryId}`,
|
||||
params.payload
|
||||
)
|
||||
).data;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
if (schemeId) {
|
||||
await queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePricingCategoryMutation(schemeId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (pricingCategoryId: string) => {
|
||||
if (!schemeId) throw new Error("schemeId is required");
|
||||
await apiClient.delete(`/api/v1/schemes/${schemeId}/pricing/categories/${pricingCategoryId}`);
|
||||
return { pricingCategoryId };
|
||||
},
|
||||
onSuccess: async () => {
|
||||
if (schemeId) {
|
||||
await queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePriceRuleMutation(schemeId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (payload: CreatePriceRuleRequest) => {
|
||||
if (!schemeId) throw new Error("schemeId is required");
|
||||
return (await apiClient.post<PriceRuleItem>(`/api/v1/schemes/${schemeId}/pricing/rules`, payload)).data;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
if (schemeId) {
|
||||
await queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePriceRuleMutation(schemeId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (params: { priceRuleId: string; payload: UpdatePriceRuleRequest }) => {
|
||||
if (!schemeId) throw new Error("schemeId is required");
|
||||
return (
|
||||
await apiClient.put<PriceRuleItem>(`/api/v1/schemes/${schemeId}/pricing/rules/${params.priceRuleId}`, params.payload)
|
||||
).data;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
if (schemeId) {
|
||||
await queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePriceRuleMutation(schemeId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (priceRuleId: string) => {
|
||||
if (!schemeId) throw new Error("schemeId is required");
|
||||
await apiClient.delete(`/api/v1/schemes/${schemeId}/pricing/rules/${priceRuleId}`);
|
||||
return { priceRuleId };
|
||||
},
|
||||
onSuccess: async () => {
|
||||
if (schemeId) {
|
||||
await queryClient.invalidateQueries({ queryKey: ["scheme-pricing", schemeId] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["scheme-audit", schemeId] });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
5
src/app/App.tsx
Normal file
5
src/app/App.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AppRouter } from "./router";
|
||||
|
||||
export function App() {
|
||||
return <AppRouter />;
|
||||
}
|
||||
74
src/app/AppLayout.tsx
Normal file
74
src/app/AppLayout.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Link, NavLink, Outlet } from "react-router-dom";
|
||||
import { useAuthMeQuery, useManifestQuery, getApiErrorMessage } from "../api/queries";
|
||||
import { getAppConfig } from "../shared/config/env";
|
||||
import { ApiErrorView } from "../shared/ui/ApiErrorView";
|
||||
import { localizeRole } from "../shared/lib/formatters";
|
||||
|
||||
export function AppLayout() {
|
||||
const config = getAppConfig();
|
||||
const authQuery = useAuthMeQuery();
|
||||
const manifestQuery = useManifestQuery();
|
||||
|
||||
const authError = authQuery.isError ? getApiErrorMessage(authQuery.error) : null;
|
||||
const manifest = manifestQuery.data;
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<aside className="sidebar">
|
||||
<div className="logo-block">
|
||||
<div className="logo-title">{config.appTitle}</div>
|
||||
<div className="logo-subtitle">Админка / тестовый интерфейс</div>
|
||||
</div>
|
||||
|
||||
<nav className="menu">
|
||||
<NavLink to="/" className="menu-link">
|
||||
Схемы
|
||||
</NavLink>
|
||||
<NavLink to="/upload" className="menu-link">
|
||||
Загрузить SVG
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-box">
|
||||
<div className="sidebar-box-title">Подключение</div>
|
||||
<div className="kv"><span>API</span><span>{config.apiBaseUrl}</span></div>
|
||||
<div className="kv"><span>Авторизация</span><span>{authQuery.isLoading ? "..." : localizeRole(authQuery.data?.role) ?? "ошибка"}</span></div>
|
||||
<div className="kv"><span>Заголовок</span><span>{authQuery.data?.auth_header ?? manifest?.auth_header_name ?? "X-API-Key"}</span></div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-box">
|
||||
<div className="sidebar-box-title">Лимиты</div>
|
||||
<div className="kv"><span>Макс. размер SVG</span><span>{manifest?.svg_limits?.max_file_size_bytes ?? "n/a"}</span></div>
|
||||
<div className="kv"><span>Макс. элементов SVG</span><span>{manifest?.svg_limits?.max_elements ?? "n/a"}</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="content">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<div className="page-title">Схема зала</div>
|
||||
<div className="page-subtitle">frontend v1</div>
|
||||
</div>
|
||||
|
||||
<div className="top-actions">
|
||||
<Link to="/upload" className="btn btn-secondary">Загрузить SVG</Link>
|
||||
<Link to="/" className="btn btn-primary">К списку схем</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{authError ? (
|
||||
<main className="main">
|
||||
<ApiErrorView
|
||||
title="Ошибка доступа"
|
||||
message={`${authError}. Проверь VITE_API_BASE_URL и VITE_API_KEY.`}
|
||||
/>
|
||||
</main>
|
||||
) : (
|
||||
<main className="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
src/app/router.tsx
Normal file
18
src/app/router.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AppLayout } from "./AppLayout";
|
||||
import { HomePage } from "../pages/HomePage";
|
||||
import { UploadPage } from "../pages/UploadPage";
|
||||
import { SchemeDetailPage } from "../pages/SchemeDetailPage";
|
||||
|
||||
export function AppRouter() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/upload" element={<UploadPage />} />
|
||||
<Route path="/schemes/:schemeId" element={<SchemeDetailPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
504
src/app/styles.css
Normal file
504
src/app/styles.css
Normal file
@@ -0,0 +1,504 @@
|
||||
:root {
|
||||
font-family: Inter, Arial, Helvetica, sans-serif;
|
||||
line-height: 1.4;
|
||||
font-weight: 400;
|
||||
color: #111827;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
padding: 20px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.logo-block {
|
||||
padding: 8px 8px 16px 8px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.logo-subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.menu-link {
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-link.active {
|
||||
background: #e5edff;
|
||||
border-color: #bfd0ff;
|
||||
}
|
||||
|
||||
.sidebar-box {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.sidebar-box-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding: 18px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid #dfe3ea;
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel h1,
|
||||
.panel h2,
|
||||
.panel h3,
|
||||
.panel h4,
|
||||
.panel p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
cursor: pointer;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: #ffffff;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: #ffffff;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.scheme-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scheme-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: #fbfbfc;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-top: 6px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.code-box {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.select {
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #ffffff;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.mono-cell {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.text-input {
|
||||
min-height: 40px;
|
||||
min-width: 360px;
|
||||
padding: 0 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.test-mode-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.svg-preview-wrap {
|
||||
width: 100%;
|
||||
min-height: 420px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seat-preview-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 520px;
|
||||
}
|
||||
|
||||
.seat-node {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seat-shape {
|
||||
fill: #dbeafe;
|
||||
stroke: #2563eb;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.seat-shape.selected {
|
||||
fill: #fca5a5;
|
||||
stroke: #dc2626;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.seat-label {
|
||||
font-size: 6px;
|
||||
fill: #111827;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.inspector-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel-nested {
|
||||
margin-top: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.test-mode-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.audit-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.audit-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: #fbfbfc;
|
||||
}
|
||||
|
||||
.audit-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.audit-title {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.audit-meta-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}\n
|
||||
.viewer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.svg-viewer-frame {
|
||||
width: 100%;
|
||||
min-height: 720px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.svg-viewer-transform {
|
||||
display: inline-block;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.svg-inline-host {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.svg-inline-host svg,
|
||||
.viewer-inline-svg {
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.viewer-selected-element {
|
||||
outline: 2px solid #ef4444;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.viewer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}\n
|
||||
.svg-object-frame {
|
||||
width: 100%;
|
||||
min-height: 900px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.svg-object-viewer {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 900px;
|
||||
border: 0;
|
||||
background: #ffffff;
|
||||
}\n
|
||||
.form-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: #dc2626;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
border-color: #ef4444 !important;
|
||||
background: #fff5f5;
|
||||
}
|
||||
233
src/features/schemes/SchemeAuditTab.tsx
Normal file
233
src/features/schemes/SchemeAuditTab.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { getApiErrorMessage, useSchemeAuditQuery } from "../../api/queries";
|
||||
import { ApiErrorView } from "../../shared/ui/ApiErrorView";
|
||||
import { StatCard } from "../../shared/ui/StatCard";
|
||||
|
||||
type Props = {
|
||||
schemeId: string;
|
||||
};
|
||||
|
||||
type AuditGroup = "all" | "lifecycle" | "pricing" | "versioning" | "upload";
|
||||
|
||||
function valueText(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "—";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function detectGroup(eventType: string | null | undefined): AuditGroup {
|
||||
const value = (eventType || "").toLowerCase();
|
||||
|
||||
if (value.includes("pricing.")) return "pricing";
|
||||
if (value.includes("version")) return "versioning";
|
||||
if (value.includes("upload") || value.includes("import")) return "upload";
|
||||
if (
|
||||
value.includes("published") ||
|
||||
value.includes("unpublished") ||
|
||||
value.includes("rolled_back") ||
|
||||
value.includes("scheme.created")
|
||||
) {
|
||||
return "lifecycle";
|
||||
}
|
||||
|
||||
return "all";
|
||||
}
|
||||
|
||||
function groupLabel(group: AuditGroup): string {
|
||||
if (group === "all") return "Все";
|
||||
if (group === "lifecycle") return "Жизненный цикл";
|
||||
if (group === "pricing") return "Тарифы";
|
||||
if (group === "versioning") return "Версионирование";
|
||||
return "Загрузка / импорт";
|
||||
}
|
||||
|
||||
function prettyDetails(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return "null";
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function SchemeAuditTab({ schemeId }: Props) {
|
||||
const auditQuery = useSchemeAuditQuery(schemeId);
|
||||
const [selectedGroup, setSelectedGroup] = useState<AuditGroup>("all");
|
||||
const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({});
|
||||
|
||||
const items = useMemo(() => {
|
||||
const raw = auditQuery.data ?? [];
|
||||
return [...raw].sort((a, b) => {
|
||||
const left = new Date(a.created_at || 0).getTime();
|
||||
const right = new Date(b.created_at || 0).getTime();
|
||||
return right - left;
|
||||
});
|
||||
}, [auditQuery.data]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
return {
|
||||
all: items.length,
|
||||
lifecycle: items.filter((item) => detectGroup(item.event_type) === "lifecycle").length,
|
||||
pricing: items.filter((item) => detectGroup(item.event_type) === "pricing").length,
|
||||
versioning: items.filter((item) => detectGroup(item.event_type) === "versioning").length,
|
||||
upload: items.filter((item) => detectGroup(item.event_type) === "upload").length
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (selectedGroup === "all") {
|
||||
return items;
|
||||
}
|
||||
return items.filter((item) => detectGroup(item.event_type) === selectedGroup);
|
||||
}, [items, selectedGroup]);
|
||||
|
||||
const detailsMap = useMemo(() => {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
filtered.forEach((item, index) => {
|
||||
const key = item.audit_event_id || `${item.event_type || "event"}-${index}`;
|
||||
result[key] = prettyDetails(item.details_json);
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [filtered]);
|
||||
|
||||
if (auditQuery.isLoading) {
|
||||
return <div className="panel">Загрузка аудита...</div>;
|
||||
}
|
||||
|
||||
if (auditQuery.isError) {
|
||||
return <ApiErrorView title="Ошибка загрузки аудита" message={getApiErrorMessage(auditQuery.error)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="detail-grid">
|
||||
<div className="panel">
|
||||
<h3>Аудит схемы</h3>
|
||||
<p className="muted">
|
||||
История событий по схеме. Список отсортирован от новых к старым.
|
||||
</p>
|
||||
|
||||
<div className="stats-grid">
|
||||
<StatCard label="Всего событий" value={counts.all} />
|
||||
<StatCard label="Жизненный цикл" value={counts.lifecycle} />
|
||||
<StatCard label="Тарифы" value={counts.pricing} />
|
||||
<StatCard label="Версионирование" value={counts.versioning} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${selectedGroup === "all" ? "btn-primary" : "btn-secondary"}`}
|
||||
onClick={() => setSelectedGroup("all")}
|
||||
>
|
||||
Все ({counts.all})
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${selectedGroup === "lifecycle" ? "btn-primary" : "btn-secondary"}`}
|
||||
onClick={() => setSelectedGroup("lifecycle")}
|
||||
>
|
||||
Жизненный цикл ({counts.lifecycle})
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${selectedGroup === "pricing" ? "btn-primary" : "btn-secondary"}`}
|
||||
onClick={() => setSelectedGroup("pricing")}
|
||||
>
|
||||
Тарифы ({counts.pricing})
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${selectedGroup === "versioning" ? "btn-primary" : "btn-secondary"}`}
|
||||
onClick={() => setSelectedGroup("versioning")}
|
||||
>
|
||||
Версионирование ({counts.versioning})
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${selectedGroup === "upload" ? "btn-primary" : "btn-secondary"}`}
|
||||
onClick={() => setSelectedGroup("upload")}
|
||||
>
|
||||
Загрузка / импорт ({counts.upload})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<h3>События</h3>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="muted">Событий для выбранной группы нет.</p>
|
||||
) : (
|
||||
<div className="audit-list">
|
||||
{filtered.map((item, index) => {
|
||||
const rowKey = item.audit_event_id || `${item.event_type || "event"}-${index}`;
|
||||
const isOpen = expandedRows[rowKey] === true;
|
||||
const details = detailsMap[rowKey] || "{}";
|
||||
|
||||
return (
|
||||
<div key={rowKey} className="audit-card">
|
||||
<div className="audit-card-head">
|
||||
<div>
|
||||
<div className="audit-title">{valueText(item.event_type)}</div>
|
||||
<div className="muted">
|
||||
Группа: {groupLabel(detectGroup(item.event_type))} · Создано: {valueText(item.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() =>
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !prev[rowKey]
|
||||
}))
|
||||
}
|
||||
>
|
||||
{isOpen ? "Скрыть details_json" : "Показать details_json"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="audit-meta-grid">
|
||||
<div><strong>Тип объекта:</strong> {valueText(item.object_type)}</div>
|
||||
<div><strong>Ссылка на объект:</strong> {valueText(item.object_ref)}</div>
|
||||
<div><strong>ID события:</strong> <span className="mono-cell">{valueText(item.audit_event_id)}</span></div>
|
||||
</div>
|
||||
|
||||
{isOpen ? (
|
||||
<div className="code-box" style={{ marginTop: 12 }}>
|
||||
{details}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
src/features/schemes/SchemeOverviewTab.tsx
Normal file
161
src/features/schemes/SchemeOverviewTab.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { getApiErrorMessage, useCreateNextVersionMutation, usePublishSchemeMutation, useRollbackSchemeMutation, useSchemeCurrentQuery, useSchemeDetailQuery, useSchemeVersionsQuery, useUnpublishSchemeMutation } from "../../api/queries";
|
||||
import { localizeStatus } from "../../shared/lib/formatters";
|
||||
import { ApiErrorView } from "../../shared/ui/ApiErrorView";
|
||||
import { StatCard } from "../../shared/ui/StatCard";
|
||||
|
||||
type Props = {
|
||||
schemeId: string;
|
||||
};
|
||||
|
||||
export function SchemeOverviewTab({ schemeId }: Props) {
|
||||
const detailQuery = useSchemeDetailQuery(schemeId);
|
||||
const currentQuery = useSchemeCurrentQuery(schemeId);
|
||||
const versionsQuery = useSchemeVersionsQuery(schemeId);
|
||||
|
||||
const publishMutation = usePublishSchemeMutation(schemeId);
|
||||
const unpublishMutation = useUnpublishSchemeMutation(schemeId);
|
||||
const createNextVersionMutation = useCreateNextVersionMutation(schemeId);
|
||||
const rollbackMutation = useRollbackSchemeMutation(schemeId);
|
||||
|
||||
const versions = versionsQuery.data ?? [];
|
||||
const rollbackDefault = [...versions]
|
||||
.map((item) => Number(item.version_number))
|
||||
.filter((value) => Number.isFinite(value))
|
||||
.sort((a, b) => b - a)[0];
|
||||
|
||||
const isBusy =
|
||||
publishMutation.isPending ||
|
||||
unpublishMutation.isPending ||
|
||||
createNextVersionMutation.isPending ||
|
||||
rollbackMutation.isPending;
|
||||
|
||||
if (detailQuery.isLoading || currentQuery.isLoading || versionsQuery.isLoading) {
|
||||
return <div className="panel">Загрузка карточки схемы...</div>;
|
||||
}
|
||||
|
||||
if (detailQuery.isError) {
|
||||
return <ApiErrorView title="Ошибка загрузки карточки схемы" message={getApiErrorMessage(detailQuery.error)} />;
|
||||
}
|
||||
|
||||
if (currentQuery.isError) {
|
||||
return <ApiErrorView title="Ошибка загрузки текущей версии" message={getApiErrorMessage(currentQuery.error)} />;
|
||||
}
|
||||
|
||||
if (versionsQuery.isError) {
|
||||
return <ApiErrorView title="Ошибка загрузки версий" message={getApiErrorMessage(versionsQuery.error)} />;
|
||||
}
|
||||
|
||||
const scheme = detailQuery.data;
|
||||
const current = currentQuery.data;
|
||||
|
||||
return (
|
||||
<div className="detail-grid">
|
||||
<div className="panel">
|
||||
<div className="scheme-card-header">
|
||||
<div>
|
||||
<h2>{scheme?.name ?? schemeId}</h2>
|
||||
<div className="muted">ID схемы: {schemeId}</div>
|
||||
</div>
|
||||
<div className="badge">{localizeStatus(scheme?.status)}</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
<StatCard label="Текущая версия" value={scheme?.current_version_number ?? "—"} />
|
||||
<StatCard label="Опубликовано" value={scheme?.published_at ?? "—"} />
|
||||
<StatCard label="Исходная загрузка" value={scheme?.source_upload_id ?? "—"} />
|
||||
<StatCard label="Статус схемы" value={localizeStatus(scheme?.status)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<h3>Текущая версия</h3>
|
||||
<div className="stats-grid">
|
||||
<StatCard label="Версия" value={current?.version_number ?? "—"} />
|
||||
<StatCard label="Элементы" value={current?.normalized_elements_count ?? 0} />
|
||||
<StatCard label="Места" value={current?.normalized_seats_count ?? 0} />
|
||||
<StatCard label="Группы" value={current?.normalized_groups_count ?? 0} />
|
||||
</div>
|
||||
<div className="stats-grid" style={{ marginTop: 10 }}>
|
||||
<StatCard label="Секторы" value={current?.normalized_sectors_count ?? 0} />
|
||||
<StatCard label="Статус версии" value={localizeStatus(current?.status)} />
|
||||
<StatCard label="ID версии" value={current?.scheme_version_id ?? "—"} />
|
||||
<StatCard label="Создано" value={current?.created_at ?? "—"} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<h3>Действия жизненного цикла</h3>
|
||||
<div className="toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={isBusy}
|
||||
onClick={() => publishMutation.mutate()}
|
||||
>
|
||||
{publishMutation.isPending ? "Публикация..." : "Опубликовать текущую"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => unpublishMutation.mutate()}
|
||||
>
|
||||
{unpublishMutation.isPending ? "Снятие публикации..." : "Снять с публикации"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => createNextVersionMutation.mutate()}
|
||||
>
|
||||
{createNextVersionMutation.isPending ? "Создание..." : "Создать следующую черновую версию"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled={isBusy || !rollbackDefault}
|
||||
onClick={() => {
|
||||
if (rollbackDefault) {
|
||||
rollbackMutation.mutate(rollbackDefault);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{rollbackMutation.isPending ? "Откат..." : `Откатить к v${rollbackDefault ?? "?"}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(publishMutation.isError || unpublishMutation.isError || createNextVersionMutation.isError || rollbackMutation.isError) ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<ApiErrorView
|
||||
title="Ошибка действия жизненного цикла"
|
||||
message={
|
||||
getApiErrorMessage(
|
||||
publishMutation.error ??
|
||||
unpublishMutation.error ??
|
||||
createNextVersionMutation.error ??
|
||||
rollbackMutation.error
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(publishMutation.data || unpublishMutation.data || createNextVersionMutation.data || rollbackMutation.data) ? (
|
||||
<div style={{ marginTop: 12 }} className="code-box">
|
||||
{JSON.stringify(
|
||||
publishMutation.data ??
|
||||
unpublishMutation.data ??
|
||||
createNextVersionMutation.data ??
|
||||
rollbackMutation.data,
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
393
src/features/schemes/SchemePricingTab.tsx
Normal file
393
src/features/schemes/SchemePricingTab.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
getApiErrorMessage,
|
||||
getApiFieldErrors,
|
||||
useCreatePriceRuleMutation,
|
||||
useCreatePricingCategoryMutation,
|
||||
useDeletePriceRuleMutation,
|
||||
useDeletePricingCategoryMutation,
|
||||
useSchemeGroupsQuery,
|
||||
useSchemePricingQuery,
|
||||
useSchemeSeatsQuery,
|
||||
useSchemeSectorsQuery,
|
||||
useUpdatePriceRuleMutation,
|
||||
useUpdatePricingCategoryMutation
|
||||
} from "../../api/queries";
|
||||
import type { CreatePriceRuleRequest, PricingCategoryItem, PriceRuleItem } from "../../shared/types/api";
|
||||
import { ApiErrorView } from "../../shared/ui/ApiErrorView";
|
||||
import { StatCard } from "../../shared/ui/StatCard";
|
||||
|
||||
type Props = {
|
||||
schemeId: string;
|
||||
};
|
||||
|
||||
type TargetType = "sector" | "group" | "seat";
|
||||
|
||||
type CategoryFormState = {
|
||||
name: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
type RuleFormState = {
|
||||
pricing_category_id: string;
|
||||
target_type: TargetType;
|
||||
target_ref: string;
|
||||
amount: string;
|
||||
currency: "RUB";
|
||||
};
|
||||
|
||||
function emptyCategoryForm(): CategoryFormState {
|
||||
return { name: "", code: "" };
|
||||
}
|
||||
|
||||
function emptyRuleForm(): RuleFormState {
|
||||
return { pricing_category_id: "", target_type: "sector", target_ref: "", amount: "", currency: "RUB" };
|
||||
}
|
||||
|
||||
function normalizeAmount(value: string): string {
|
||||
return value.replace(",", ".").trim();
|
||||
}
|
||||
|
||||
function targetTypeLabel(value: string | null | undefined): string {
|
||||
if (value === "sector") return "Сектор";
|
||||
if (value === "group") return "Группа";
|
||||
if (value === "seat") return "Место";
|
||||
return value || "—";
|
||||
}
|
||||
|
||||
function valueText(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "—";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function getCategoryDisplayName(item: PricingCategoryItem): string {
|
||||
return valueText(item.name || item.code || item.pricing_category_id);
|
||||
}
|
||||
|
||||
export function SchemePricingTab({ schemeId }: Props) {
|
||||
const pricingQuery = useSchemePricingQuery(schemeId);
|
||||
const sectorsQuery = useSchemeSectorsQuery(schemeId);
|
||||
const groupsQuery = useSchemeGroupsQuery(schemeId);
|
||||
const seatsQuery = useSchemeSeatsQuery(schemeId);
|
||||
|
||||
const createCategoryMutation = useCreatePricingCategoryMutation(schemeId);
|
||||
const updateCategoryMutation = useUpdatePricingCategoryMutation(schemeId);
|
||||
const deleteCategoryMutation = useDeletePricingCategoryMutation(schemeId);
|
||||
|
||||
const createRuleMutation = useCreatePriceRuleMutation(schemeId);
|
||||
const updateRuleMutation = useUpdatePriceRuleMutation(schemeId);
|
||||
const deleteRuleMutation = useDeletePriceRuleMutation(schemeId);
|
||||
|
||||
const [categoryForm, setCategoryForm] = useState<CategoryFormState>(emptyCategoryForm());
|
||||
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
|
||||
|
||||
const [ruleForm, setRuleForm] = useState<RuleFormState>(emptyRuleForm());
|
||||
const [editingRuleId, setEditingRuleId] = useState<string | null>(null);
|
||||
|
||||
if (pricingQuery.isLoading || sectorsQuery.isLoading || groupsQuery.isLoading || seatsQuery.isLoading) {
|
||||
return <div className="panel">Загрузка тарифов...</div>;
|
||||
}
|
||||
|
||||
if (pricingQuery.isError) {
|
||||
return <ApiErrorView title="Ошибка загрузки тарифов" message={getApiErrorMessage(pricingQuery.error)} />;
|
||||
}
|
||||
|
||||
if (sectorsQuery.isError) {
|
||||
return <ApiErrorView title="Ошибка загрузки секторов" message={getApiErrorMessage(sectorsQuery.error)} />;
|
||||
}
|
||||
|
||||
if (groupsQuery.isError) {
|
||||
return <ApiErrorView title="Ошибка загрузки групп" message={getApiErrorMessage(groupsQuery.error)} />;
|
||||
}
|
||||
|
||||
if (seatsQuery.isError) {
|
||||
return <ApiErrorView title="Ошибка загрузки мест" message={getApiErrorMessage(seatsQuery.error)} />;
|
||||
}
|
||||
|
||||
const categories = Array.isArray(pricingQuery.data?.categories) ? pricingQuery.data!.categories : [];
|
||||
const rules = Array.isArray(pricingQuery.data?.rules) ? pricingQuery.data!.rules : [];
|
||||
const sectors = Array.isArray(sectorsQuery.data) ? sectorsQuery.data : [];
|
||||
const groups = Array.isArray(groupsQuery.data) ? groupsQuery.data : [];
|
||||
const seats = Array.isArray(seatsQuery.data) ? seatsQuery.data : [];
|
||||
|
||||
const categoryMutationError = createCategoryMutation.error ?? updateCategoryMutation.error ?? deleteCategoryMutation.error;
|
||||
const ruleMutationError = createRuleMutation.error ?? updateRuleMutation.error ?? deleteRuleMutation.error;
|
||||
const ruleFieldErrors = getApiFieldErrors(ruleMutationError);
|
||||
|
||||
const isCategoryBusy = createCategoryMutation.isPending || updateCategoryMutation.isPending || deleteCategoryMutation.isPending;
|
||||
const isRuleBusy = createRuleMutation.isPending || updateRuleMutation.isPending || deleteRuleMutation.isPending;
|
||||
|
||||
let targetOptions: Array<{ value: string; label: string }> = [];
|
||||
|
||||
if (ruleForm.target_type === "sector") {
|
||||
targetOptions = sectors.filter((item) => item.sector_id).map((item) => ({
|
||||
value: String(item.sector_id),
|
||||
label: `${item.sector_id}${item.name ? ` — ${item.name}` : ""}`
|
||||
}));
|
||||
} else if (ruleForm.target_type === "group") {
|
||||
targetOptions = groups.filter((item) => item.group_id).map((item) => ({
|
||||
value: String(item.group_id),
|
||||
label: `${item.group_id}${item.name ? ` — ${item.name}` : ""}`
|
||||
}));
|
||||
} else {
|
||||
targetOptions = seats.filter((item) => item.seat_id).map((item) => ({
|
||||
value: String(item.seat_id),
|
||||
label: `${item.seat_id} / ряд ${item.row_label || "—"} / место ${item.seat_number || "—"}`
|
||||
}));
|
||||
}
|
||||
|
||||
const handleCategorySubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const payload = {
|
||||
name: categoryForm.name.trim(),
|
||||
code: categoryForm.code.trim()
|
||||
};
|
||||
|
||||
if (!payload.name || !payload.code) return;
|
||||
|
||||
if (editingCategoryId) {
|
||||
await updateCategoryMutation.mutateAsync({ pricingCategoryId: editingCategoryId, payload });
|
||||
} else {
|
||||
await createCategoryMutation.mutateAsync(payload);
|
||||
}
|
||||
|
||||
setCategoryForm(emptyCategoryForm());
|
||||
setEditingCategoryId(null);
|
||||
};
|
||||
|
||||
const handleRuleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const payload: CreatePriceRuleRequest = {
|
||||
pricing_category_id: ruleForm.pricing_category_id.trim(),
|
||||
target_type: ruleForm.target_type,
|
||||
target_ref: ruleForm.target_ref.trim(),
|
||||
amount: normalizeAmount(ruleForm.amount),
|
||||
currency: "RUB"
|
||||
};
|
||||
|
||||
if (!payload.pricing_category_id || !payload.target_ref || !payload.amount) return;
|
||||
|
||||
if (editingRuleId) {
|
||||
await updateRuleMutation.mutateAsync({ priceRuleId: editingRuleId, payload });
|
||||
} else {
|
||||
await createRuleMutation.mutateAsync(payload);
|
||||
}
|
||||
|
||||
setRuleForm(emptyRuleForm());
|
||||
setEditingRuleId(null);
|
||||
};
|
||||
|
||||
const startCategoryEdit = (item: PricingCategoryItem) => {
|
||||
setEditingCategoryId(item.pricing_category_id || null);
|
||||
setCategoryForm({ name: item.name || "", code: item.code || "" });
|
||||
};
|
||||
|
||||
const startRuleEdit = (item: PriceRuleItem) => {
|
||||
setEditingRuleId(item.price_rule_id || null);
|
||||
setRuleForm({
|
||||
pricing_category_id: item.pricing_category_id || "",
|
||||
target_type: (item.target_type as TargetType) || "sector",
|
||||
target_ref: item.target_ref || "",
|
||||
amount: item.amount || "",
|
||||
currency: "RUB"
|
||||
});
|
||||
};
|
||||
|
||||
const resolveCategoryName = (pricingCategoryId: string | null | undefined): string => {
|
||||
if (!pricingCategoryId) return "—";
|
||||
const found = categories.find((item) => item.pricing_category_id === pricingCategoryId);
|
||||
return found ? getCategoryDisplayName(found) : pricingCategoryId;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="detail-grid">
|
||||
<div className="panel">
|
||||
<h3>Тарифы текущей схемы</h3>
|
||||
<p className="muted">Управление тарифными категориями и правилами ценообразования. Валюта версии v1 — только RUB.</p>
|
||||
|
||||
<div className="stats-grid">
|
||||
<StatCard label="Категории" value={categories.length} />
|
||||
<StatCard label="Правила" value={rules.length} />
|
||||
<StatCard label="Секторы для выбора" value={sectors.length} />
|
||||
<StatCard label="Места для выбора" value={seats.length} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<h3>{editingCategoryId ? "Редактирование категории" : "Создание категории"}</h3>
|
||||
|
||||
<form className="form-grid" onSubmit={handleCategorySubmit}>
|
||||
<input className="text-input" type="text" placeholder="Название категории" value={categoryForm.name} onChange={(e) => setCategoryForm((p) => ({ ...p, name: e.target.value }))} />
|
||||
<input className="text-input" type="text" placeholder="Код категории" value={categoryForm.code} onChange={(e) => setCategoryForm((p) => ({ ...p, code: e.target.value }))} />
|
||||
|
||||
<div className="toolbar">
|
||||
<button type="submit" className="btn btn-primary" disabled={isCategoryBusy}>
|
||||
{editingCategoryId ? (updateCategoryMutation.isPending ? "Сохранение..." : "Сохранить категорию") : (createCategoryMutation.isPending ? "Создание..." : "Создать категорию")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={isCategoryBusy} onClick={() => { setEditingCategoryId(null); setCategoryForm(emptyCategoryForm()); }}>
|
||||
Сбросить форму
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{categoryMutationError ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<ApiErrorView title="Ошибка операции с категорией" message={getApiErrorMessage(categoryMutationError)} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<h3>Категории</h3>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Код</th>
|
||||
<th>ID категории</th>
|
||||
<th>Создано</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.length === 0 ? (
|
||||
<tr><td colSpan={5}>Категорий пока нет</td></tr>
|
||||
) : (
|
||||
categories.map((item, index) => (
|
||||
<tr key={`${item.pricing_category_id ?? "cat"}-${index}`}>
|
||||
<td>{valueText(item.name)}</td>
|
||||
<td>{valueText(item.code)}</td>
|
||||
<td className="mono-cell">{valueText(item.pricing_category_id)}</td>
|
||||
<td>{valueText(item.created_at)}</td>
|
||||
<td>
|
||||
<div className="toolbar">
|
||||
<button type="button" className="btn btn-secondary" disabled={isCategoryBusy} onClick={() => startCategoryEdit(item)}>Изменить</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled={isCategoryBusy || !item.pricing_category_id}
|
||||
onClick={() => { if (item.pricing_category_id) deleteCategoryMutation.mutate(item.pricing_category_id); }}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<h3>{editingRuleId ? "Редактирование правила" : "Создание правила"}</h3>
|
||||
|
||||
<form className="form-grid" onSubmit={handleRuleSubmit}>
|
||||
<select className="select" value={ruleForm.pricing_category_id} onChange={(e) => setRuleForm((p) => ({ ...p, pricing_category_id: e.target.value }))}>
|
||||
<option value="">Выбери категорию</option>
|
||||
{categories.map((item, index) => (
|
||||
<option key={`${item.pricing_category_id ?? "cat"}-${index}`} value={item.pricing_category_id || ""}>{getCategoryDisplayName(item)}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="select"
|
||||
value={ruleForm.target_type}
|
||||
onChange={(e) => setRuleForm((p) => ({ ...p, target_type: e.target.value as TargetType, target_ref: "" }))}
|
||||
>
|
||||
<option value="sector">Сектор</option>
|
||||
<option value="group">Группа</option>
|
||||
<option value="seat">Место</option>
|
||||
</select>
|
||||
|
||||
<select className="select" value={ruleForm.target_ref} onChange={(e) => setRuleForm((p) => ({ ...p, target_ref: e.target.value }))}>
|
||||
<option value="">Выбери цель</option>
|
||||
{targetOptions.map((item) => (
|
||||
<option key={item.value} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="form-field">
|
||||
<input
|
||||
className={`text-input ${ruleFieldErrors.amount ? "input-error" : ""}`}
|
||||
type="text"
|
||||
placeholder="Сумма, например 2500.00"
|
||||
value={ruleForm.amount}
|
||||
onChange={(e) => setRuleForm((p) => ({ ...p, amount: e.target.value }))}
|
||||
/>
|
||||
{ruleFieldErrors.amount ? <div className="field-error">{ruleFieldErrors.amount}</div> : null}
|
||||
</div>
|
||||
|
||||
<input className="text-input" type="text" value="RUB" disabled />
|
||||
|
||||
<div className="toolbar">
|
||||
<button type="submit" className="btn btn-primary" disabled={isRuleBusy}>
|
||||
{editingRuleId ? (updateRuleMutation.isPending ? "Сохранение..." : "Сохранить правило") : (createRuleMutation.isPending ? "Создание..." : "Создать правило")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={isRuleBusy} onClick={() => { setEditingRuleId(null); setRuleForm(emptyRuleForm()); }}>
|
||||
Сбросить форму
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{ruleMutationError ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<ApiErrorView title="Ошибка операции с правилом" message={getApiErrorMessage(ruleMutationError)} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<h3>Правила</h3>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Категория</th>
|
||||
<th>Тип цели</th>
|
||||
<th>Цель</th>
|
||||
<th>Сумма</th>
|
||||
<th>Валюта</th>
|
||||
<th>ID правила</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.length === 0 ? (
|
||||
<tr><td colSpan={7}>Правил пока нет</td></tr>
|
||||
) : (
|
||||
rules.map((item, index) => (
|
||||
<tr key={`${item.price_rule_id ?? "rule"}-${index}`}>
|
||||
<td>{resolveCategoryName(item.pricing_category_id)}</td>
|
||||
<td>{targetTypeLabel(item.target_type)}</td>
|
||||
<td>{valueText(item.target_ref)}</td>
|
||||
<td>{valueText(item.amount)}</td>
|
||||
<td>{valueText(item.currency)}</td>
|
||||
<td className="mono-cell">{valueText(item.price_rule_id)}</td>
|
||||
<td>
|
||||
<div className="toolbar">
|
||||
<button type="button" className="btn btn-secondary" disabled={isRuleBusy} onClick={() => startRuleEdit(item)}>Изменить</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled={isRuleBusy || !item.price_rule_id}
|
||||
onClick={() => { if (item.price_rule_id) deleteRuleMutation.mutate(item.price_rule_id); }}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
258
src/features/schemes/SchemeStructureTab.tsx
Normal file
258
src/features/schemes/SchemeStructureTab.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { getApiErrorMessage, useSchemeGroupsQuery, useSchemeSeatsQuery, useSchemeSectorsQuery } from "../../api/queries";
|
||||
import { ApiErrorView } from "../../shared/ui/ApiErrorView";
|
||||
import { StatCard } from "../../shared/ui/StatCard";
|
||||
|
||||
type Props = {
|
||||
schemeId: string;
|
||||
};
|
||||
|
||||
type InnerTab = "sectors" | "groups" | "seats";
|
||||
|
||||
function text(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "—";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function num(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return "—";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function SchemeStructureTab({ schemeId }: Props) {
|
||||
const sectorsQuery = useSchemeSectorsQuery(schemeId);
|
||||
const groupsQuery = useSchemeGroupsQuery(schemeId);
|
||||
const seatsQuery = useSchemeSeatsQuery(schemeId);
|
||||
|
||||
const [innerTab, setInnerTab] = useState<InnerTab>("sectors");
|
||||
const [seatFilter, setSeatFilter] = useState("");
|
||||
|
||||
const seatFilterNormalized = seatFilter.trim().toLowerCase();
|
||||
|
||||
const filteredSeats = useMemo(() => {
|
||||
const items = seatsQuery.data ?? [];
|
||||
|
||||
if (!seatFilterNormalized) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.filter((item) => {
|
||||
const haystack = [
|
||||
item.seat_id,
|
||||
item.sector_id,
|
||||
item.group_id,
|
||||
item.row_label,
|
||||
item.seat_number,
|
||||
item.element_id,
|
||||
item.tag
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
return haystack.includes(seatFilterNormalized);
|
||||
});
|
||||
}, [seatFilterNormalized, seatsQuery.data]);
|
||||
|
||||
if (sectorsQuery.isLoading || groupsQuery.isLoading || seatsQuery.isLoading) {
|
||||
return <div className="panel">Загрузка структуры схемы...</div>;
|
||||
}
|
||||
|
||||
if (sectorsQuery.isError) {
|
||||
return <ApiErrorView title="Ошибка загрузки секторов" message={getApiErrorMessage(sectorsQuery.error)} />;
|
||||
}
|
||||
|
||||
if (groupsQuery.isError) {
|
||||
return <ApiErrorView title="Ошибка загрузки групп" message={getApiErrorMessage(groupsQuery.error)} />;
|
||||
}
|
||||
|
||||
if (seatsQuery.isError) {
|
||||
return <ApiErrorView title="Ошибка загрузки мест" message={getApiErrorMessage(seatsQuery.error)} />;
|
||||
}
|
||||
|
||||
const sectors = sectorsQuery.data ?? [];
|
||||
const groups = groupsQuery.data ?? [];
|
||||
const seats = seatsQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="detail-grid">
|
||||
<div className="panel">
|
||||
<h3>Структура текущей версии</h3>
|
||||
<p className="muted">
|
||||
Это представление текущей нормализованной схемы только для чтения. Редактирование структуры через frontend пока не поддерживается.
|
||||
</p>
|
||||
|
||||
<div className="stats-grid">
|
||||
<StatCard label="Секторы" value={sectors.length} />
|
||||
<StatCard label="Группы" value={groups.length} />
|
||||
<StatCard label="Места" value={seats.length} />
|
||||
<StatCard label="После фильтра" value={filteredSeats.length} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${innerTab === "sectors" ? "btn-primary" : "btn-secondary"}`}
|
||||
onClick={() => setInnerTab("sectors")}
|
||||
>
|
||||
Секторы
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${innerTab === "groups" ? "btn-primary" : "btn-secondary"}`}
|
||||
onClick={() => setInnerTab("groups")}
|
||||
>
|
||||
Группы
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${innerTab === "seats" ? "btn-primary" : "btn-secondary"}`}
|
||||
onClick={() => setInnerTab("seats")}
|
||||
>
|
||||
Места
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{innerTab === "sectors" ? (
|
||||
<div className="panel">
|
||||
<h3>Секторы</h3>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID сектора</th>
|
||||
<th>Название</th>
|
||||
<th>ID элемента</th>
|
||||
<th>CSS-классы</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sectors.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4}>Секторов нет</td>
|
||||
</tr>
|
||||
) : (
|
||||
sectors.map((item, index) => (
|
||||
<tr key={`${item.sector_record_id ?? "sector"}-${index}`}>
|
||||
<td>{text(item.sector_id)}</td>
|
||||
<td>{text(item.name)}</td>
|
||||
<td>{text(item.element_id)}</td>
|
||||
<td className="mono-cell">{text(item.classes_raw)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{innerTab === "groups" ? (
|
||||
<div className="panel">
|
||||
<h3>Группы</h3>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID группы</th>
|
||||
<th>Название</th>
|
||||
<th>ID элемента</th>
|
||||
<th>CSS-классы</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4}>Групп нет</td>
|
||||
</tr>
|
||||
) : (
|
||||
groups.map((item, index) => (
|
||||
<tr key={`${item.group_record_id ?? "group"}-${index}`}>
|
||||
<td>{text(item.group_id)}</td>
|
||||
<td>{text(item.name)}</td>
|
||||
<td>{text(item.element_id)}</td>
|
||||
<td className="mono-cell">{text(item.classes_raw)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{innerTab === "seats" ? (
|
||||
<>
|
||||
<div className="panel">
|
||||
<h3>Места</h3>
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="text-input"
|
||||
type="text"
|
||||
placeholder="Фильтр по ID места, сектору, группе, ряду, номеру"
|
||||
value={seatFilter}
|
||||
onChange={(event) => setSeatFilter(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID места</th>
|
||||
<th>ID сектора</th>
|
||||
<th>ID группы</th>
|
||||
<th>Ряд</th>
|
||||
<th>Номер места</th>
|
||||
<th>SVG-тег</th>
|
||||
<th>X</th>
|
||||
<th>Y</th>
|
||||
<th>CX</th>
|
||||
<th>CY</th>
|
||||
<th>Ширина</th>
|
||||
<th>Высота</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredSeats.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={12}>Места не найдены</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredSeats.map((item, index) => (
|
||||
<tr key={`${item.seat_record_id ?? "seat"}-${index}`}>
|
||||
<td>{text(item.seat_id)}</td>
|
||||
<td>{text(item.sector_id)}</td>
|
||||
<td>{text(item.group_id)}</td>
|
||||
<td>{text(item.row_label)}</td>
|
||||
<td>{text(item.seat_number)}</td>
|
||||
<td>{text(item.tag)}</td>
|
||||
<td>{num(item.x)}</td>
|
||||
<td>{num(item.y)}</td>
|
||||
<td>{num(item.cx)}</td>
|
||||
<td>{num(item.cy)}</td>
|
||||
<td>{num(item.width)}</td>
|
||||
<td>{num(item.height)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
306
src/features/schemes/SchemeTestModeTab.tsx
Normal file
306
src/features/schemes/SchemeTestModeTab.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { getApiErrorMessage, useSchemeSeatsQuery, useTestSeatPreviewQuery } from "../../api/queries";
|
||||
import { ApiErrorView } from "../../shared/ui/ApiErrorView";
|
||||
import { StatCard } from "../../shared/ui/StatCard";
|
||||
|
||||
type Props = {
|
||||
schemeId: string;
|
||||
};
|
||||
|
||||
function text(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "—";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function boolText(value: boolean | null | undefined): string {
|
||||
if (value === true) return "Да";
|
||||
if (value === false) return "Нет";
|
||||
return "—";
|
||||
}
|
||||
|
||||
function targetLevelText(value: string | null | undefined): string {
|
||||
if (value === "seat") return "Место";
|
||||
if (value === "group") return "Группа";
|
||||
if (value === "sector") return "Сектор";
|
||||
return value || "—";
|
||||
}
|
||||
|
||||
type SeatRender = {
|
||||
seatId: string;
|
||||
rowLabel: string;
|
||||
seatNumber: string;
|
||||
tag: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
};
|
||||
|
||||
export function SchemeTestModeTab({ schemeId }: Props) {
|
||||
const seatsQuery = useSchemeSeatsQuery(schemeId);
|
||||
|
||||
const [selectedSeatId, setSelectedSeatId] = useState<string>("");
|
||||
const [seatFilter, setSeatFilter] = useState<string>("");
|
||||
|
||||
const previewQuery = useTestSeatPreviewQuery(schemeId, selectedSeatId || undefined);
|
||||
|
||||
const filteredSeats = useMemo(() => {
|
||||
const items = seatsQuery.data ?? [];
|
||||
const q = seatFilter.trim().toLowerCase();
|
||||
|
||||
if (!q) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.filter((item) => {
|
||||
const haystack = [
|
||||
item.seat_id,
|
||||
item.sector_id,
|
||||
item.group_id,
|
||||
item.row_label,
|
||||
item.seat_number,
|
||||
item.element_id,
|
||||
item.tag
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
return haystack.includes(q);
|
||||
});
|
||||
}, [seatFilter, seatsQuery.data]);
|
||||
|
||||
const renderSeats = useMemo(() => {
|
||||
const items = filteredSeats;
|
||||
const result: SeatRender[] = [];
|
||||
|
||||
items.forEach((item, index) => {
|
||||
if (!item.seat_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tag = item.tag || "rect";
|
||||
|
||||
if (tag === "circle") {
|
||||
const cx = item.cx ?? item.x ?? (index * 18 + 20);
|
||||
const cy = item.cy ?? item.y ?? 20;
|
||||
result.push({
|
||||
seatId: item.seat_id,
|
||||
rowLabel: item.row_label || "—",
|
||||
seatNumber: item.seat_number || "—",
|
||||
tag,
|
||||
x: cx - 6,
|
||||
y: cy - 6,
|
||||
width: 12,
|
||||
height: 12,
|
||||
cx,
|
||||
cy
|
||||
});
|
||||
} else {
|
||||
const x = item.x ?? item.cx ?? (index * 18 + 20);
|
||||
const y = item.y ?? item.cy ?? 20;
|
||||
const width = item.width ?? 12;
|
||||
const height = item.height ?? 12;
|
||||
|
||||
result.push({
|
||||
seatId: item.seat_id,
|
||||
rowLabel: item.row_label || "—",
|
||||
seatNumber: item.seat_number || "—",
|
||||
tag,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
cx: x + width / 2,
|
||||
cy: y + height / 2
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [filteredSeats]);
|
||||
|
||||
const viewBox = useMemo(() => {
|
||||
if (renderSeats.length === 0) {
|
||||
return "0 0 600 320";
|
||||
}
|
||||
|
||||
const minX = Math.min(...renderSeats.map((s) => Math.min(s.x, s.cx))) - 20;
|
||||
const minY = Math.min(...renderSeats.map((s) => Math.min(s.y, s.cy))) - 20;
|
||||
const maxX = Math.max(...renderSeats.map((s) => Math.max(s.x + s.width, s.cx + 8))) + 20;
|
||||
const maxY = Math.max(...renderSeats.map((s) => Math.max(s.y + s.height, s.cy + 8))) + 20;
|
||||
|
||||
return `${minX} ${minY} ${Math.max(200, maxX - minX)} ${Math.max(140, maxY - minY)}`;
|
||||
}, [renderSeats]);
|
||||
|
||||
if (seatsQuery.isLoading) {
|
||||
return <div className="panel">Загрузка тестового режима...</div>;
|
||||
}
|
||||
|
||||
if (seatsQuery.isError) {
|
||||
return <ApiErrorView title="Ошибка загрузки мест" message={getApiErrorMessage(seatsQuery.error)} />;
|
||||
}
|
||||
|
||||
const seats = seatsQuery.data ?? [];
|
||||
const selectedSeat = filteredSeats.find((item) => item.seat_id === selectedSeatId) || null;
|
||||
|
||||
return (
|
||||
<div className="detail-grid">
|
||||
<div className="panel">
|
||||
<h3>Тестовый режим</h3>
|
||||
<p className="muted">
|
||||
Визуальный preview строится по координатам текущей нормализованной схемы. Клик по месту вызывает backend preview API.
|
||||
</p>
|
||||
|
||||
<div className="stats-grid">
|
||||
<StatCard label="Всего мест" value={seats.length} />
|
||||
<StatCard label="После фильтра" value={filteredSeats.length} />
|
||||
<StatCard label="Выбрано место" value={selectedSeatId || "—"} />
|
||||
<StatCard label="Preview загружен" value={previewQuery.data ? "Да" : "Нет"} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="text-input"
|
||||
type="text"
|
||||
placeholder="Фильтр по ID места, сектору, группе, ряду, номеру"
|
||||
value={seatFilter}
|
||||
onChange={(event) => setSeatFilter(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="test-mode-grid">
|
||||
<div className="panel">
|
||||
<h3>SVG preview</h3>
|
||||
|
||||
<div className="svg-preview-wrap">
|
||||
<svg className="seat-preview-svg" viewBox={viewBox} preserveAspectRatio="xMidYMid meet">
|
||||
<rect x="-10000" y="-10000" width="20000" height="20000" fill="#f8fafc" />
|
||||
|
||||
{renderSeats.map((seat) => {
|
||||
const isSelected = seat.seatId === selectedSeatId;
|
||||
|
||||
if (seat.tag === "circle") {
|
||||
return (
|
||||
<g
|
||||
key={seat.seatId}
|
||||
onClick={() => setSelectedSeatId(seat.seatId)}
|
||||
className="seat-node"
|
||||
>
|
||||
<circle
|
||||
cx={seat.cx}
|
||||
cy={seat.cy}
|
||||
r={6}
|
||||
className={isSelected ? "seat-shape selected" : "seat-shape"}
|
||||
/>
|
||||
<text x={seat.cx} y={seat.cy - 10} textAnchor="middle" className="seat-label">
|
||||
{seat.seatNumber}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
key={seat.seatId}
|
||||
onClick={() => setSelectedSeatId(seat.seatId)}
|
||||
className="seat-node"
|
||||
>
|
||||
<rect
|
||||
x={seat.x}
|
||||
y={seat.y}
|
||||
width={seat.width}
|
||||
height={seat.height}
|
||||
rx={2}
|
||||
ry={2}
|
||||
className={isSelected ? "seat-shape selected" : "seat-shape"}
|
||||
/>
|
||||
<text
|
||||
x={seat.x + seat.width / 2}
|
||||
y={seat.y - 4}
|
||||
textAnchor="middle"
|
||||
className="seat-label"
|
||||
>
|
||||
{seat.seatNumber}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p className="muted" style={{ marginTop: 12 }}>
|
||||
Нажми на место на схеме. Для текущего v1 preview строится по координатам мест, а не по полному исходному SVG.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<h3>Инспектор места</h3>
|
||||
|
||||
{!selectedSeatId ? (
|
||||
<p className="muted">Выбери место на preview.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="stats-grid" style={{ marginBottom: 12 }}>
|
||||
<StatCard label="ID места" value={selectedSeatId} />
|
||||
<StatCard label="Ряд" value={selectedSeat?.row_label ?? "—"} />
|
||||
<StatCard label="Номер" value={selectedSeat?.seat_number ?? "—"} />
|
||||
<StatCard label="SVG тег" value={selectedSeat?.tag ?? "—"} />
|
||||
</div>
|
||||
|
||||
<div className="inspector-grid">
|
||||
<div><strong>ID элемента:</strong> {text(selectedSeat?.element_id)}</div>
|
||||
<div><strong>ID сектора:</strong> {text(selectedSeat?.sector_id)}</div>
|
||||
<div><strong>ID группы:</strong> {text(selectedSeat?.group_id)}</div>
|
||||
<div><strong>Классы:</strong> {text(selectedSeat?.classes_raw)}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{previewQuery.isLoading ? <div>Загрузка preview...</div> : null}
|
||||
|
||||
{previewQuery.isError ? (
|
||||
<ApiErrorView
|
||||
title="Ошибка preview места"
|
||||
message={getApiErrorMessage(previewQuery.error)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{previewQuery.data ? (
|
||||
<div className="panel panel-nested">
|
||||
<h4>Результат test mode</h4>
|
||||
|
||||
<div className="stats-grid">
|
||||
<StatCard label="Можно выбрать" value={boolText(previewQuery.data.selectable)} />
|
||||
<StatCard label="Цена найдена" value={boolText(previewQuery.data.has_price)} />
|
||||
<StatCard label="Уровень правила" value={targetLevelText(previewQuery.data.matched_rule_level)} />
|
||||
<StatCard label="Сумма" value={previewQuery.data.amount ?? "—"} />
|
||||
</div>
|
||||
|
||||
<div className="inspector-grid" style={{ marginTop: 12 }}>
|
||||
<div><strong>ID места:</strong> {text(previewQuery.data.seat_id)}</div>
|
||||
<div><strong>ID элемента:</strong> {text(previewQuery.data.element_id)}</div>
|
||||
<div><strong>ID сектора:</strong> {text(previewQuery.data.sector_id)}</div>
|
||||
<div><strong>ID группы:</strong> {text(previewQuery.data.group_id)}</div>
|
||||
<div><strong>Ряд:</strong> {text(previewQuery.data.row_label)}</div>
|
||||
<div><strong>Номер места:</strong> {text(previewQuery.data.seat_number)}</div>
|
||||
<div><strong>Цель правила:</strong> {text(previewQuery.data.matched_target_ref)}</div>
|
||||
<div><strong>ID категории тарифа:</strong> {text(previewQuery.data.pricing_category_id)}</div>
|
||||
<div><strong>Валюта:</strong> {text(previewQuery.data.currency)}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
src/features/schemes/SchemeVersionsTab.tsx
Normal file
93
src/features/schemes/SchemeVersionsTab.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useState } from "react";
|
||||
import { getApiErrorMessage, useRollbackSchemeMutation, useSchemeVersionsQuery } from "../../api/queries";
|
||||
import { localizeStatus } from "../../shared/lib/formatters";
|
||||
import { ApiErrorView } from "../../shared/ui/ApiErrorView";
|
||||
|
||||
type Props = {
|
||||
schemeId: string;
|
||||
};
|
||||
|
||||
export function SchemeVersionsTab({ schemeId }: Props) {
|
||||
const versionsQuery = useSchemeVersionsQuery(schemeId);
|
||||
const rollbackMutation = useRollbackSchemeMutation(schemeId);
|
||||
const [selectedVersion, setSelectedVersion] = useState<string>("");
|
||||
|
||||
if (versionsQuery.isLoading) {
|
||||
return <div className="panel">Загрузка версий...</div>;
|
||||
}
|
||||
|
||||
if (versionsQuery.isError) {
|
||||
return <ApiErrorView title="Ошибка загрузки версий" message={getApiErrorMessage(versionsQuery.error)} />;
|
||||
}
|
||||
|
||||
const versions = versionsQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="detail-grid">
|
||||
<div className="panel">
|
||||
<h3>Откат версии</h3>
|
||||
<div className="toolbar">
|
||||
<select
|
||||
className="select"
|
||||
value={selectedVersion}
|
||||
onChange={(event) => setSelectedVersion(event.target.value)}
|
||||
>
|
||||
<option value="">Выбери номер версии</option>
|
||||
{versions.map((item, index) => (
|
||||
<option key={`${item.scheme_version_id ?? "row"}-${index}`} value={String(item.version_number ?? "")}>
|
||||
v{item.version_number ?? "?"} / {localizeStatus(item.status)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled={rollbackMutation.isPending || !selectedVersion}
|
||||
onClick={() => rollbackMutation.mutate(Number(selectedVersion))}
|
||||
>
|
||||
{rollbackMutation.isPending ? "Откат..." : "Откатить"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rollbackMutation.isError ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<ApiErrorView title="Ошибка отката" message={getApiErrorMessage(rollbackMutation.error)} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<h3>Версии</h3>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Номер версии</th>
|
||||
<th>Статус</th>
|
||||
<th>Создано</th>
|
||||
<th>ID версии</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{versions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4}>Версий нет</td>
|
||||
</tr>
|
||||
) : (
|
||||
versions.map((item, index) => (
|
||||
<tr key={`${item.scheme_version_id ?? "row"}-${index}`}>
|
||||
<td>{item.version_number ?? "—"}</td>
|
||||
<td>{localizeStatus(item.status)}</td>
|
||||
<td>{item.created_at ?? "—"}</td>
|
||||
<td className="mono-cell">{item.scheme_version_id ?? "—"}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
212
src/features/schemes/SchemeViewerTab.tsx
Normal file
212
src/features/schemes/SchemeViewerTab.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { getApiErrorMessage, useDisplaySvgMetaQuery, useDisplaySvgQuery, useLegacySvgQuery } from "../../api/queries";
|
||||
import { ApiErrorView } from "../../shared/ui/ApiErrorView";
|
||||
import { StatCard } from "../../shared/ui/StatCard";
|
||||
|
||||
type Props = {
|
||||
schemeId: string;
|
||||
};
|
||||
|
||||
function parseViewBox(viewBox: string | null | undefined): { minX: number; minY: number; width: number; height: number } | null {
|
||||
if (!viewBox || !viewBox.trim()) return null;
|
||||
const parts = viewBox.trim().split(/\s+/).map((v) => Number(v));
|
||||
if (parts.length !== 4 || parts.some((v) => Number.isNaN(v))) return null;
|
||||
return { minX: parts[0], minY: parts[1], width: parts[2], height: parts[3] };
|
||||
}
|
||||
|
||||
function parseSvgDimension(value: string | null | undefined): number | null {
|
||||
if (!value || !value.trim()) return null;
|
||||
const normalized = value.trim().toLowerCase().replace("px", "");
|
||||
const numeric = Number(normalized);
|
||||
return Number.isNaN(numeric) ? null : numeric;
|
||||
}
|
||||
|
||||
export function SchemeViewerTab({ schemeId }: Props) {
|
||||
const metaQuery = useDisplaySvgMetaQuery(schemeId);
|
||||
const displaySvgQuery = useDisplaySvgQuery(schemeId);
|
||||
const legacySvgQuery = useLegacySvgQuery(schemeId);
|
||||
|
||||
const [zoom, setZoom] = useState<number>(1);
|
||||
const [panX, setPanX] = useState<number>(0);
|
||||
const [panY, setPanY] = useState<number>(0);
|
||||
|
||||
const [displayBlobUrl, setDisplayBlobUrl] = useState<string>("");
|
||||
const [legacyBlobUrl, setLegacyBlobUrl] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!displaySvgQuery.data) return;
|
||||
const blob = new Blob([displaySvgQuery.data], { type: "image/svg+xml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setDisplayBlobUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [displaySvgQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!legacySvgQuery.data) return;
|
||||
const blob = new Blob([legacySvgQuery.data], { type: "image/svg+xml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setLegacyBlobUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [legacySvgQuery.data]);
|
||||
|
||||
const fitSource = useMemo(() => {
|
||||
const viewBox = parseViewBox(metaQuery.data?.view_box);
|
||||
if (viewBox) return `viewBox ${metaQuery.data?.view_box}`;
|
||||
|
||||
const width = parseSvgDimension(metaQuery.data?.width);
|
||||
const height = parseSvgDimension(metaQuery.data?.height);
|
||||
if (width && height) return `width/height ${width}×${height}`;
|
||||
|
||||
if (metaQuery.isSuccess) return "дефолт контейнера";
|
||||
return "не определён";
|
||||
}, [metaQuery.data, metaQuery.isSuccess]);
|
||||
|
||||
const handleRetry = async () => {
|
||||
await Promise.all([metaQuery.refetch(), displaySvgQuery.refetch(), legacySvgQuery.refetch()]);
|
||||
};
|
||||
|
||||
const handleFitReset = () => {
|
||||
setZoom(1);
|
||||
setPanX(0);
|
||||
setPanY(0);
|
||||
};
|
||||
|
||||
const handleOpenDisplay = () => {
|
||||
if (displayBlobUrl) window.open(displayBlobUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const handleOpenLegacy = () => {
|
||||
if (legacyBlobUrl) window.open(legacyBlobUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
if (metaQuery.isLoading || displaySvgQuery.isLoading || legacySvgQuery.isLoading) {
|
||||
return <div className="panel">Загрузка display viewer...</div>;
|
||||
}
|
||||
|
||||
if (metaQuery.isError) {
|
||||
const message = getApiErrorMessage(metaQuery.error);
|
||||
|
||||
return (
|
||||
<div className="detail-grid">
|
||||
<div className="panel">
|
||||
<h3>Просмотр схемы</h3>
|
||||
|
||||
{message === "Display SVG not found for current scheme version" ? (
|
||||
<ApiErrorView title="Display SVG отсутствует" message="Display SVG не найден для текущей версии схемы." />
|
||||
) : message === "Current scheme version is not ready for display rendering" ? (
|
||||
<ApiErrorView title="Схема ещё не готова для отображения" message="Текущая версия схемы ещё не готова для display rendering." />
|
||||
) : message === "Scheme not found" ? (
|
||||
<ApiErrorView title="Схема не найдена" message="Указанная схема не существует." />
|
||||
) : (
|
||||
<ApiErrorView title="Техническая ошибка viewer" message={message} />
|
||||
)}
|
||||
|
||||
<div className="toolbar" style={{ marginTop: 12 }}>
|
||||
<button type="button" className="btn btn-primary" onClick={handleRetry}>Повторить</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={handleOpenLegacy} disabled={!legacyBlobUrl}>
|
||||
Открыть исходный SVG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (displaySvgQuery.isError) {
|
||||
const message = getApiErrorMessage(displaySvgQuery.error);
|
||||
|
||||
return (
|
||||
<div className="detail-grid">
|
||||
<div className="panel">
|
||||
<h3>Просмотр схемы</h3>
|
||||
|
||||
{message === "Display SVG not found for current scheme version" ? (
|
||||
<ApiErrorView title="Display SVG отсутствует" message="Display SVG не найден для текущей версии схемы." />
|
||||
) : message === "Current scheme version is not ready for display rendering" ? (
|
||||
<ApiErrorView title="Схема ещё не готова для отображения" message="Текущая версия схемы ещё не готова для display rendering." />
|
||||
) : message === "Scheme not found" ? (
|
||||
<ApiErrorView title="Схема не найдена" message="Указанная схема не существует." />
|
||||
) : (
|
||||
<ApiErrorView title="Техническая ошибка viewer" message={message} />
|
||||
)}
|
||||
|
||||
<div className="toolbar" style={{ marginTop: 12 }}>
|
||||
<button type="button" className="btn btn-primary" onClick={handleRetry}>Повторить</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={handleOpenLegacy} disabled={!legacyBlobUrl}>
|
||||
Открыть исходный SVG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="detail-grid">
|
||||
<div className="panel">
|
||||
<h3>Просмотр схемы</h3>
|
||||
<p className="muted">
|
||||
Основной источник viewer — display SVG в режиме passthrough. Legacy endpoint используется только как fallback/debug.
|
||||
</p>
|
||||
|
||||
<div className="stats-grid">
|
||||
<StatCard label="Display SVG" value={metaQuery.data?.display_svg_available ? "Доступен" : "Недоступен"} />
|
||||
<StatCard label="Источник fit" value={fitSource} />
|
||||
<StatCard label="Версия схемы" value={metaQuery.data?.scheme_version_id ?? "—"} />
|
||||
<StatCard label="Сгенерировано" value={metaQuery.data?.generated_at ?? "—"} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="toolbar">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setZoom((v) => Math.max(0.25, Number((v - 0.1).toFixed(2))))}>Уменьшить</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setZoom((v) => Math.min(4, Number((v + 0.1).toFixed(2))))}>Увеличить</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={handleFitReset}>Сбросить вид</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setPanX((v) => v - 40)}>←</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setPanY((v) => v - 40)}>↑</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setPanY((v) => v + 40)}>↓</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setPanX((v) => v + 40)}>→</button>
|
||||
|
||||
<button type="button" className="btn btn-primary" onClick={handleOpenDisplay} disabled={!displayBlobUrl}>
|
||||
Открыть в новой вкладке
|
||||
</button>
|
||||
|
||||
<button type="button" className="btn btn-secondary" onClick={handleOpenLegacy} disabled={!legacyBlobUrl}>
|
||||
Открыть исходный SVG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<h3>Viewer</h3>
|
||||
<div className="svg-object-frame">
|
||||
<div
|
||||
className="svg-viewer-transform"
|
||||
style={{
|
||||
transform: `translate(${panX}px, ${panY}px) scale(${zoom})`,
|
||||
transformOrigin: "top left"
|
||||
}}
|
||||
>
|
||||
{displayBlobUrl ? (
|
||||
<object
|
||||
data={displayBlobUrl}
|
||||
type="image/svg+xml"
|
||||
className="svg-object-viewer"
|
||||
aria-label="Display SVG схема"
|
||||
>
|
||||
<div className="panel panel-nested">
|
||||
<p>Браузер не смог встроить display SVG во viewer.</p>
|
||||
<p className="muted">Используй кнопку «Открыть в новой вкладке».</p>
|
||||
</div>
|
||||
</object>
|
||||
) : (
|
||||
<div className="panel panel-nested">
|
||||
<p>Display SVG не загружен.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
src/main.tsx
Normal file
18
src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./app/App";
|
||||
import "./app/styles.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
84
src/pages/HomePage.tsx
Normal file
84
src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useSchemesQuery, getApiErrorMessage } from "../api/queries";
|
||||
import { ApiErrorView } from "../shared/ui/ApiErrorView";
|
||||
import { StatCard } from "../shared/ui/StatCard";
|
||||
import { localizeStatus } from "../shared/lib/formatters";
|
||||
|
||||
export function HomePage() {
|
||||
const schemesQuery = useSchemesQuery();
|
||||
|
||||
if (schemesQuery.isLoading) {
|
||||
return <div className="panel">Загрузка списка схем...</div>;
|
||||
}
|
||||
|
||||
if (schemesQuery.isError) {
|
||||
return (
|
||||
<ApiErrorView
|
||||
title="Не удалось загрузить список схем"
|
||||
message={getApiErrorMessage(schemesQuery.error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const items = schemesQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="panel">
|
||||
<div className="toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => schemesQuery.refetch()}
|
||||
disabled={schemesQuery.isFetching}
|
||||
>
|
||||
{schemesQuery.isFetching ? "Обновление..." : "Обновить список"}
|
||||
</button>
|
||||
|
||||
<Link to="/upload" className="btn btn-primary">
|
||||
Загрузить новый SVG
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<h2>Реестр схем</h2>
|
||||
<p className="muted">Всего схем: {schemesQuery.data?.total ?? items.length}</p>
|
||||
</div>
|
||||
|
||||
<div className="cards">
|
||||
{items.length === 0 ? (
|
||||
<div className="panel">Схем пока нет.</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div key={item.scheme_id} className="panel scheme-card">
|
||||
<div className="scheme-card-header">
|
||||
<div>
|
||||
<h3>{item.name}</h3>
|
||||
<div className="muted">ID схемы: {item.scheme_id}</div>
|
||||
</div>
|
||||
<div className="badge">{localizeStatus(item.status)}</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
<StatCard label="Версия" value={item.current_version_number ?? "—"} />
|
||||
<StatCard label="Места" value={item.normalized_seats_count ?? 0} />
|
||||
<StatCard label="Группы" value={item.normalized_groups_count ?? 0} />
|
||||
<StatCard label="Секторы" value={item.normalized_sectors_count ?? 0} />
|
||||
</div>
|
||||
|
||||
<div className="muted">Создано: {item.created_at}</div>
|
||||
<div className="muted">Опубликовано: {item.published_at ?? "—"}</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<Link to={`/schemes/${item.scheme_id}`} className="btn btn-secondary">
|
||||
Открыть карточку
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
src/pages/SchemeDetailPage.tsx
Normal file
56
src/pages/SchemeDetailPage.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { SchemeAuditTab } from "../features/schemes/SchemeAuditTab";
|
||||
import { SchemeOverviewTab } from "../features/schemes/SchemeOverviewTab";
|
||||
import { SchemePricingTab } from "../features/schemes/SchemePricingTab";
|
||||
import { SchemeStructureTab } from "../features/schemes/SchemeStructureTab";
|
||||
import { SchemeTestModeTab } from "../features/schemes/SchemeTestModeTab";
|
||||
import { SchemeVersionsTab } from "../features/schemes/SchemeVersionsTab";
|
||||
import { SchemeViewerTab } from "../features/schemes/SchemeViewerTab";
|
||||
|
||||
type DetailTab = "viewer" | "overview" | "versions" | "structure" | "pricing" | "test" | "audit";
|
||||
|
||||
export function SchemeDetailPage() {
|
||||
const { schemeId } = useParams();
|
||||
const [tab, setTab] = useState<DetailTab>("viewer");
|
||||
|
||||
if (!schemeId) {
|
||||
return (
|
||||
<div className="panel error">
|
||||
<h2>ID схемы отсутствует</h2>
|
||||
<Link to="/" className="btn btn-secondary">Назад к списку</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="panel">
|
||||
<div className="scheme-card-header">
|
||||
<div>
|
||||
<h1>Карточка схемы</h1>
|
||||
<p className="muted">ID схемы: {schemeId}</p>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<button type="button" className={`btn ${tab === "viewer" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("viewer")}>Просмотр схемы</button>
|
||||
<button type="button" className={`btn ${tab === "overview" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("overview")}>Обзор</button>
|
||||
<button type="button" className={`btn ${tab === "versions" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("versions")}>Версии</button>
|
||||
<button type="button" className={`btn ${tab === "structure" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("structure")}>Структура</button>
|
||||
<button type="button" className={`btn ${tab === "pricing" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("pricing")}>Тарифы</button>
|
||||
<button type="button" className={`btn ${tab === "test" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("test")}>Тестовый режим</button>
|
||||
<button type="button" className={`btn ${tab === "audit" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("audit")}>Аудит</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === "viewer" ? <SchemeViewerTab schemeId={schemeId} /> : null}
|
||||
{tab === "overview" ? <SchemeOverviewTab schemeId={schemeId} /> : null}
|
||||
{tab === "versions" ? <SchemeVersionsTab schemeId={schemeId} /> : null}
|
||||
{tab === "structure" ? <SchemeStructureTab schemeId={schemeId} /> : null}
|
||||
{tab === "pricing" ? <SchemePricingTab schemeId={schemeId} /> : null}
|
||||
{tab === "test" ? <SchemeTestModeTab schemeId={schemeId} /> : null}
|
||||
{tab === "audit" ? <SchemeAuditTab schemeId={schemeId} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
99
src/pages/UploadPage.tsx
Normal file
99
src/pages/UploadPage.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useManifestQuery, useUploadSchemeMutation, getApiErrorMessage } from "../api/queries";
|
||||
import { ApiErrorView } from "../shared/ui/ApiErrorView";
|
||||
|
||||
export function UploadPage() {
|
||||
const manifestQuery = useManifestQuery();
|
||||
const uploadMutation = useUploadSchemeMutation();
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
const manifestLimits = useMemo(() => {
|
||||
const size = manifestQuery.data?.svg_limits?.max_file_size_bytes;
|
||||
const elements = manifestQuery.data?.svg_limits?.max_elements;
|
||||
return { size, elements };
|
||||
}, [manifestQuery.data]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
await uploadMutation.mutateAsync(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="panel">
|
||||
<h2>Импорт SVG</h2>
|
||||
<p className="muted">
|
||||
Backend выполняет validate → sanitize → normalize → create scheme → create version 1.
|
||||
</p>
|
||||
<p className="muted">
|
||||
Лимит размера: {manifestLimits.size ?? "n/a"} байт. Лимит элементов: {manifestLimits.elements ?? "n/a"}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<form className="form-grid" onSubmit={handleSubmit}>
|
||||
<input
|
||||
className="file-input"
|
||||
type="file"
|
||||
accept=".svg,image/svg+xml"
|
||||
onChange={(event) => {
|
||||
const selected = event.target.files?.[0] ?? null;
|
||||
setFile(selected);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="muted">
|
||||
Выбранный файл: {file ? `${file.name} (${file.size} bytes)` : "не выбран"}
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<button type="submit" className="btn btn-primary" disabled={!file || uploadMutation.isPending}>
|
||||
{uploadMutation.isPending ? "Загрузка..." : "Загрузить SVG"}
|
||||
</button>
|
||||
|
||||
<Link to="/" className="btn btn-secondary">
|
||||
Назад к списку
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{uploadMutation.isError ? (
|
||||
<ApiErrorView
|
||||
title="Ошибка загрузки"
|
||||
message={getApiErrorMessage(uploadMutation.error)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{uploadMutation.data ? (
|
||||
<div className="panel">
|
||||
<h3>Результат загрузки</h3>
|
||||
<div className="code-box">{JSON.stringify(uploadMutation.data, null, 2)}</div>
|
||||
|
||||
<div className="toolbar" style={{ marginTop: 12 }}>
|
||||
<Link to="/" className="btn btn-secondary">
|
||||
К списку схем
|
||||
</Link>
|
||||
|
||||
{typeof uploadMutation.data.scheme_id === "string" && uploadMutation.data.scheme_id.trim() ? (
|
||||
<Link to={`/schemes/${uploadMutation.data.scheme_id}`} className="btn btn-primary">
|
||||
Открыть карточку схемы
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!uploadMutation.data.scheme_id ? (
|
||||
<p className="muted" style={{ marginTop: 12 }}>
|
||||
Backend пока может не возвращать scheme_id в ответе загрузки. Это допустимый gap.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
src/shared/config/env.ts
Normal file
20
src/shared/config/env.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
type AppConfig = {
|
||||
appTitle: string;
|
||||
apiBaseUrl: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
function requireEnv(name: string, value: string | undefined): string {
|
||||
if (!value || !value.trim()) {
|
||||
throw new Error(`Missing required env: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getAppConfig(): AppConfig {
|
||||
return {
|
||||
appTitle: import.meta.env.VITE_APP_TITLE || "SVG Service Admin UI",
|
||||
apiBaseUrl: requireEnv("VITE_API_BASE_URL", import.meta.env.VITE_API_BASE_URL),
|
||||
apiKey: requireEnv("VITE_API_KEY", import.meta.env.VITE_API_KEY)
|
||||
};
|
||||
}
|
||||
43
src/shared/lib/formatters.ts
Normal file
43
src/shared/lib/formatters.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export function localizeStatus(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return "—";
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
|
||||
const map: Record<string, string> = {
|
||||
draft: "Черновик",
|
||||
published: "Опубликовано",
|
||||
unpublished: "Снято с публикации",
|
||||
archived: "Архив",
|
||||
active: "Активно",
|
||||
inactive: "Неактивно",
|
||||
error: "Ошибка",
|
||||
failed: "Ошибка",
|
||||
success: "Успешно",
|
||||
pending: "В обработке",
|
||||
processing: "Обрабатывается",
|
||||
created: "Создано",
|
||||
unknown: "Неизвестно"
|
||||
};
|
||||
|
||||
return map[normalized] ?? value;
|
||||
}
|
||||
|
||||
export function localizeRole(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return "—";
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
|
||||
const map: Record<string, string> = {
|
||||
admin: "Администратор",
|
||||
editor: "Редактор",
|
||||
viewer: "Наблюдатель",
|
||||
operator: "Оператор",
|
||||
unknown: "Неизвестно"
|
||||
};
|
||||
|
||||
return map[normalized] ?? value;
|
||||
}
|
||||
259
src/shared/types/api.ts
Normal file
259
src/shared/types/api.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
export type AuthMeResponse = {
|
||||
role: string;
|
||||
auth_header: string;
|
||||
};
|
||||
|
||||
export type ManifestResponse = {
|
||||
service?: string;
|
||||
api_prefix?: string;
|
||||
auth_header_name?: string;
|
||||
svg_limits?: {
|
||||
max_file_size_bytes?: number;
|
||||
max_elements?: number;
|
||||
};
|
||||
sanitization?: Record<string, unknown>;
|
||||
extraction_contract?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ApiValidationFieldError = {
|
||||
field: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ApiErrorPayload = {
|
||||
detail?: string;
|
||||
errors?: ApiValidationFieldError[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type SchemesListItem = {
|
||||
scheme_id: string;
|
||||
source_upload_id: string | null;
|
||||
name: string;
|
||||
status: string;
|
||||
current_version_number: number | null;
|
||||
published_at: string | null;
|
||||
normalized_elements_count: number | null;
|
||||
normalized_seats_count: number | null;
|
||||
normalized_groups_count: number | null;
|
||||
normalized_sectors_count: number | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type SchemesListResponse = {
|
||||
items: SchemesListItem[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type SchemeDetailResponse = {
|
||||
scheme_id: string;
|
||||
source_upload_id?: string | null;
|
||||
name?: string;
|
||||
status?: string;
|
||||
current_version_number?: number | null;
|
||||
published_at?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type SchemeCurrentVersionResponse = {
|
||||
scheme_version_id?: string;
|
||||
scheme_id?: string;
|
||||
version_number?: number | null;
|
||||
status?: string;
|
||||
normalized_elements_count?: number | null;
|
||||
normalized_seats_count?: number | null;
|
||||
normalized_groups_count?: number | null;
|
||||
normalized_sectors_count?: number | null;
|
||||
created_at?: string;
|
||||
normalized_storage_path?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type SchemeVersionItem = {
|
||||
scheme_version_id?: string;
|
||||
scheme_id?: string;
|
||||
version_number?: number | null;
|
||||
status?: string;
|
||||
created_at?: string;
|
||||
normalized_storage_path?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type SchemeVersionsResponse = SchemeVersionItem[] | { items?: SchemeVersionItem[] };
|
||||
|
||||
export type SchemeSectorItem = {
|
||||
sector_record_id?: string;
|
||||
scheme_id?: string;
|
||||
scheme_version_id?: string;
|
||||
element_id?: string | null;
|
||||
sector_id?: string | null;
|
||||
name?: string | null;
|
||||
classes_raw?: string | null;
|
||||
created_at?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type SchemeGroupItem = {
|
||||
group_record_id?: string;
|
||||
scheme_id?: string;
|
||||
scheme_version_id?: string;
|
||||
element_id?: string | null;
|
||||
group_id?: string | null;
|
||||
name?: string | null;
|
||||
classes_raw?: string | null;
|
||||
created_at?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type SchemeSeatItem = {
|
||||
seat_record_id?: string;
|
||||
scheme_id?: string;
|
||||
scheme_version_id?: string;
|
||||
element_id?: string | null;
|
||||
seat_id?: string | null;
|
||||
sector_id?: string | null;
|
||||
group_id?: string | null;
|
||||
row_label?: string | null;
|
||||
seat_number?: string | null;
|
||||
tag?: string | null;
|
||||
classes_raw?: string | null;
|
||||
x?: number | null;
|
||||
y?: number | null;
|
||||
cx?: number | null;
|
||||
cy?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
created_at?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type SchemeSectorsResponse = SchemeSectorItem[] | { items?: SchemeSectorItem[] };
|
||||
export type SchemeGroupsResponse = SchemeGroupItem[] | { items?: SchemeGroupItem[] };
|
||||
export type SchemeSeatsResponse = SchemeSeatItem[] | { items?: SchemeSeatItem[] };
|
||||
|
||||
export type PricingCategoryItem = {
|
||||
pricing_category_id?: string;
|
||||
scheme_id?: string;
|
||||
name?: string;
|
||||
code?: string;
|
||||
created_at?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type PriceRuleItem = {
|
||||
price_rule_id?: string;
|
||||
scheme_id?: string;
|
||||
pricing_category_id?: string;
|
||||
target_type?: string;
|
||||
target_ref?: string;
|
||||
amount?: string;
|
||||
currency?: string;
|
||||
created_at?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type SchemePricingResponse = {
|
||||
categories?: PricingCategoryItem[];
|
||||
rules?: PriceRuleItem[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type DisplaySvgMetaResponse = {
|
||||
scheme_id: string;
|
||||
scheme_version_id: string;
|
||||
display_svg_available: boolean;
|
||||
view_box: string | null;
|
||||
width: string | null;
|
||||
height: string | null;
|
||||
generated_at: string | null;
|
||||
};
|
||||
|
||||
export type TestSeatPreviewResponse = {
|
||||
scheme_id?: string;
|
||||
scheme_version_id?: string;
|
||||
seat_id?: string;
|
||||
element_id?: string | null;
|
||||
sector_id?: string | null;
|
||||
group_id?: string | null;
|
||||
row_label?: string | null;
|
||||
seat_number?: string | null;
|
||||
selectable?: boolean;
|
||||
has_price?: boolean;
|
||||
matched_rule_level?: string | null;
|
||||
matched_target_ref?: string | null;
|
||||
pricing_category_id?: string | null;
|
||||
amount?: string | null;
|
||||
currency?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type SchemeAuditItem = {
|
||||
audit_event_id?: string;
|
||||
scheme_id?: string;
|
||||
event_type?: string;
|
||||
object_type?: string | null;
|
||||
object_ref?: string | null;
|
||||
details_json?: unknown;
|
||||
created_at?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type SchemeAuditResponse = SchemeAuditItem[] | { items?: SchemeAuditItem[] };
|
||||
|
||||
export type CreatePricingCategoryRequest = {
|
||||
name: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export type UpdatePricingCategoryRequest = {
|
||||
name: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export type CreatePriceRuleRequest = {
|
||||
pricing_category_id: string;
|
||||
target_type: "sector" | "group" | "seat";
|
||||
target_ref: string;
|
||||
amount: string;
|
||||
currency: "RUB";
|
||||
};
|
||||
|
||||
export type UpdatePriceRuleRequest = {
|
||||
pricing_category_id: string;
|
||||
target_type: "sector" | "group" | "seat";
|
||||
target_ref: string;
|
||||
amount: string;
|
||||
currency: "RUB";
|
||||
};
|
||||
|
||||
export type UploadSchemeResponse = {
|
||||
upload_id?: string;
|
||||
scheme_id?: string;
|
||||
filename?: string;
|
||||
content_type?: string;
|
||||
size_bytes?: number;
|
||||
file_size_bytes?: number;
|
||||
accepted?: boolean;
|
||||
element_count?: number;
|
||||
removed_elements_count?: number;
|
||||
removed_attributes_count?: number;
|
||||
normalized_elements_count?: number;
|
||||
normalized_seats_count?: number;
|
||||
normalized_groups_count?: number;
|
||||
normalized_sectors_count?: number;
|
||||
counts?: Record<string, unknown>;
|
||||
storage_paths?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type LifecycleActionResponse = {
|
||||
scheme_id?: string;
|
||||
scheme_version_id?: string;
|
||||
version_number?: number;
|
||||
status?: string;
|
||||
normalized_storage_path?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
13
src/shared/ui/ApiErrorView.tsx
Normal file
13
src/shared/ui/ApiErrorView.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
type Props = {
|
||||
title: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function ApiErrorView({ title, message }: Props) {
|
||||
return (
|
||||
<div className="panel error">
|
||||
<h3>{title}</h3>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
src/shared/ui/StatCard.tsx
Normal file
13
src/shared/ui/StatCard.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
type Props = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
export function StatCard({ label, value }: Props) {
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">{label}</div>
|
||||
<div className="stat-value">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
tsconfig.app.json
Normal file
19
tsconfig.app.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
6
tsconfig.json
Normal file
6
tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" }
|
||||
]
|
||||
}
|
||||
22
vite.config.ts
Normal file
22
vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: Number(env.VITE_FRONTEND_PORT || 28080),
|
||||
proxy: {
|
||||
"/api-proxy": {
|
||||
target: env.VITE_API_PROXY_TARGET,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path.replace(/^\/api-proxy/, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user