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 (
+
+ )
+}
+
+/** 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 && (
{
- 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). */}
-
-
-
- {filtered.length}/{data.length}
+ {/* PageHeader per redesign: title + inline "N шт." count, без description.
+ Actions удалены — + Создать переехала в toolbar справа от Граф ⇄. */}
+
+ {/* N / M counter */}
+
+ {filtered.length} / {data.length}
+
-
+ {/* Граф ⇄ — link to relations graph view */}
+
+
+ {/* + Создать (moved here from PageHeader actions per redesign) */}
+ {createButton}
+