From 1eba2ee59be6377bed174f54babe6302ea48e114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Mon, 4 May 2026 00:55:04 +0300 Subject: [PATCH] feat(admin-ui): minimal React+Vite+TanStack scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vite 7 + React 19 + TanStack Router (file-based) + TanStack Query - Tailwind v4 (CSS-first config, OKLCH brand colors) - i18next + react-i18next с RU/EN switcher в header - Axios client с Accept-Language interceptor + JWT placeholder - 2 pages: /dictionaries (list cards) + /dictionaries/$name (records table) - Dockerfile (node build + nginx serving + API proxy) - README + .gitignore Backend: - Новый GET /api/v1/dictionaries в read-api (DictionaryListController), фильтрует по доступному scope через ScopeContext - DictionaryListItem DTO без heavy schema_json routeTree.gen.ts — stub; реальное содержимое генерится Vite plugin'ом при первом 'pnpm dev'. --- ordinis-admin-ui/.gitignore | 5 ++ ordinis-admin-ui/Dockerfile | 12 ++++ ordinis-admin-ui/README.md | 30 +++++++++ ordinis-admin-ui/index.html | 12 ++++ ordinis-admin-ui/nginx.conf | 36 ++++++++++ ordinis-admin-ui/package.json | 31 +++++++++ ordinis-admin-ui/src/api/client.ts | 53 +++++++++++++++ ordinis-admin-ui/src/api/queries.ts | 26 +++++++ ordinis-admin-ui/src/i18n.ts | 46 +++++++++++++ ordinis-admin-ui/src/main.tsx | 29 ++++++++ ordinis-admin-ui/src/routeTree.gen.ts | 11 +++ ordinis-admin-ui/src/routes/__root.tsx | 48 +++++++++++++ .../src/routes/dictionaries.$name.tsx | 67 +++++++++++++++++++ ordinis-admin-ui/src/routes/dictionaries.tsx | 43 ++++++++++++ ordinis-admin-ui/src/routes/index.tsx | 7 ++ ordinis-admin-ui/src/styles.css | 19 ++++++ ordinis-admin-ui/tsconfig.json | 22 ++++++ ordinis-admin-ui/vite.config.ts | 28 ++++++++ .../readapi/dto/DictionaryListItem.java | 25 +++++++ .../readapi/web/DictionaryListController.java | 59 ++++++++++++++++ 20 files changed, 609 insertions(+) create mode 100644 ordinis-admin-ui/.gitignore create mode 100644 ordinis-admin-ui/Dockerfile create mode 100644 ordinis-admin-ui/README.md create mode 100644 ordinis-admin-ui/index.html create mode 100644 ordinis-admin-ui/nginx.conf create mode 100644 ordinis-admin-ui/package.json create mode 100644 ordinis-admin-ui/src/api/client.ts create mode 100644 ordinis-admin-ui/src/api/queries.ts create mode 100644 ordinis-admin-ui/src/i18n.ts create mode 100644 ordinis-admin-ui/src/main.tsx create mode 100644 ordinis-admin-ui/src/routeTree.gen.ts create mode 100644 ordinis-admin-ui/src/routes/__root.tsx create mode 100644 ordinis-admin-ui/src/routes/dictionaries.$name.tsx create mode 100644 ordinis-admin-ui/src/routes/dictionaries.tsx create mode 100644 ordinis-admin-ui/src/routes/index.tsx create mode 100644 ordinis-admin-ui/src/styles.css create mode 100644 ordinis-admin-ui/tsconfig.json create mode 100644 ordinis-admin-ui/vite.config.ts create mode 100644 ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/dto/DictionaryListItem.java create mode 100644 ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryListController.java 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 ( +
+
+
+ + {t('app.title')} + + +
+ + +
+
+
+
+ +
+
+ ) +} 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 && ( +
+ + + + + + + + + + + + {data.map((r) => ( + + + + + + + + ))} + +
{t('dict.col.businessKey')}name / code{t('dict.col.scope')}{t('dict.col.locale')}{t('dict.col.validFrom')}
{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()); + } +}