From ad7c51d93fe73c8af4c91464637992ef758d9b48 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 11 May 2026 19:09:39 +0300 Subject: [PATCH 1/2] feat(catalog): toolbar layout per redesign prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match handoff screenshot: - Title "Справочники" + inline "N шт." count caption (no description text) - Remove page-level SearchInput (TopBar already has global search ⌘K) - Reorder toolbar to single row: [scope pills PUBLIC|INTERNAL|RESTRICTED] | divider | [bundle pills все|cuod|geo|i18n|core] | spacer | N/M counter [Граф ⇄] (→ /graph) [+ Создать] (moved here from PageHeader actions) - Scope/bundle pills are bordered chips с ring-1 для active state - Drop deps-only toggle (not in redesign) - Drop scopeCounts useMemo (counts moved out of chip labels per design) --- .../src/routes/dictionaries.index.tsx | 115 ++++++++---------- 1 file changed, 48 insertions(+), 67 deletions(-) diff --git a/ordinis-admin-ui/src/routes/dictionaries.index.tsx b/ordinis-admin-ui/src/routes/dictionaries.index.tsx index 25916dd..04f3521 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.index.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.index.tsx @@ -16,7 +16,7 @@ import { useDeferredValue, useMemo, useState } from 'react' import { createFileRoute, useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' -import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader, SearchInput } from '@/ui' +import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader } from '@/ui' import { PlusIcon } from '@phosphor-icons/react' import { useQueries } from '@tanstack/react-query' import { dictionaryDependentsQuery, useDictionaries, useDictionaryDependents } from '@/api/queries' @@ -167,11 +167,6 @@ function DictionariesPage() { return out }, [data]) - 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 filtersActive = Boolean(q) || scopeFilter.size > 0 || bundleFilter || withDepsOnly const resetFilters = () => navigate({ search: {} }) @@ -223,40 +218,27 @@ function DictionariesPage() { return (
- - - {/* Compact toolbar: всё в одной строке (wraps на narrow viewport). - Tradeoff: scope/bundle chips меньше padding'а + краткие labels. - UPPERCASE labels оставлены для consistency с brand voice (Tektur-like). */} -
-
- setSearch({ q: e.target.value })} - placeholder={t('dict.list.search.placeholder')} - aria-label={t('dict.list.search.placeholder')} - /> -
- - - {filtered.length}/{data.length} + {/* PageHeader per redesign: title + inline "N шт." count, без description. + Actions удалены — + Создать переехала в toolbar справа от Граф ⇄. */} +
+

{t('nav.dictionaries')}

+ + {data.length} {t('dict.list.countSuffix', { defaultValue: 'шт.' })} +
- {/* Scope chips: компактные, dot + count, full label по hover'у через title */} + {/* Toolbar per redesign prototype: [scope pills] | [bundle pills] [...] N/M [Граф ⇄] [+ Создать] */} +
+ {/* Scope filter: PUBLIC / INTERNAL / RESTRICTED — pill chips с border */}
{SCOPE_ORDER.map((scope) => { const selected = scopeFilter.has(scope) - const count = scopeCounts[scope] return ( ) })}
- {/* Vertical divider */} -
{/* Empty state */} From 16da694d8ca9acb1a3014f0d2947ea2421c57401 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 11 May 2026 19:13:30 +0300 Subject: [PATCH 2/2] =?UTF-8?q?feat(record):=20conflict=20diff=20modal=20?= =?UTF-8?q?=D0=BD=D0=B0=20409=20save=20(handoff=20line=20444)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Когда PUT /records/{key} возвращает 409 (optimistic-lock fail — другой пользователь сохранил тот же record между нашим load и submit'ом), открываем ConflictDiffModal. Component: - Header: 'Конфликт сохранения · {DICT}' + business key - Diff body: refetch server state via useRecordRaw, иди по union ключей local + server, оставь только отличающиеся. Per-row: field name (mono) + ваше значение (accent-bg) + серверное (surface-2) - Footer 3 actions: - Отмена: close, оставить editor open - Загрузить серверное: discard local, refetch records, close editor - Перезаписать: re-submit current payload (если опять 409 → новый diff) - Backend-friendly: не требует server diff endpoint, client считает diff client-side через JSON.stringify equality Route wiring: - onError handler distinguishes generic 409 (open diff modal) от специальных кодов: x_references_blocked_by_dependents, x_references_cascade_required, draft_required — те идут в свои dialogs - Conflict state ({payload, businessKey}) capture'ит local data чтобы показать в diff даже после reset формы --- .../components/record/ConflictDiffModal.tsx | 225 ++++++++++++++++++ .../src/routes/dictionaries.$name.tsx | 85 +++++++ 2 files changed, 310 insertions(+) create mode 100644 ordinis-admin-ui/src/components/record/ConflictDiffModal.tsx diff --git a/ordinis-admin-ui/src/components/record/ConflictDiffModal.tsx b/ordinis-admin-ui/src/components/record/ConflictDiffModal.tsx new file mode 100644 index 0000000..455162f --- /dev/null +++ b/ordinis-admin-ui/src/components/record/ConflictDiffModal.tsx @@ -0,0 +1,225 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { ArrowsClockwiseIcon, WarningIcon } from '@phosphor-icons/react' +import { Dialog, DialogContent } from '@/ui' +import { useRecordRaw } from '@/api/queries' +import type { CreateRecordRequest } from '@/api/client' +import { cn } from '@/lib/utils' + +/** + * ConflictDiffModal — handoff line 444 ("Save → optimistic update + toast; + * conflict → modal with diff"). + * + *

Опoшается когда PUT /records/{key} вернул 409 (optimistic-lock fail — + * другой пользователь сохранил тот же record между нашим load и submit'ом). + * + *

Сравнивает локальный payload (что юзер пытался сохранить) с серверным + * текущим состоянием (refetch через useRecordRaw). Per-field diff list: + * показываем только те properties у которых local≠server. Footer actions: + *

    + *
  • Отменить — close modal, оставить editor open (user сам решит)
  • + *
  • Загрузить серверное — discard local edits, reload server state
  • + *
  • Перезаписать сервером — force-apply local payload (retry without + * optimistic lock; backend всё ещё применит business rules). UX warning: + * это перетрёт чужие правки.
  • + *
+ */ + +export type ConflictDiffModalProps = { + open: boolean + onClose: () => void + /** Local payload пользователя — что он только что попытался сохранить. */ + localPayload: CreateRecordRequest | null + /** Dict name + business key для refetch server state. */ + dictionaryName: string + businessKey: string | undefined + /** "Загрузить серверное" — discard local, refetch & open editor с server data. */ + onReloadServer: () => void + /** "Перезаписать" — force update (re-submit with current data, expecting backend + * либо примет либо вернёт 409 ещё раз). */ + onOverwrite: () => void +} + +export function ConflictDiffModal({ + open, + onClose, + localPayload, + dictionaryName, + businessKey, + onReloadServer, + onOverwrite, +}: ConflictDiffModalProps) { + const { t } = useTranslation() + const serverQuery = useRecordRaw(dictionaryName, open ? businessKey : undefined) + + // Diff: иди по union ключей local + server, оставь только отличающиеся. + const diffRows = useMemo(() => { + if (!localPayload || !serverQuery.data) return [] + const localData = (localPayload.data ?? {}) as Record + const serverData = (serverQuery.data.data ?? {}) as Record + const keys = new Set([ + ...Object.keys(localData), + ...Object.keys(serverData), + ]) + const rows: { field: string; local: unknown; server: unknown }[] = [] + for (const k of keys) { + const lv = localData[k] + const sv = serverData[k] + if (!valuesEqual(lv, sv)) { + rows.push({ field: k, local: lv, server: sv }) + } + } + return rows.sort((a, b) => a.field.localeCompare(b.field)) + }, [localPayload, serverQuery.data]) + + return ( + !v && onClose()}> + + {/* Header */} +
+
+ +
+
+
+ {t('conflict.label', { defaultValue: 'Конфликт сохранения' })} + · + {dictionaryName.toUpperCase()} +
+

+ {t('conflict.title', { + defaultValue: 'Запись изменена другим пользователем', + })} +

+ {businessKey && ( +
{businessKey}
+ )} +
+
+ + {/* Diff body */} +
+ {serverQuery.isLoading && ( +
+ {t('conflict.loading', { + defaultValue: 'Загружаем актуальное состояние сервера…', + })} +
+ )} + {serverQuery.error && ( +
+ {t('conflict.loadError', { + defaultValue: 'Не удалось загрузить серверное состояние', + })} +
+ )} + {!serverQuery.isLoading && !serverQuery.error && diffRows.length === 0 && ( +
+ {t('conflict.noFieldDiff', { + defaultValue: + 'Поля совпадают. Возможно конфликт на уровне метаданных (validFrom / dataScope).', + })} +
+ )} + {diffRows.length > 0 && ( + <> +
+
+ {t('conflict.col.field', { defaultValue: 'Поле' })} +
+
+ {t('conflict.col.local', { defaultValue: 'Ваши изменения' })} +
+
+ {t('conflict.col.server', { defaultValue: 'На сервере' })} +
+
+
    + {diffRows.map((row) => ( +
  • +
    + {row.field} +
    +
    + {formatValue(row.local)} +
    +
    + {formatValue(row.server)} +
    +
  • + ))} +
+ + )} +
+ + {/* Footer */} +
+
+ {t('conflict.disclaimer', { + defaultValue: + 'Перезапись потеряет чужие изменения. Загрузить — потеряет ваши.', + })} +
+
+ + + +
+
+
+
+ ) +} + +/** Deep-equality для JSON-safe values (string/number/bool/null/array/object). */ +function valuesEqual(a: unknown, b: unknown): boolean { + if (a === b) return true + if (a === null || b === null) return a === b + if (typeof a !== typeof b) return false + if (typeof a === 'object') { + try { + return JSON.stringify(a) === JSON.stringify(b) + } catch { + return false + } + } + return false +} + +/** Render value as a short readable string. Objects → JSON, undefined → «—». */ +function formatValue(v: unknown): string { + if (v === undefined || v === null || v === '') return '—' + if (typeof v === 'string') return v + if (typeof v === 'number' || typeof v === 'boolean') return String(v) + try { + const s = JSON.stringify(v) + return s.length > 120 ? s.slice(0, 117) + '…' : s + } catch { + return String(v) + } +} diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index e1f4276..39da0c8 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -39,6 +39,7 @@ import { DictionaryHubView } from '@/components/lineage/DictionaryHubView' import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog' import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer' import { RecordDrawer } from '@/components/record/RecordDrawer' +import { ConflictDiffModal } from '@/components/record/ConflictDiffModal' import { WorkflowBanner } from '@/components/editor/WorkflowBanner' import { EditorInfoPanel } from '@/components/editor/EditorInfoPanel' import { ExportModal } from '@/components/editor/ExportModal' @@ -189,6 +190,12 @@ function DictionaryDetail() { const [aoiOpen, setAoiOpen] = useState(false) const [timeTravelOpen, setTimeTravelOpen] = useState(false) const [exportOpen, setExportOpen] = useState(false) + /** 409 save conflict: capture local payload + businessKey для diff modal. + * Открывается из createMut/updateMut onError когда status===409. */ + const [conflict, setConflict] = useState<{ + payload: CreateRecordRequest + businessKey: string + } | null>(null) /** Phase 3 dict-relationships-v2: cascade dialog state. Открывается из 409 * на close, или явным action из record menu. */ const [cascadeKey, setCascadeKey] = useState(undefined) @@ -474,6 +481,26 @@ function DictionaryDetail() { ...req, validFrom: req.validFrom && req.validFrom.length > 0 ? req.validFrom : nowIsoLocal(), } + // 409 conflict handler — handoff line 444. Detect optimistic-lock + // mismatch and open ConflictDiffModal (diff editor's payload vs server + // current state). Other 409 codes (cascade, draft_required) обрабатываются + // отдельно — здесь только generic conflict ("entity changed concurrently"). + const handleSaveError = (err: unknown, bk: string) => { + const status = (err as { response?: { status?: number } })?.response?.status + const code = (err as { response?: { data?: { code?: string } } }) + ?.response?.data?.code + if (status !== 409) return + // Specific 409 codes use their own dialogs — skip those. + if ( + code === 'x_references_blocked_by_dependents' || + code === 'x_references_cascade_required' || + code === 'draft_required' + ) { + return + } + // Generic 409 = optimistic-lock conflict. Open diff modal. + setConflict({ payload: submitReq, businessKey: bk }) + } if (edit.kind === 'create') { createMut.mutate( { payload: submitReq, idempotencyKey: recordIdempotencyKey }, @@ -482,6 +509,11 @@ function DictionaryDetail() { setEdit({ kind: 'closed' }) resetRecordIdempotencyKey() }, + onError: (err) => { + // Create rarely conflicts (no prior record to lock), но если backend + // вернёт 409 по уникальному ключу — businessKey возьмём из payload. + handleSaveError(err, submitReq.businessKey ?? '') + }, }, ) return @@ -498,6 +530,7 @@ function DictionaryDetail() { setEdit({ kind: 'closed' }) resetRecordIdempotencyKey() }, + onError: (err) => handleSaveError(err, edit.record.businessKey), }, ) } @@ -1123,6 +1156,58 @@ function DictionaryDetail() { initial={aoi?.geojson ?? null} /> + {/* Conflict diff modal — opens on 409 generic conflict (optimistic lock). + * Diff: editor payload vs server current state (refetched). Three actions: + * - Отмена: close modal, keep editor (user retry/edit manually) + * - Загрузить серверное: discard local, open editor с server snapshot + * - Перезаписать: re-submit with current payload (will overwrite if no + * new conflict — backend has no force-overwrite endpoint поэтому + * просто retry; если опять 409 — modal снова откроется). */} + {conflict && ( + setConflict(null)} + localPayload={conflict.payload} + dictionaryName={name} + businessKey={conflict.businessKey} + onReloadServer={() => { + // Discard local edits, refetch records → пользователь увидит + // актуальное состояние. Закрываем editor; user может открыть + // запись ещё раз для редактирования с правильной базы. + setConflict(null) + setEdit({ kind: 'closed' }) + resetRecordIdempotencyKey() + void recordsResult.refetch() + }} + onOverwrite={() => { + // Re-submit с current payload. Backend re-evaluates; если record + // изменился ещё раз → новый 409, modal откроется заново. + const payload = conflict.payload + const bk = conflict.businessKey + setConflict(null) + if (edit.kind === 'edit') { + updateMut.mutate( + { businessKey: bk, payload, idempotencyKey: recordIdempotencyKey }, + { + onSuccess: () => { + setEdit({ kind: 'closed' }) + resetRecordIdempotencyKey() + }, + onError: (err) => { + const status = (err as { response?: { status?: number } }) + ?.response?.status + if (status === 409) { + // Re-open diff modal с свежим server state. + setConflict({ payload, businessKey: bk }) + } + }, + }, + ) + } + }} + /> + )} + {/* Export modal — only mount when open. Same lazy pattern as Time-travel. */} {exportOpen && detailQuery.data && (