diff --git a/ordinis-admin-ui/.gitignore b/ordinis-admin-ui/.gitignore
new file mode 100644
index 0000000..b4b80bc
--- /dev/null
+++ b/ordinis-admin-ui/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+dist
+.vite
+*.log
+.env.local
diff --git a/ordinis-admin-ui/Dockerfile b/ordinis-admin-ui/Dockerfile
new file mode 100644
index 0000000..31d1e96
--- /dev/null
+++ b/ordinis-admin-ui/Dockerfile
@@ -0,0 +1,12 @@
+FROM repo.nstart.cloud/library/node:22-alpine AS build
+WORKDIR /app
+COPY package.json ./
+RUN corepack enable && corepack prepare pnpm@latest --activate
+RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --no-frozen-lockfile
+COPY . .
+RUN pnpm build
+
+FROM repo.nstart.cloud/library/nginx:1.27-alpine
+COPY --from=build /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
diff --git a/ordinis-admin-ui/README.md b/ordinis-admin-ui/README.md
new file mode 100644
index 0000000..14709c0
--- /dev/null
+++ b/ordinis-admin-ui/README.md
@@ -0,0 +1,30 @@
+# Ordinis Admin UI
+
+React 19 + Vite 7 + TanStack Router + TanStack Query + Tailwind v4 + i18next.
+
+## Локально
+
+```bash
+cd ordinis-admin-ui
+pnpm install
+# .env.local: VITE_API_PROXY=http://localhost:8081 (port-forward read-api)
+pnpm dev
+```
+
+## Контракт API
+
+- `GET /api/v1/dictionaries` (read-api) — список справочников
+- `GET /api/v1/{dictionaryName}/records` (read-api) — записи с локализацией
+- `POST /api/v1/dictionaries` (writer) — создание справочника (TODO в UI)
+- `POST /api/v1/dictionaries/{name}/records` (writer) — создание записи (TODO в UI)
+
+## Сборка / Docker
+
+```bash
+docker build -t repo.nstart.cloud/terravault/ordinis-admin-ui:dev .
+docker run --rm -p 8088:80 repo.nstart.cloud/terravault/ordinis-admin-ui:dev
+# → http://localhost:8088
+```
+
+В кластере nginx проксирует `/api/v1/*` в `ordinis-read-api` (GET) и
+`ordinis-app` (POST/PUT/PATCH/DELETE).
diff --git a/ordinis-admin-ui/index.html b/ordinis-admin-ui/index.html
new file mode 100644
index 0000000..1e4c863
--- /dev/null
+++ b/ordinis-admin-ui/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Ordinis Admin — НСИ ЦУОД
+
+
+
+
+
+
diff --git a/ordinis-admin-ui/nginx.conf b/ordinis-admin-ui/nginx.conf
new file mode 100644
index 0000000..72faf03
--- /dev/null
+++ b/ordinis-admin-ui/nginx.conf
@@ -0,0 +1,36 @@
+server {
+ listen 80;
+ server_name _;
+
+ # SPA history fallback
+ root /usr/share/nginx/html;
+ index index.html;
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # API proxy в read-api/writer (через k8s service DNS).
+ # Реальный endpoint задаётся через env-template или ENV в pod, в v1
+ # просто проксируем на ordinis-read-api (только GET) и ordinis-app (write).
+ location /api/v1/dictionaries {
+ # POST/PUT идут в writer, GET — в read-api. Простое разделение по methods:
+ if ($request_method ~ ^(POST|PUT|PATCH|DELETE)$) {
+ proxy_pass http://ordinis-app:80;
+ }
+ proxy_pass http://ordinis-read-api:80;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_pass_request_headers on;
+ }
+
+ location ~ ^/api/v1/[^/]+/records {
+ if ($request_method ~ ^(POST|PUT|PATCH|DELETE)$) {
+ proxy_pass http://ordinis-app:80;
+ }
+ proxy_pass http://ordinis-read-api:80;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_pass_request_headers on;
+ }
+}
diff --git a/ordinis-admin-ui/package.json b/ordinis-admin-ui/package.json
new file mode 100644
index 0000000..183c938
--- /dev/null
+++ b/ordinis-admin-ui/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "ordinis-admin-ui",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview --host 0.0.0.0 --port 4173"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^5.62.0",
+ "@tanstack/react-router": "^1.86.0",
+ "axios": "^1.7.9",
+ "i18next": "^24.0.5",
+ "i18next-browser-languagedetector": "^8.0.2",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "react-i18next": "^15.4.0"
+ },
+ "devDependencies": {
+ "@tanstack/router-vite-plugin": "^1.86.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^4.3.4",
+ "tailwindcss": "^4.0.0-beta.7",
+ "@tailwindcss/vite": "^4.0.0-beta.7",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.5"
+ }
+}
diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts
new file mode 100644
index 0000000..de3d550
--- /dev/null
+++ b/ordinis-admin-ui/src/api/client.ts
@@ -0,0 +1,53 @@
+import axios from 'axios'
+
+/**
+ * Centralized axios instance. JWT injection вешается через interceptor:
+ * когда @nstart/auth интегрируется, добавляем Authorization: Bearer ...
+ */
+export const apiClient = axios.create({
+ baseURL: import.meta.env.VITE_API_BASE ?? '/api/v1',
+ timeout: 10_000,
+})
+
+apiClient.interceptors.request.use((config) => {
+ const lang = (typeof navigator !== 'undefined' && navigator.language) || 'ru-RU'
+ config.headers['Accept-Language'] = `${lang},ru-RU;q=0.9,en-US;q=0.8`
+ // JWT placeholder
+ const token = localStorage.getItem('ordinis.token')
+ if (token) config.headers['Authorization'] = `Bearer ${token}`
+ return config
+})
+
+apiClient.interceptors.response.use(
+ (resp) => resp,
+ (error) => {
+ if (error.response?.status === 401) {
+ localStorage.removeItem('ordinis.token')
+ }
+ return Promise.reject(error)
+ },
+)
+
+export type DictionaryDefinition = {
+ id: string
+ name: string
+ displayName?: string
+ description?: string
+ scope: 'PUBLIC' | 'INTERNAL' | 'RESTRICTED'
+ schemaVersion: string
+ bundle: string
+ supportedLocales: string[]
+ defaultLocale: string
+ createdAt: string
+ updatedAt: string
+}
+
+export type FlattenedRecord = {
+ id: string
+ businessKey: string
+ data: Record
+ dataScope: 'PUBLIC' | 'INTERNAL' | 'RESTRICTED'
+ validFrom: string
+ validTo: string
+ _meta?: { locale?: string }
+}
diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts
new file mode 100644
index 0000000..f5d8413
--- /dev/null
+++ b/ordinis-admin-ui/src/api/queries.ts
@@ -0,0 +1,26 @@
+import { useQuery, queryOptions } from '@tanstack/react-query'
+import { apiClient, type DictionaryDefinition, type FlattenedRecord } from './client'
+
+export const dictionariesQuery = queryOptions({
+ queryKey: ['dictionaries'],
+ queryFn: async (): Promise => {
+ const { data } = await apiClient.get('/dictionaries')
+ return data
+ },
+})
+
+export const recordsQuery = (dictionaryName: string, scopeCsv: string) =>
+ queryOptions({
+ queryKey: ['records', dictionaryName, scopeCsv],
+ queryFn: async (): Promise => {
+ const { data } = await apiClient.get(
+ `/${dictionaryName}/records`,
+ { params: { as_scope: scopeCsv } },
+ )
+ return data
+ },
+ })
+
+export const useDictionaries = () => useQuery(dictionariesQuery)
+export const useRecords = (dictionaryName: string, scopeCsv: string) =>
+ useQuery(recordsQuery(dictionaryName, scopeCsv))
diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts
new file mode 100644
index 0000000..a25f7ce
--- /dev/null
+++ b/ordinis-admin-ui/src/i18n.ts
@@ -0,0 +1,46 @@
+import i18n from 'i18next'
+import { initReactI18next } from 'react-i18next'
+import LanguageDetector from 'i18next-browser-languagedetector'
+
+i18n
+ .use(LanguageDetector)
+ .use(initReactI18next)
+ .init({
+ fallbackLng: 'ru-RU',
+ supportedLngs: ['ru-RU', 'en-US'],
+ interpolation: { escapeValue: false },
+ resources: {
+ 'ru-RU': {
+ translation: {
+ 'app.title': 'Ordinis НСИ ЦУОД',
+ 'nav.dictionaries': 'Справочники',
+ 'header.scope': 'Доступный scope',
+ 'dict.empty': 'В этом справочнике пока нет записей',
+ 'dict.col.businessKey': 'Бизнес-ключ',
+ 'dict.col.scope': 'Scope',
+ 'dict.col.validFrom': 'Действует с',
+ 'dict.col.locale': 'Локаль',
+ 'dict.list.records': 'записей',
+ 'loading': 'Загрузка...',
+ 'error.failed': 'Не удалось загрузить данные',
+ },
+ },
+ 'en-US': {
+ translation: {
+ 'app.title': 'Ordinis MDM',
+ 'nav.dictionaries': 'Dictionaries',
+ 'header.scope': 'Allowed scope',
+ 'dict.empty': 'No records in this dictionary yet',
+ 'dict.col.businessKey': 'Business key',
+ 'dict.col.scope': 'Scope',
+ 'dict.col.validFrom': 'Valid from',
+ 'dict.col.locale': 'Locale',
+ 'dict.list.records': 'records',
+ 'loading': 'Loading...',
+ 'error.failed': 'Failed to load data',
+ },
+ },
+ },
+ })
+
+export default i18n
diff --git a/ordinis-admin-ui/src/main.tsx b/ordinis-admin-ui/src/main.tsx
new file mode 100644
index 0000000..4f13445
--- /dev/null
+++ b/ordinis-admin-ui/src/main.tsx
@@ -0,0 +1,29 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { RouterProvider, createRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+import './i18n'
+import './styles.css'
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { staleTime: 30_000, retry: 1 },
+ },
+})
+
+const router = createRouter({ routeTree, context: { queryClient } })
+
+declare module '@tanstack/react-router' {
+ interface Register {
+ router: typeof router
+ }
+}
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
+)
diff --git a/ordinis-admin-ui/src/routeTree.gen.ts b/ordinis-admin-ui/src/routeTree.gen.ts
new file mode 100644
index 0000000..7560a5a
--- /dev/null
+++ b/ordinis-admin-ui/src/routeTree.gen.ts
@@ -0,0 +1,11 @@
+// АВТОГЕНЕРИРУЕТСЯ TanStackRouterVite plugin'ом при `pnpm dev` или `pnpm build`.
+// Этот stub нужен только для первой компиляции TS — реальное содержимое
+// (импорты route'ов, типы) появится после первого запуска vite.
+//
+// После первого запуска можно либо .gitignore'ить routeTree.gen.ts, либо
+// держать его в репо обновлённым (рекомендация TanStack).
+//
+// eslint-disable
+// @ts-nocheck
+import { Route as RootRoute } from './routes/__root'
+export const routeTree = (RootRoute as unknown as { addChildren: (c: unknown[]) => unknown }).addChildren([])
diff --git a/ordinis-admin-ui/src/routes/__root.tsx b/ordinis-admin-ui/src/routes/__root.tsx
new file mode 100644
index 0000000..99907bc
--- /dev/null
+++ b/ordinis-admin-ui/src/routes/__root.tsx
@@ -0,0 +1,48 @@
+import { createRootRouteWithContext, Link, Outlet } from '@tanstack/react-router'
+import type { QueryClient } from '@tanstack/react-query'
+import { useTranslation } from 'react-i18next'
+
+export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
+ component: RootLayout,
+})
+
+function RootLayout() {
+ const { t, i18n } = useTranslation()
+ return (
+
+ )
+}
diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
new file mode 100644
index 0000000..76fc1fe
--- /dev/null
+++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
@@ -0,0 +1,67 @@
+import { createFileRoute, Link } from '@tanstack/react-router'
+import { useTranslation } from 'react-i18next'
+import { useRecords } from '@/api/queries'
+
+export const Route = createFileRoute('/dictionaries/$name')({
+ component: DictionaryDetail,
+})
+
+function DictionaryDetail() {
+ const { name } = Route.useParams()
+ const { t } = useTranslation()
+ const { data, isLoading, error } = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED')
+
+ return (
+
+
+
+ ← {t('nav.dictionaries')}
+
+
+
{name}
+
+ {data?.length ?? 0} {t('dict.list.records')}
+
+
+ {isLoading &&
{t('loading')}
}
+ {error &&
{t('error.failed')}
}
+
+ {data && data.length === 0 && (
+
{t('dict.empty')}
+ )}
+
+ {data && data.length > 0 && (
+
+
+
+
+ | {t('dict.col.businessKey')} |
+ name / code |
+ {t('dict.col.scope')} |
+ {t('dict.col.locale')} |
+ {t('dict.col.validFrom')} |
+
+
+
+ {data.map((r) => (
+
+ | {r.businessKey} |
+
+ {String((r.data as Record).name ?? (r.data as Record).code ?? '—')}
+ |
+
+ {r.dataScope}
+ |
+ {r._meta?.locale ?? '—'} |
+
+ {new Date(r.validFrom).toLocaleDateString()}
+ |
+
+ ))}
+
+
+
+ )}
+
+ )
+}
diff --git a/ordinis-admin-ui/src/routes/dictionaries.tsx b/ordinis-admin-ui/src/routes/dictionaries.tsx
new file mode 100644
index 0000000..75b1397
--- /dev/null
+++ b/ordinis-admin-ui/src/routes/dictionaries.tsx
@@ -0,0 +1,43 @@
+import { createFileRoute, Link } from '@tanstack/react-router'
+import { useTranslation } from 'react-i18next'
+import { useDictionaries } from '@/api/queries'
+
+export const Route = createFileRoute('/dictionaries')({
+ component: DictionariesPage,
+})
+
+function DictionariesPage() {
+ const { t } = useTranslation()
+ const { data, isLoading, error } = useDictionaries()
+
+ if (isLoading) return {t('loading')}
+ if (error) return {t('error.failed')}: {String(error)}
+
+ return (
+
+ {(data ?? []).map((d) => (
+
+
+
{d.displayName ?? d.name}
+
+ {d.scope}
+
+
+
{d.description}
+
+ v{d.schemaVersion}
+ ·
+ {d.bundle}
+ ·
+ {d.supportedLocales.join(', ')}
+
+
+ ))}
+
+ )
+}
diff --git a/ordinis-admin-ui/src/routes/index.tsx b/ordinis-admin-ui/src/routes/index.tsx
new file mode 100644
index 0000000..d08db86
--- /dev/null
+++ b/ordinis-admin-ui/src/routes/index.tsx
@@ -0,0 +1,7 @@
+import { createFileRoute, redirect } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/')({
+ beforeLoad: () => {
+ throw redirect({ to: '/dictionaries' })
+ },
+})
diff --git a/ordinis-admin-ui/src/styles.css b/ordinis-admin-ui/src/styles.css
new file mode 100644
index 0000000..7ad34ec
--- /dev/null
+++ b/ordinis-admin-ui/src/styles.css
@@ -0,0 +1,19 @@
+@import 'tailwindcss';
+
+@theme {
+ --color-brand-50: oklch(0.97 0.02 250);
+ --color-brand-500: oklch(0.55 0.16 250);
+ --color-brand-700: oklch(0.42 0.18 250);
+ --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
+}
+
+html,
+body,
+#root {
+ height: 100%;
+}
+
+body {
+ font-family: var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+}
diff --git a/ordinis-admin-ui/tsconfig.json b/ordinis-admin-ui/tsconfig.json
new file mode 100644
index 0000000..e75117c
--- /dev/null
+++ b/ordinis-admin-ui/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "paths": { "@/*": ["src/*"] }
+ },
+ "include": ["src"]
+}
diff --git a/ordinis-admin-ui/vite.config.ts b/ordinis-admin-ui/vite.config.ts
new file mode 100644
index 0000000..a0cd96f
--- /dev/null
+++ b/ordinis-admin-ui/vite.config.ts
@@ -0,0 +1,28 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+import { TanStackRouterVite } from '@tanstack/router-vite-plugin'
+import path from 'path'
+
+export default defineConfig({
+ plugins: [
+ TanStackRouterVite({ routesDirectory: 'src/routes', generatedRouteTree: 'src/routeTree.gen.ts' }),
+ react(),
+ tailwindcss(),
+ ],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src'),
+ },
+ },
+ server: {
+ host: '0.0.0.0',
+ port: 5173,
+ proxy: {
+ '/api': {
+ target: process.env.VITE_API_PROXY ?? 'http://localhost:8080',
+ changeOrigin: true,
+ },
+ },
+ },
+})
diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/dto/DictionaryListItem.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/dto/DictionaryListItem.java
new file mode 100644
index 0000000..b65eded
--- /dev/null
+++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/dto/DictionaryListItem.java
@@ -0,0 +1,25 @@
+package cloud.nstart.terravault.ordinis.readapi.dto;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Облегчённое представление справочника для list-эндпоинта (без schema_json,
+ * чтобы не таскать тяжёлые JSON Schema на главную). Полная схема — отдельным
+ * GET /api/v1/dictionaries/{name}.
+ */
+public record DictionaryListItem(
+ UUID id,
+ String name,
+ String displayName,
+ String description,
+ DataScope scope,
+ String schemaVersion,
+ String bundle,
+ List supportedLocales,
+ String defaultLocale,
+ OffsetDateTime createdAt,
+ OffsetDateTime updatedAt) {}
diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryListController.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryListController.java
new file mode 100644
index 0000000..b7d2900
--- /dev/null
+++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryListController.java
@@ -0,0 +1,59 @@
+package cloud.nstart.terravault.ordinis.readapi.web;
+
+import cloud.nstart.terravault.ordinis.auth.ScopeContext;
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
+import cloud.nstart.terravault.ordinis.readapi.dto.DictionaryListItem;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * GET /api/v1/dictionaries — список доступных справочников. Фильтрация по
+ * scope такая же, как для records: возвращаются только справочники со scope ⊆
+ * текущим разрешённым scope клиенту.
+ */
+@RestController
+@RequestMapping("/api/v1/dictionaries")
+public class DictionaryListController {
+
+ private final DictionaryDefinitionRepository repository;
+ private final ScopeContext scopeContext;
+
+ public DictionaryListController(
+ DictionaryDefinitionRepository repository, ScopeContext scopeContext) {
+ this.repository = repository;
+ this.scopeContext = scopeContext;
+ }
+
+ @GetMapping
+ public List list(
+ @RequestParam(value = "as_scope", required = false) String asScope) {
+
+ Set scopes = scopeContext.resolveForRead(asScope);
+ return repository.findAll().stream()
+ .filter(d -> scopes.contains(d.getScope()))
+ .map(DictionaryListController::toItem)
+ .toList();
+ }
+
+ private static DictionaryListItem toItem(DictionaryDefinition d) {
+ return new DictionaryListItem(
+ d.getId(),
+ d.getName(),
+ d.getDisplayName(),
+ d.getDescription(),
+ d.getScope(),
+ d.getSchemaVersion(),
+ d.getBundle(),
+ d.getSupportedLocales() == null ? List.of() : List.of(d.getSupportedLocales()),
+ d.getDefaultLocale(),
+ d.getCreatedAt(),
+ d.getUpdatedAt());
+ }
+}