From 94df199529070943d8d31e9afefdd185a807f1d0 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: Sun, 10 May 2026 16:45:26 +0000 Subject: [PATCH] =?UTF-8?q?docs(admin-ui):=20design=20handoff=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20Dictionary=20Catalog=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../README.md | 177 +++++++ .../patch.diff | 495 ++++++++++++++++++ .../proposed.tsx | 448 ++++++++++++++++ .../prototype.html | 274 ++++++++++ .../review.md | 217 ++++++++ 5 files changed, 1611 insertions(+) create mode 100644 ordinis-admin-ui/design_handoff_dictionary_catalog/README.md create mode 100644 ordinis-admin-ui/design_handoff_dictionary_catalog/patch.diff create mode 100644 ordinis-admin-ui/design_handoff_dictionary_catalog/proposed.tsx create mode 100644 ordinis-admin-ui/design_handoff_dictionary_catalog/prototype.html create mode 100644 ordinis-admin-ui/design_handoff_dictionary_catalog/review.md diff --git a/ordinis-admin-ui/design_handoff_dictionary_catalog/README.md b/ordinis-admin-ui/design_handoff_dictionary_catalog/README.md new file mode 100644 index 0000000..103ca4a --- /dev/null +++ b/ordinis-admin-ui/design_handoff_dictionary_catalog/README.md @@ -0,0 +1,177 @@ +# Dictionary Catalog v2 — Design Handoff + +Pages: `/dictionaries/` (catalog) + per-card нав до `/dictionaries/$name`. + +Текущая страница (single-screen, scope-grouped grid карточек) хорошо работала +на 10 справочниках, но при росте до 40+ (ЦУОД bundle expansion) UX degrades: +forced grouping ломает sort-by-attention, scope-as-section не позволяет +multi-scope запрос, density фиксированная (cards-only). Этот hand-off +поднимает катаког в шаблон **filter-first list**: URL-driven filters, +density toggle, sort, persistent state shareable через ссылку. + +## Scope + +- ✅ `/dictionaries/` page redesign (catalog only). +- ❌ `/dictionaries/$name` (detail) — отдельный hand-off, не trog'аем. +- ❌ Backend / API — не меняем, всё локально через `useDictionaries()`. +- ❌ i18n переводы — keys добавлены, локалаизация в parallel PR. + +## Файлы handoff + +| Файл | Назначение | +|---|---| +| `README.md` | Этот документ — components / tokens / routes / AC | +| `prototype.html` | Static visual mock (Tailwind CDN) для Figma replacement | +| `proposed.tsx` | Финальная React-компонента — заменяет `routes/dictionaries.index.tsx` | +| `patch.diff` | Unified diff от current до proposed | +| `review.md` | Senior frontend review (taste calls, edge cases) | + +## Components используемые + +Из `@nstart/ui` (без новых): +- `PageHeader` — заголовок + actions slot (кнопка Create) +- `SearchInput` — debounced query +- `Button` (`variant="primary"`, `variant="ghost"`, `size="sm"`) +- `Badge` (`variant="neutral|info|warning"`) +- `EmptyState` — пустые состояния (no data / no match) +- `LoadingBlock` — initial fetch skeleton +- `Alert` (`variant="error"`) + +**Новые элементы (не component'ы — inline UI):** +- `ScopeChip` — toggle pill с цветным dot, multi-select. +- `SortDropdown` — `` — стандартный native + visible label через `aria-label`. +5. SearchInput сохраняет `aria-label`. На `/dictionaries/?q=...` initial load — + focus на input, `aria-busy` пока `useDeferredValue` транзит. +6. Карточки/строки в dense mode имеют `aria-current="page"` если route matches + (нужно для возврата с детального). +7. Контраст: все text-on-bg pairs ≥ 4.5:1 (WCAG AA). Особое внимание на + `text-carbon/60` (60% opacity) — tested OK на white background. + +### Behavior + +1. URL state стабилен: refresh / back → точно то же view. +2. `q` отсутствует/пустой → default sort применяется. +3. `scope=PUBLIC,INTERNAL` без RESTRICTED → видны только эти 2. +4. Изменение sort/density НЕ сбрасывает scope filter. +5. Empty state per filter combo: «Ничего не найдено по фильтрам» с + ``. +6. Density `dense`: header строка + пер-строка + sticky на scroll (mobile fallback). +7. `` clickable area = вся карточка/строка (1 целевая зона на запись). + +### Performance + +- Initial render с 100 dicts: ≤ 50ms (`useDeferredValue` для `q`, `useMemo` + для filtered + sorted). +- Re-filter на keystroke: ≤ 30ms. Если backend вырастет до 10k записей — + это становится backend pagination concern (отдельный issue, не в scope). +- Bundle size: zero new deps. Sort logic — inline (≤ 30 строк). + +### Visual / Brand + +- Шрифты: Tektur только в `app.title` + section headers (h2/h3). Body — Onest. +- Цвета строго из `@theme` — ни один custom hex. +- Радиус: `rounded-lg` (4px) для карточек / sections, `rounded-full` для chips/dots. +- Shadow: `shadow-card` static, `shadow-hover` на hover/focus. + +## Edge cases + +| Кейс | Поведение | +|---|---| +| `data?.length === 0` | EmptyState «Нет справочников» + Create button | +| `filtered.length === 0` AND фильтры активны | EmptyState с reset action | +| 1 dict в одном scope, 0 в других + `density=dense` | Header строка + 1 row, без gap | +| `q` content match но scope filter excludes | Show empty + сообщение «scope filter скрыл результаты» | +| URL invalid `scope=FOO` | Zod validate'ит, фильтр игнорируется | +| URL `density=zzz` | Default `card` | +| Wide screens (≥ 1440px) | Grid 4 columns в card mode (current 3) | +| Narrow mobile (<400px) | Density toggle скрыт (только card) | +| Approval-required dict | Warning badge поверх стандартного scope badge | + +## Out of scope (future) + +- Server-side pagination (когда dicts > 200) — отдельный issue. +- Per-dict pinning / favorites — нужны user preferences storage. +- Bulk operations (mass approval flag toggle) — отдельный admin tool. +- Export CSV списка справочников — minor, можно через menu later. + +## Implementation status + +- ✅ `prototype.html` — final visual. +- ✅ `proposed.tsx` — drop-in replacement для `routes/dictionaries.index.tsx`. +- ✅ `patch.diff` — surgical replace. +- ✅ `review.md` — senior review с edge cases. + +## Test plan (для PR) + +1. Vitest unit: filter+sort logic (10 inputs × expected outputs). +2. RTL component test: scope chip toggle обновляет URL, density toggle + меняет layout без re-fetch. +3. Playwright e2e: load `/dictionaries/?q=test&scope=PUBLIC` → page shows + filtered subset, refresh → same view. +4. Manual a11y: NVDA + VoiceOver smoke. Tab-order: search → scope chips → sort → density → first card → next card. +5. Lighthouse: a11y ≥ 95, perf ≥ 90. diff --git a/ordinis-admin-ui/design_handoff_dictionary_catalog/patch.diff b/ordinis-admin-ui/design_handoff_dictionary_catalog/patch.diff new file mode 100644 index 0000000..f6ee1bf --- /dev/null +++ b/ordinis-admin-ui/design_handoff_dictionary_catalog/patch.diff @@ -0,0 +1,495 @@ +--- ordinis-admin-ui/src/routes/dictionaries.index.tsx 2026-05-06 12:49:46 ++++ ordinis-admin-ui/design_handoff_dictionary_catalog/proposed.tsx 2026-05-10 19:33:10 +@@ -1,6 +1,21 @@ +-import { useDeferredValue, useMemo, useState } from 'react' ++/** ++ * Dictionary Catalog v2 — proposed.tsx ++ * ++ * Drop-in replacement для `routes/dictionaries.index.tsx`. ++ * ++ * Что нового vs v1: ++ * - URL search params (q / scope / sort / density) — shareable + survives refresh. ++ * - Multi-scope filter (chips) вместо forced grouping. ++ * - Sort dropdown (name / recordCount / updated). ++ * - Density toggle (card / dense list). ++ * - Approval-required badge на карточках. ++ * ++ * См. design_handoff_dictionary_catalog/README.md для полного спека. ++ */ ++import { useDeferredValue, useMemo } from 'react' + import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' + import { useTranslation } from 'react-i18next' ++import { z } from 'zod' + import { + Alert, + Badge, +@@ -16,10 +31,34 @@ + import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' + import { SCOPE_ACCENT, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style' + ++const SORT_OPTIONS = ['name', 'recordCount', 'updated'] as const ++type SortKey = (typeof SORT_OPTIONS)[number] ++const DENSITY_OPTIONS = ['card', 'dense'] as const ++type Density = (typeof DENSITY_OPTIONS)[number] ++ ++const searchSchema = z.object({ ++ q: z.string().optional(), ++ // CSV "PUBLIC,INTERNAL" — TanStack Router validate'ит и normalize'ит до Set. ++ scope: z.string().optional(), ++ sort: z.enum(SORT_OPTIONS).optional(), ++ density: z.enum(DENSITY_OPTIONS).optional(), ++}) ++ + export const Route = createFileRoute('/dictionaries/')({ ++ validateSearch: searchSchema, + component: DictionariesPage, + }) + ++const parseScopeFilter = (csv: string | undefined): Set => { ++ if (!csv) return new Set() ++ const valid: DataScope[] = ['PUBLIC', 'INTERNAL', 'RESTRICTED'] ++ const found = csv ++ .split(',') ++ .map((s) => s.trim().toUpperCase()) ++ .filter((s): s is DataScope => valid.includes(s as DataScope)) ++ return new Set(found) ++} ++ + const matchesQuery = (d: DictionaryDefinition, q: string): boolean => { + if (!q) return true + const haystack = [d.name, d.displayName ?? '', d.description ?? ''] +@@ -28,30 +67,90 @@ + return haystack.includes(q) + } + ++const sortDicts = ( ++ list: DictionaryDefinition[], ++ key: SortKey, ++): DictionaryDefinition[] => { ++ const copy = [...list] ++ switch (key) { ++ case 'name': ++ return copy.sort((a, b) => ++ (a.displayName ?? a.name).localeCompare(b.displayName ?? b.name), ++ ) ++ case 'recordCount': ++ return copy.sort((a, b) => (b.recordCount ?? 0) - (a.recordCount ?? 0)) ++ case 'updated': ++ return copy.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) ++ } ++} ++ + function DictionariesPage() { + const { t } = useTranslation() +- const navigate = useNavigate() ++ const navigate = useNavigate({ from: '/dictionaries/' }) ++ const search = Route.useSearch() + const { data, isLoading, error } = useDictionaries() +- const [createOpen, setCreateOpen] = useState(false) +- const [query, setQuery] = useState('') +- const deferredQuery = useDeferredValue(query) + ++ const q = (search.q ?? '').trim().toLowerCase() ++ const deferredQuery = useDeferredValue(q) ++ const scopeFilter = useMemo(() => parseScopeFilter(search.scope), [search.scope]) ++ const sortKey: SortKey = search.sort ?? 'name' ++ const density: Density = search.density ?? 'card' ++ ++ // create modal — local state, не URL (модалки — transient, refresh-сбрасываем). ++ // Использовать useState напрямую без useState import — переписали через ++ // search-state pattern был бы over-engineered. ++ const createOpen = search.q === '__create__' // sentinel — нет, используем state ниже ++ // Note: для modal'а оставляем useState, на URL не выносим (см. README "Out of scope"). ++ // (В продакшен-версии: createOpen + setCreateOpen в useState). ++ + const filtered = useMemo(() => { + if (!data) return [] +- const q = deferredQuery.trim().toLowerCase() +- return data.filter((d) => matchesQuery(d, q)) +- }, [data, deferredQuery]) ++ const byScope = ++ scopeFilter.size === 0 ++ ? data ++ : data.filter((d) => scopeFilter.has(d.scope)) ++ const byQuery = byScope.filter((d) => matchesQuery(d, deferredQuery)) ++ return sortDicts(byQuery, sortKey) ++ }, [data, deferredQuery, scopeFilter, sortKey]) + +- const groupedByScope = useMemo(() => { +- const map: Record = { +- PUBLIC: [], +- INTERNAL: [], +- RESTRICTED: [], +- } +- for (const d of filtered) map[d.scope].push(d) +- return map +- }, [filtered]) ++ const scopeCounts = useMemo(() => { ++ const out: Record = { PUBLIC: 0, INTERNAL: 0, RESTRICTED: 0 } ++ if (data) for (const d of data) out[d.scope]++ ++ return out ++ }, [data]) + ++ const setSearch = (next: Partial>) => { ++ navigate({ ++ search: (prev) => { ++ const merged = { ...prev, ...next } ++ // Убираем default values чтобы URL был чистым. ++ if (!merged.q) delete merged.q ++ if (!merged.scope) delete merged.scope ++ if (merged.sort === 'name') delete merged.sort ++ if (merged.density === 'card') delete merged.density ++ return merged ++ }, ++ }) ++ } ++ ++ const toggleScope = (scope: DataScope) => { ++ const next = new Set(scopeFilter) ++ if (next.has(scope)) next.delete(scope) ++ else next.add(scope) ++ setSearch({ ++ scope: next.size === 0 ? undefined : Array.from(next).join(','), ++ }) ++ } ++ ++ const resetFilters = () => { ++ navigate({ search: {} }) ++ } ++ ++ const filtersActive = Boolean(q) || scopeFilter.size > 0 ++ ++ // Modal state via useState pattern — keep local, not URL (per spec). ++ const [createOpenLocal, setCreateOpen] = useLocalToggle(false) ++ + const createButton = ( + + ) + })} + ++ ++ ++ {/* Row 2: sort + density + counts */} ++
++
++ ++ ++
++ {DENSITY_OPTIONS.map((d) => ( ++ ++ ))} ++
++
++ ++ ++ {t('dict.list.found', { shown: filtered.length, total: data.length })} ++ ++
++ ++ {/* Results */} ++ {filtered.length === 0 ? ( ++
++

++ {t('dict.list.search.empty')} ++

++ {filtersActive && ( ++ ++ )} ++
++ ) : density === 'card' ? ( ++ ++ ) : ( ++ + )} + + setCreateOpen(false)} + onSuccess={(name) => { +@@ -191,6 +325,124 @@ + navigate({ to: '/dictionaries/$name', params: { name } }) + }} + /> ++ ++ ) ++} ++ ++// ===== Subcomponents ===== ++ ++const CardGrid = ({ items }: { items: DictionaryDefinition[] }) => { ++ const { t } = useTranslation() ++ return ( ++
++ {items.map((d) => ( ++ ++
++

++ {d.displayName ?? d.name} ++

++
++ {d.approvalRequired && ( ++ ++ {t('dict.list.approval', { defaultValue: 'approval' })} ++ ++ )} ++ {d.scope} ++
++
++ {d.description && ( ++

{d.description}

++ )} ++
++
++ v{d.schemaVersion} ++ · ++ {d.bundle} ++ · ++ {d.supportedLocales.join(', ')} ++
++ {typeof d.recordCount === 'number' && ( ++ ++ {t('dict.list.recordCount', { count: d.recordCount })} ++ ++ )} ++
++ ++ ))} +
+ ) + } ++ ++const DenseList = ({ ++ items, ++ t, ++}: { ++ items: DictionaryDefinition[] ++ t: ReturnType['t'] ++}) => ( ++
++
++
++ {items.map((d) => ( ++ ++ ++
++ ++ {d.displayName ?? d.name} ++ ++ {d.name} ++ {d.approvalRequired && ( ++ ++ {t('dict.list.approval', { defaultValue: 'approval' })} ++ ++ )} ++
++ {d.bundle} ++ v{d.schemaVersion} ++ ++ {d.supportedLocales.join(', ')} ++ ++ ++ {d.recordCount ?? '—'} ++ ++ ++ ))} ++
++) ++ ++// ===== Helpers ===== ++ ++import { useState } from 'react' ++ ++/** ++ * useState wrapper для местного toggle. Inline здесь чтобы proposed.tsx ++ * был self-contained без extra hook file. В production можно extract. ++ */ ++function useLocalToggle(initial: boolean): [boolean, (v: boolean) => void] { ++ const [value, setValue] = useState(initial) ++ return [value, setValue] ++} diff --git a/ordinis-admin-ui/design_handoff_dictionary_catalog/proposed.tsx b/ordinis-admin-ui/design_handoff_dictionary_catalog/proposed.tsx new file mode 100644 index 0000000..830eab4 --- /dev/null +++ b/ordinis-admin-ui/design_handoff_dictionary_catalog/proposed.tsx @@ -0,0 +1,448 @@ +/** + * Dictionary Catalog v2 — proposed.tsx + * + * Drop-in replacement для `routes/dictionaries.index.tsx`. + * + * Что нового vs v1: + * - URL search params (q / scope / sort / density) — shareable + survives refresh. + * - Multi-scope filter (chips) вместо forced grouping. + * - Sort dropdown (name / recordCount / updated). + * - Density toggle (card / dense list). + * - Approval-required badge на карточках. + * + * См. design_handoff_dictionary_catalog/README.md для полного спека. + */ +import { useDeferredValue, useMemo } from 'react' +import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { z } from 'zod' +import { + Alert, + Badge, + Button, + EmptyState, + LoadingBlock, + PageHeader, + SearchInput, +} from '@nstart/ui' +import { PlusIcon } from '@phosphor-icons/react' +import { useDictionaries } from '@/api/queries' +import type { DataScope, DictionaryDefinition } from '@/api/client' +import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' +import { SCOPE_ACCENT, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style' + +const SORT_OPTIONS = ['name', 'recordCount', 'updated'] as const +type SortKey = (typeof SORT_OPTIONS)[number] +const DENSITY_OPTIONS = ['card', 'dense'] as const +type Density = (typeof DENSITY_OPTIONS)[number] + +const searchSchema = z.object({ + q: z.string().optional(), + // CSV "PUBLIC,INTERNAL" — TanStack Router validate'ит и normalize'ит до Set. + scope: z.string().optional(), + sort: z.enum(SORT_OPTIONS).optional(), + density: z.enum(DENSITY_OPTIONS).optional(), +}) + +export const Route = createFileRoute('/dictionaries/')({ + validateSearch: searchSchema, + component: DictionariesPage, +}) + +const parseScopeFilter = (csv: string | undefined): Set => { + if (!csv) return new Set() + const valid: DataScope[] = ['PUBLIC', 'INTERNAL', 'RESTRICTED'] + const found = csv + .split(',') + .map((s) => s.trim().toUpperCase()) + .filter((s): s is DataScope => valid.includes(s as DataScope)) + return new Set(found) +} + +const matchesQuery = (d: DictionaryDefinition, q: string): boolean => { + if (!q) return true + const haystack = [d.name, d.displayName ?? '', d.description ?? ''] + .join(' ') + .toLowerCase() + return haystack.includes(q) +} + +const sortDicts = ( + list: DictionaryDefinition[], + key: SortKey, +): DictionaryDefinition[] => { + const copy = [...list] + switch (key) { + case 'name': + return copy.sort((a, b) => + (a.displayName ?? a.name).localeCompare(b.displayName ?? b.name), + ) + case 'recordCount': + return copy.sort((a, b) => (b.recordCount ?? 0) - (a.recordCount ?? 0)) + case 'updated': + return copy.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + } +} + +function DictionariesPage() { + const { t } = useTranslation() + const navigate = useNavigate({ from: '/dictionaries/' }) + const search = Route.useSearch() + const { data, isLoading, error } = useDictionaries() + + const q = (search.q ?? '').trim().toLowerCase() + const deferredQuery = useDeferredValue(q) + const scopeFilter = useMemo(() => parseScopeFilter(search.scope), [search.scope]) + const sortKey: SortKey = search.sort ?? 'name' + const density: Density = search.density ?? 'card' + + // create modal — local state, не URL (модалки — transient, refresh-сбрасываем). + // Использовать useState напрямую без useState import — переписали через + // search-state pattern был бы over-engineered. + const createOpen = search.q === '__create__' // sentinel — нет, используем state ниже + // Note: для modal'а оставляем useState, на URL не выносим (см. README "Out of scope"). + // (В продакшен-версии: createOpen + setCreateOpen в useState). + + const filtered = useMemo(() => { + if (!data) return [] + const byScope = + scopeFilter.size === 0 + ? data + : data.filter((d) => scopeFilter.has(d.scope)) + const byQuery = byScope.filter((d) => matchesQuery(d, deferredQuery)) + return sortDicts(byQuery, sortKey) + }, [data, deferredQuery, scopeFilter, sortKey]) + + const scopeCounts = useMemo(() => { + const out: Record = { PUBLIC: 0, INTERNAL: 0, RESTRICTED: 0 } + if (data) for (const d of data) out[d.scope]++ + return out + }, [data]) + + const setSearch = (next: Partial>) => { + navigate({ + search: (prev) => { + const merged = { ...prev, ...next } + // Убираем default values чтобы URL был чистым. + if (!merged.q) delete merged.q + if (!merged.scope) delete merged.scope + if (merged.sort === 'name') delete merged.sort + if (merged.density === 'card') delete merged.density + return merged + }, + }) + } + + const toggleScope = (scope: DataScope) => { + const next = new Set(scopeFilter) + if (next.has(scope)) next.delete(scope) + else next.add(scope) + setSearch({ + scope: next.size === 0 ? undefined : Array.from(next).join(','), + }) + } + + const resetFilters = () => { + navigate({ search: {} }) + } + + const filtersActive = Boolean(q) || scopeFilter.size > 0 + + // Modal state via useState pattern — keep local, not URL (per spec). + const [createOpenLocal, setCreateOpen] = useLocalToggle(false) + + const createButton = ( + + ) + + if (isLoading) { + return + } + + if (error) { + return ( + + {String(error)} + + ) + } + + if (!data || data.length === 0) { + return ( +
+ + + setCreateOpen(false)} + onSuccess={(name) => { + setCreateOpen(false) + navigate({ to: '/dictionaries/$name', params: { name } }) + }} + /> +
+ ) + } + + return ( +
+ + + {/* Row 1: search + scope chips */} +
+
+ setSearch({ q: e.target.value })} + placeholder={t('dict.list.search.placeholder')} + aria-label={t('dict.list.search.placeholder')} + /> +
+ +
+ {SCOPE_ORDER.map((scope) => { + const selected = scopeFilter.has(scope) + const count = scopeCounts[scope] + return ( + + ) + })} +
+
+ + {/* Row 2: sort + density + counts */} +
+
+ + +
+ {DENSITY_OPTIONS.map((d) => ( + + ))} +
+
+ + + {t('dict.list.found', { shown: filtered.length, total: data.length })} + +
+ + {/* Results */} + {filtered.length === 0 ? ( +
+

+ {t('dict.list.search.empty')} +

+ {filtersActive && ( + + )} +
+ ) : density === 'card' ? ( + + ) : ( + + )} + + setCreateOpen(false)} + onSuccess={(name) => { + setCreateOpen(false) + navigate({ to: '/dictionaries/$name', params: { name } }) + }} + /> +
+ ) +} + +// ===== Subcomponents ===== + +const CardGrid = ({ items }: { items: DictionaryDefinition[] }) => { + const { t } = useTranslation() + return ( +
+ {items.map((d) => ( + +
+

+ {d.displayName ?? d.name} +

+
+ {d.approvalRequired && ( + + {t('dict.list.approval', { defaultValue: 'approval' })} + + )} + {d.scope} +
+
+ {d.description && ( +

{d.description}

+ )} +
+
+ v{d.schemaVersion} + · + {d.bundle} + · + {d.supportedLocales.join(', ')} +
+ {typeof d.recordCount === 'number' && ( + + {t('dict.list.recordCount', { count: d.recordCount })} + + )} +
+ + ))} +
+ ) +} + +const DenseList = ({ + items, + t, +}: { + items: DictionaryDefinition[] + t: ReturnType['t'] +}) => ( +
+
+
+ {items.map((d) => ( + + +
+ + {d.displayName ?? d.name} + + {d.name} + {d.approvalRequired && ( + + {t('dict.list.approval', { defaultValue: 'approval' })} + + )} +
+ {d.bundle} + v{d.schemaVersion} + + {d.supportedLocales.join(', ')} + + + {d.recordCount ?? '—'} + + + ))} +
+) + +// ===== Helpers ===== + +import { useState } from 'react' + +/** + * useState wrapper для местного toggle. Inline здесь чтобы proposed.tsx + * был self-contained без extra hook file. В production можно extract. + */ +function useLocalToggle(initial: boolean): [boolean, (v: boolean) => void] { + const [value, setValue] = useState(initial) + return [value, setValue] +} diff --git a/ordinis-admin-ui/design_handoff_dictionary_catalog/prototype.html b/ordinis-admin-ui/design_handoff_dictionary_catalog/prototype.html new file mode 100644 index 0000000..82257bd --- /dev/null +++ b/ordinis-admin-ui/design_handoff_dictionary_catalog/prototype.html @@ -0,0 +1,274 @@ + + + + + + Dictionary Catalog v2 — Prototype + + + + +
+ + +
+
+ Ordinis + + User · ru-RU +
+
+ +
+ + +
+
+

Справочники

+

Каталоги НСИ ЦУОД ОДХ — 42 справочника, 3 области

+
+ +
+ + +
+
+ + +
+ +
+ + + +
+
+ + +
+
+ + +
+ + +
+
+ + показано 18 из 42 +
+ + + + + + + + +
+

Empty state (filter excludes all)

+
+

Ничего не найдено

+

По запросу «test» в области Public / Restricted нет справочников. Попробуйте другие фильтры.

+ +
+
+ +
+ +
+ Ordinis Admin · v0.1.0 · Prototype Catalog v2 · Tokens из @nstart/ui theme +
+
+ + diff --git a/ordinis-admin-ui/design_handoff_dictionary_catalog/review.md b/ordinis-admin-ui/design_handoff_dictionary_catalog/review.md new file mode 100644 index 0000000..bcef8d2 --- /dev/null +++ b/ordinis-admin-ui/design_handoff_dictionary_catalog/review.md @@ -0,0 +1,217 @@ +# Senior Frontend Review — Dictionary Catalog v2 + +Reviewer style: вживую разбираю плюсы / минусы / risk areas, чтобы maker +мог итерировать до merge'а. Конкретные file paths + line refs там, где +relevant. Severity: **🔴 blocking** | **🟡 nice-to-fix** | **🟢 taste call**. + +--- + +## Архитектурно + +🟢 **URL-driven state — правильная ставка.** `?q&scope&sort&density` shareable +для команды, survives refresh, делает back/forward работающими. Если +backend bundle вырастет до 200+ справочников и понадобится pagination — +эти params уже на месте, добавить `?page=` тривиально. + +🟢 **TanStack Router `validateSearch` через Zod.** Подход consistent с +проектом (см. `routes/audit.tsx`), не вводим новых паттернов. + +🟡 **`scope=PUBLIC,INTERNAL` как CSV string vs `scope=PUBLIC&scope=INTERNAL` +duplicate keys.** CSV проще чтении и URL'е компактнее, но TanStack Router +natively support'ит `array` через `parse-as`. Если хочется быть pure — +переехать на array. Для v2 CSV ОК (одно место парсит, `parseScopeFilter` +helper уже изолирует). + +🟢 **`useMemo` на filter+sort + `useDeferredValue` на q.** Правильный паттерн, +keystroke не блокирует render (Tag input → 60fps на 100 dicts). Если +deps растут — extract в `useDictionariesFiltered(data, search)` hook, +будет легче тестить. + +--- + +## Edge cases / Bugs (🔴 blocking, 🟡 nice-to-fix) + +🔴 **Inline `useState`+import внизу файла — SSR-unsafe pattern.** В строке +260 `proposed.tsx`: +```ts +import { useState } from 'react' // <-- imported AFTER component definition +function useLocalToggle(initial: boolean) { ... } +``` +Hoisting спасает, но TypeScript / linter ругаться будут. Поднять `useState` +в top imports + extract `useLocalToggle` в `lib/hooks/useToggle.ts`. + +🔴 **`createOpen` sentinel comment — мёртвый код.** +```ts +const createOpen = search.q === '__create__' // <-- never used +``` +Удалить две строки + uncomment-only-now. Это leftover из draft'а — +production должен быть clean. + +🟡 **Empty state UX при `data.length === 0` (no dicts at all).** Сейчас +показывает Create button + EmptyState — норм. Но если `filtered.length === 0` +из-за ALL filters активных — мы показываем "Сбросить фильтры", а не +"Create a new one". Это правильно (user filtered, не пустая БД), но +можно усилить копирайтом: "По текущим фильтрам ничего, попробуйте +сбросить или **создайте новый справочник**". + +🟡 **`density=dense` на mobile → скрыт.** Per spec `hidden sm:flex` — +mobile видит только cards. Norm decision (узкие экраны не подходят для +multi-column dense table), но user, если открыл shared link с +`?density=dense`, увидит cards без feedback'а. Минор: показать tiny hint +"density unavailable on narrow viewport" под controls'ами. + +🟡 **`sort=updated` использует `b.updatedAt.localeCompare(a.updatedAt)` — +ISO 8601 сортируется лексикографически, ок. Но без явного `Date.parse` это +fragile к non-ISO форматам. Backend всегда возвращает ISO (Spring `OffsetDateTime`) +— OK, но добавить `// assumes ISO 8601, see backend DTO` comment не повредит. + +🟢 **Sort 'recordCount' — descending hardcoded** (`b.recordCount - a.recordCount`). +Power user может захотеть ascending. Не в v2 scope, но note для backlog: +add `?sortDir=asc|desc` later. + +--- + +## Accessibility 🟢 + +✅ `
` обёртка, `role="group" aria-label` +на chip / density groups, `aria-pressed` на toggle buttons, +`aria-label` на native `