commit 89e52e3193b9a81483996922331d44ad17b48a58 Author: greebo Date: Thu Mar 19 13:42:23 2026 +0300 Initial commit: svg frontend diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..61d168f --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a4a16f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bff84f3 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..90ffbaf --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/index.html b/index.html new file mode 100644 index 0000000..cdf012d --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + SVG Service Front + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..f970491 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..0e1929d --- /dev/null +++ b/src/api/client.ts @@ -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 + } +}); diff --git a/src/api/queries.ts b/src/api/queries.ts new file mode 100644 index 0000000..a52de94 --- /dev/null +++ b/src/api/queries.ts @@ -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(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 { + if (!axios.isAxiosError(error)) return {}; + const errors = error.response?.data?.errors; + if (!Array.isArray(errors)) return {}; + + const result: Record = {}; + for (const item of errors) { + if (item && typeof item.field === "string" && typeof item.message === "string") { + result[item.field] = item.message; + } + } + return result; +} + +function getItems(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("/api/v1/auth/me")).data, + retry: false + }); +} + +export function useManifestQuery() { + return useQuery({ + queryKey: ["manifest"], + queryFn: async () => (await apiClient.get("/api/v1/manifest")).data, + retry: false + }); +} + +export function useSchemesQuery() { + return useQuery({ + queryKey: ["schemes"], + queryFn: async () => (await apiClient.get("/api/v1/schemes")).data + }); +} + +export function useSchemeDetailQuery(schemeId: string | undefined) { + return useQuery({ + queryKey: ["scheme-detail", schemeId], + enabled: Boolean(schemeId), + queryFn: async () => (await apiClient.get(`/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(`/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(`/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(`/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(`/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(`/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(`/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(`/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(`/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(`/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(`/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(`/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("/api/v1/schemes/upload", formData, { + headers: { "Content-Type": "multipart/form-data" } + }) + ).data; + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["schemes"] }); + } + }); +} + +async function refetchSchemeBundle(queryClient: ReturnType, 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(`/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(`/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(`/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(`/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(`/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( + `/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(`/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(`/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] }); + } + } + }); +} diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 0000000..ba7ecbb --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,5 @@ +import { AppRouter } from "./router"; + +export function App() { + return ; +} diff --git a/src/app/AppLayout.tsx b/src/app/AppLayout.tsx new file mode 100644 index 0000000..6dfcf4f --- /dev/null +++ b/src/app/AppLayout.tsx @@ -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 ( +
+ + +
+
+
+
Схема зала
+
frontend v1
+
+ +
+ Загрузить SVG + К списку схем +
+
+ + {authError ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/app/router.tsx b/src/app/router.tsx new file mode 100644 index 0000000..a901391 --- /dev/null +++ b/src/app/router.tsx @@ -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 ( + + }> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/src/app/styles.css b/src/app/styles.css new file mode 100644 index 0000000..bb6183f --- /dev/null +++ b/src/app/styles.css @@ -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; +} diff --git a/src/features/schemes/SchemeAuditTab.tsx b/src/features/schemes/SchemeAuditTab.tsx new file mode 100644 index 0000000..67eee54 --- /dev/null +++ b/src/features/schemes/SchemeAuditTab.tsx @@ -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("all"); + const [expandedRows, setExpandedRows] = useState>({}); + + 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 = {}; + + 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
Загрузка аудита...
; + } + + if (auditQuery.isError) { + return ; + } + + return ( +
+
+

Аудит схемы

+

+ История событий по схеме. Список отсортирован от новых к старым. +

+ +
+ + + + +
+
+ +
+
+ + + + + + + + + +
+
+ +
+

События

+ + {filtered.length === 0 ? ( +

Событий для выбранной группы нет.

+ ) : ( +
+ {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 ( +
+
+
+
{valueText(item.event_type)}
+
+ Группа: {groupLabel(detectGroup(item.event_type))} · Создано: {valueText(item.created_at)} +
+
+ +
+ +
+
+ +
+
Тип объекта: {valueText(item.object_type)}
+
Ссылка на объект: {valueText(item.object_ref)}
+
ID события: {valueText(item.audit_event_id)}
+
+ + {isOpen ? ( +
+ {details} +
+ ) : null} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/src/features/schemes/SchemeOverviewTab.tsx b/src/features/schemes/SchemeOverviewTab.tsx new file mode 100644 index 0000000..f87bc31 --- /dev/null +++ b/src/features/schemes/SchemeOverviewTab.tsx @@ -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
Загрузка карточки схемы...
; + } + + if (detailQuery.isError) { + return ; + } + + if (currentQuery.isError) { + return ; + } + + if (versionsQuery.isError) { + return ; + } + + const scheme = detailQuery.data; + const current = currentQuery.data; + + return ( +
+
+
+
+

{scheme?.name ?? schemeId}

+
ID схемы: {schemeId}
+
+
{localizeStatus(scheme?.status)}
+
+ +
+ + + + +
+
+ +
+

Текущая версия

+
+ + + + +
+
+ + + + +
+
+ +
+

Действия жизненного цикла

+
+ + + + + + + +
+ + {(publishMutation.isError || unpublishMutation.isError || createNextVersionMutation.isError || rollbackMutation.isError) ? ( +
+ +
+ ) : null} + + {(publishMutation.data || unpublishMutation.data || createNextVersionMutation.data || rollbackMutation.data) ? ( +
+ {JSON.stringify( + publishMutation.data ?? + unpublishMutation.data ?? + createNextVersionMutation.data ?? + rollbackMutation.data, + null, + 2 + )} +
+ ) : null} +
+
+ ); +} diff --git a/src/features/schemes/SchemePricingTab.tsx b/src/features/schemes/SchemePricingTab.tsx new file mode 100644 index 0000000..250523c --- /dev/null +++ b/src/features/schemes/SchemePricingTab.tsx @@ -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(emptyCategoryForm()); + const [editingCategoryId, setEditingCategoryId] = useState(null); + + const [ruleForm, setRuleForm] = useState(emptyRuleForm()); + const [editingRuleId, setEditingRuleId] = useState(null); + + if (pricingQuery.isLoading || sectorsQuery.isLoading || groupsQuery.isLoading || seatsQuery.isLoading) { + return
Загрузка тарифов...
; + } + + if (pricingQuery.isError) { + return ; + } + + if (sectorsQuery.isError) { + return ; + } + + if (groupsQuery.isError) { + return ; + } + + if (seatsQuery.isError) { + return ; + } + + 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) => { + 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) => { + 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 ( +
+
+

Тарифы текущей схемы

+

Управление тарифными категориями и правилами ценообразования. Валюта версии v1 — только RUB.

+ +
+ + + + +
+
+ +
+

{editingCategoryId ? "Редактирование категории" : "Создание категории"}

+ +
+ setCategoryForm((p) => ({ ...p, name: e.target.value }))} /> + setCategoryForm((p) => ({ ...p, code: e.target.value }))} /> + +
+ + +
+
+ + {categoryMutationError ? ( +
+ +
+ ) : null} +
+ +
+

Категории

+
+ + + + + + + + + + + + {categories.length === 0 ? ( + + ) : ( + categories.map((item, index) => ( + + + + + + + + )) + )} + +
НазваниеКодID категорииСозданоДействия
Категорий пока нет
{valueText(item.name)}{valueText(item.code)}{valueText(item.pricing_category_id)}{valueText(item.created_at)} +
+ + +
+
+
+
+ +
+

{editingRuleId ? "Редактирование правила" : "Создание правила"}

+ +
+ + + + + + +
+ setRuleForm((p) => ({ ...p, amount: e.target.value }))} + /> + {ruleFieldErrors.amount ?
{ruleFieldErrors.amount}
: null} +
+ + + +
+ + +
+
+ + {ruleMutationError ? ( +
+ +
+ ) : null} +
+ +
+

Правила

+
+ + + + + + + + + + + + + + {rules.length === 0 ? ( + + ) : ( + rules.map((item, index) => ( + + + + + + + + + + )) + )} + +
КатегорияТип целиЦельСуммаВалютаID правилаДействия
Правил пока нет
{resolveCategoryName(item.pricing_category_id)}{targetTypeLabel(item.target_type)}{valueText(item.target_ref)}{valueText(item.amount)}{valueText(item.currency)}{valueText(item.price_rule_id)} +
+ + +
+
+
+
+
+ ); +} diff --git a/src/features/schemes/SchemeStructureTab.tsx b/src/features/schemes/SchemeStructureTab.tsx new file mode 100644 index 0000000..0342b30 --- /dev/null +++ b/src/features/schemes/SchemeStructureTab.tsx @@ -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("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
Загрузка структуры схемы...
; + } + + if (sectorsQuery.isError) { + return ; + } + + if (groupsQuery.isError) { + return ; + } + + if (seatsQuery.isError) { + return ; + } + + const sectors = sectorsQuery.data ?? []; + const groups = groupsQuery.data ?? []; + const seats = seatsQuery.data ?? []; + + return ( +
+
+

Структура текущей версии

+

+ Это представление текущей нормализованной схемы только для чтения. Редактирование структуры через frontend пока не поддерживается. +

+ +
+ + + + +
+
+ +
+
+ + + + + +
+
+ + {innerTab === "sectors" ? ( +
+

Секторы

+
+ + + + + + + + + + + {sectors.length === 0 ? ( + + + + ) : ( + sectors.map((item, index) => ( + + + + + + + )) + )} + +
ID сектораНазваниеID элементаCSS-классы
Секторов нет
{text(item.sector_id)}{text(item.name)}{text(item.element_id)}{text(item.classes_raw)}
+
+
+ ) : null} + + {innerTab === "groups" ? ( +
+

Группы

+
+ + + + + + + + + + + {groups.length === 0 ? ( + + + + ) : ( + groups.map((item, index) => ( + + + + + + + )) + )} + +
ID группыНазваниеID элементаCSS-классы
Групп нет
{text(item.group_id)}{text(item.name)}{text(item.element_id)}{text(item.classes_raw)}
+
+
+ ) : null} + + {innerTab === "seats" ? ( + <> +
+

Места

+
+ setSeatFilter(event.target.value)} + /> +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + {filteredSeats.length === 0 ? ( + + + + ) : ( + filteredSeats.map((item, index) => ( + + + + + + + + + + + + + + + )) + )} + +
ID местаID сектораID группыРядНомер местаSVG-тегXYCXCYШиринаВысота
Места не найдены
{text(item.seat_id)}{text(item.sector_id)}{text(item.group_id)}{text(item.row_label)}{text(item.seat_number)}{text(item.tag)}{num(item.x)}{num(item.y)}{num(item.cx)}{num(item.cy)}{num(item.width)}{num(item.height)}
+
+
+ + ) : null} +
+ ); +} diff --git a/src/features/schemes/SchemeTestModeTab.tsx b/src/features/schemes/SchemeTestModeTab.tsx new file mode 100644 index 0000000..991f4f8 --- /dev/null +++ b/src/features/schemes/SchemeTestModeTab.tsx @@ -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(""); + const [seatFilter, setSeatFilter] = useState(""); + + 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
Загрузка тестового режима...
; + } + + if (seatsQuery.isError) { + return ; + } + + const seats = seatsQuery.data ?? []; + const selectedSeat = filteredSeats.find((item) => item.seat_id === selectedSeatId) || null; + + return ( +
+
+

Тестовый режим

+

+ Визуальный preview строится по координатам текущей нормализованной схемы. Клик по месту вызывает backend preview API. +

+ +
+ + + + +
+
+ +
+
+ setSeatFilter(event.target.value)} + /> +
+
+ +
+
+

SVG preview

+ +
+ + + + {renderSeats.map((seat) => { + const isSelected = seat.seatId === selectedSeatId; + + if (seat.tag === "circle") { + return ( + setSelectedSeatId(seat.seatId)} + className="seat-node" + > + + + {seat.seatNumber} + + + ); + } + + return ( + setSelectedSeatId(seat.seatId)} + className="seat-node" + > + + + {seat.seatNumber} + + + ); + })} + +
+ +

+ Нажми на место на схеме. Для текущего v1 preview строится по координатам мест, а не по полному исходному SVG. +

+
+ +
+

Инспектор места

+ + {!selectedSeatId ? ( +

Выбери место на preview.

+ ) : ( + <> +
+ + + + +
+ +
+
ID элемента: {text(selectedSeat?.element_id)}
+
ID сектора: {text(selectedSeat?.sector_id)}
+
ID группы: {text(selectedSeat?.group_id)}
+
Классы: {text(selectedSeat?.classes_raw)}
+
+ +
+ {previewQuery.isLoading ?
Загрузка preview...
: null} + + {previewQuery.isError ? ( + + ) : null} + + {previewQuery.data ? ( +
+

Результат test mode

+ +
+ + + + +
+ +
+
ID места: {text(previewQuery.data.seat_id)}
+
ID элемента: {text(previewQuery.data.element_id)}
+
ID сектора: {text(previewQuery.data.sector_id)}
+
ID группы: {text(previewQuery.data.group_id)}
+
Ряд: {text(previewQuery.data.row_label)}
+
Номер места: {text(previewQuery.data.seat_number)}
+
Цель правила: {text(previewQuery.data.matched_target_ref)}
+
ID категории тарифа: {text(previewQuery.data.pricing_category_id)}
+
Валюта: {text(previewQuery.data.currency)}
+
+
+ ) : null} +
+ + )} +
+
+
+ ); +} diff --git a/src/features/schemes/SchemeVersionsTab.tsx b/src/features/schemes/SchemeVersionsTab.tsx new file mode 100644 index 0000000..ae84f2f --- /dev/null +++ b/src/features/schemes/SchemeVersionsTab.tsx @@ -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(""); + + if (versionsQuery.isLoading) { + return
Загрузка версий...
; + } + + if (versionsQuery.isError) { + return ; + } + + const versions = versionsQuery.data ?? []; + + return ( +
+
+

Откат версии

+
+ + + +
+ + {rollbackMutation.isError ? ( +
+ +
+ ) : null} +
+ +
+

Версии

+
+ + + + + + + + + + + {versions.length === 0 ? ( + + + + ) : ( + versions.map((item, index) => ( + + + + + + + )) + )} + +
Номер версииСтатусСозданоID версии
Версий нет
{item.version_number ?? "—"}{localizeStatus(item.status)}{item.created_at ?? "—"}{item.scheme_version_id ?? "—"}
+
+
+
+ ); +} diff --git a/src/features/schemes/SchemeViewerTab.tsx b/src/features/schemes/SchemeViewerTab.tsx new file mode 100644 index 0000000..78b6971 --- /dev/null +++ b/src/features/schemes/SchemeViewerTab.tsx @@ -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(1); + const [panX, setPanX] = useState(0); + const [panY, setPanY] = useState(0); + + const [displayBlobUrl, setDisplayBlobUrl] = useState(""); + const [legacyBlobUrl, setLegacyBlobUrl] = useState(""); + + 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
Загрузка display viewer...
; + } + + if (metaQuery.isError) { + const message = getApiErrorMessage(metaQuery.error); + + return ( +
+
+

Просмотр схемы

+ + {message === "Display SVG not found for current scheme version" ? ( + + ) : message === "Current scheme version is not ready for display rendering" ? ( + + ) : message === "Scheme not found" ? ( + + ) : ( + + )} + +
+ + +
+
+
+ ); + } + + if (displaySvgQuery.isError) { + const message = getApiErrorMessage(displaySvgQuery.error); + + return ( +
+
+

Просмотр схемы

+ + {message === "Display SVG not found for current scheme version" ? ( + + ) : message === "Current scheme version is not ready for display rendering" ? ( + + ) : message === "Scheme not found" ? ( + + ) : ( + + )} + +
+ + +
+
+
+ ); + } + + return ( +
+
+

Просмотр схемы

+

+ Основной источник viewer — display SVG в режиме passthrough. Legacy endpoint используется только как fallback/debug. +

+ +
+ + + + +
+
+ +
+
+ + + + + + + + + + + +
+
+ +
+

Viewer

+
+
+ {displayBlobUrl ? ( + +
+

Браузер не смог встроить display SVG во viewer.

+

Используй кнопку «Открыть в новой вкладке».

+
+
+ ) : ( +
+

Display SVG не загружен.

+
+ )} +
+
+
+
+ ); +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..da51d31 --- /dev/null +++ b/src/main.tsx @@ -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( + + + + + + + +); diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx new file mode 100644 index 0000000..7e2b2eb --- /dev/null +++ b/src/pages/HomePage.tsx @@ -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
Загрузка списка схем...
; + } + + if (schemesQuery.isError) { + return ( + + ); + } + + const items = schemesQuery.data?.items ?? []; + + return ( + <> +
+
+ + + + Загрузить новый SVG + +
+
+ +
+

Реестр схем

+

Всего схем: {schemesQuery.data?.total ?? items.length}

+
+ +
+ {items.length === 0 ? ( +
Схем пока нет.
+ ) : ( + items.map((item) => ( +
+
+
+

{item.name}

+
ID схемы: {item.scheme_id}
+
+
{localizeStatus(item.status)}
+
+ +
+ + + + +
+ +
Создано: {item.created_at}
+
Опубликовано: {item.published_at ?? "—"}
+ +
+ + Открыть карточку + +
+
+ )) + )} +
+ + ); +} diff --git a/src/pages/SchemeDetailPage.tsx b/src/pages/SchemeDetailPage.tsx new file mode 100644 index 0000000..5bece49 --- /dev/null +++ b/src/pages/SchemeDetailPage.tsx @@ -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("viewer"); + + if (!schemeId) { + return ( +
+

ID схемы отсутствует

+ Назад к списку +
+ ); + } + + return ( + <> +
+
+
+

Карточка схемы

+

ID схемы: {schemeId}

+
+ +
+ + + + + + + +
+
+
+ + {tab === "viewer" ? : null} + {tab === "overview" ? : null} + {tab === "versions" ? : null} + {tab === "structure" ? : null} + {tab === "pricing" ? : null} + {tab === "test" ? : null} + {tab === "audit" ? : null} + + ); +} diff --git a/src/pages/UploadPage.tsx b/src/pages/UploadPage.tsx new file mode 100644 index 0000000..3ef9393 --- /dev/null +++ b/src/pages/UploadPage.tsx @@ -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(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) => { + event.preventDefault(); + if (!file) { + return; + } + await uploadMutation.mutateAsync(file); + }; + + return ( + <> +
+

Импорт SVG

+

+ Backend выполняет validate → sanitize → normalize → create scheme → create version 1. +

+

+ Лимит размера: {manifestLimits.size ?? "n/a"} байт. Лимит элементов: {manifestLimits.elements ?? "n/a"}. +

+
+ +
+
+ { + const selected = event.target.files?.[0] ?? null; + setFile(selected); + }} + /> + +
+ Выбранный файл: {file ? `${file.name} (${file.size} bytes)` : "не выбран"} +
+ +
+ + + + Назад к списку + +
+
+
+ + {uploadMutation.isError ? ( + + ) : null} + + {uploadMutation.data ? ( +
+

Результат загрузки

+
{JSON.stringify(uploadMutation.data, null, 2)}
+ +
+ + К списку схем + + + {typeof uploadMutation.data.scheme_id === "string" && uploadMutation.data.scheme_id.trim() ? ( + + Открыть карточку схемы + + ) : null} +
+ + {!uploadMutation.data.scheme_id ? ( +

+ Backend пока может не возвращать scheme_id в ответе загрузки. Это допустимый gap. +

+ ) : null} +
+ ) : null} + + ); +} diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts new file mode 100644 index 0000000..61213c9 --- /dev/null +++ b/src/shared/config/env.ts @@ -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) + }; +} diff --git a/src/shared/lib/formatters.ts b/src/shared/lib/formatters.ts new file mode 100644 index 0000000..c924d69 --- /dev/null +++ b/src/shared/lib/formatters.ts @@ -0,0 +1,43 @@ +export function localizeStatus(value: string | null | undefined): string { + if (!value) { + return "—"; + } + + const normalized = value.trim().toLowerCase(); + + const map: Record = { + 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 = { + admin: "Администратор", + editor: "Редактор", + viewer: "Наблюдатель", + operator: "Оператор", + unknown: "Неизвестно" + }; + + return map[normalized] ?? value; +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts new file mode 100644 index 0000000..65dc9c4 --- /dev/null +++ b/src/shared/types/api.ts @@ -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; + extraction_contract?: Record; + [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; + storage_paths?: Record; + [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; +}; diff --git a/src/shared/ui/ApiErrorView.tsx b/src/shared/ui/ApiErrorView.tsx new file mode 100644 index 0000000..492646c --- /dev/null +++ b/src/shared/ui/ApiErrorView.tsx @@ -0,0 +1,13 @@ +type Props = { + title: string; + message: string; +}; + +export function ApiErrorView({ title, message }: Props) { + return ( +
+

{title}

+

{message}

+
+ ); +} diff --git a/src/shared/ui/StatCard.tsx b/src/shared/ui/StatCard.tsx new file mode 100644 index 0000000..f6d1604 --- /dev/null +++ b/src/shared/ui/StatCard.tsx @@ -0,0 +1,13 @@ +type Props = { + label: string; + value: string | number; +}; + +export function StatCard({ label, value }: Props) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..f3a0ee0 --- /dev/null +++ b/tsconfig.app.json @@ -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"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..426eda2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,6 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..64affcf --- /dev/null +++ b/vite.config.ts @@ -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/, "") + } + } + } + }; +});