Initial commit: svg frontend

This commit is contained in:
greebo
2026-03-19 13:42:23 +03:00
commit 89e52e3193
31 changed files with 3425 additions and 0 deletions

4
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
import { AppRouter } from "./router";
export function App() {
return <AppRouter />;
}

74
src/app/AppLayout.tsx Normal file
View 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
View 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
View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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>
</>
);
}

View 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
View 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
View 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)
};
}

View 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
View 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;
};

View 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>
);
}

View 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
View 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
View File

@@ -0,0 +1,6 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" }
]
}

22
vite.config.ts Normal file
View 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/, "")
}
}
}
};
});