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