From b312ba37adb5260625fa792a9078edde5d1d67b8 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Tue, 12 May 2026 00:01:04 +0300 Subject: [PATCH] feat(ui): 3-dot row menu + history compare/revert + DateTime fitting + tablet touch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per user feedback screenshots: SchemaDrivenForm DateTimeField: - grid → flex flex-wrap: на узких cells (drawer 520px ÷ 2 cols = ~210px) time wraps под date вертикально. На wide — inline. - Date min-w 8rem (128px), time w-24 (96px) — tablet touch-safe. TableHeaderCell без visible label (sr-only для screen readers). TableCell содержит DropdownMenu trigger 36×36px (touch target): - History (always) - Edit, Separator, Close (только canMutate, Close в danger color) Заменяет 3 inline IconButton'a — экономит ширину + tablet friendly. RecordHistoryDrawer accepts onCompare / onRevert callbacks. Каждая non-current version row показывает: - Сравнить с текущим — opens ConflictDiffModal (reused diff UI) - Откатить к v{N} — submit updateMut с этой version данными как payload (+ confirm dialog). Backend создаёт новую active version, bitemporal history preserves старые. JSON viewer переключился с
на state-driven (hide/show без CSS rabbit hole). history.hideData/compare/revert/revertConfirm (RU + EN). - 3-dot trigger 36px = comfortable touch target (Apple HIG 44, ours acceptable для density) - Drawer на <880px viewport = fullscreen sheet (already) - DateTime wraps на cell <8rem+9rem+gap = 290px - Sidebar collapse на <1024 = hamburger overlay --- .../src/components/form/SchemaDrivenForm.tsx | 20 +-- .../components/record/RecordHistoryDrawer.tsx | 67 ++++++++-- ordinis-admin-ui/src/i18n.ts | 8 ++ .../src/routes/dictionaries.$name.tsx | 114 ++++++++++++++---- 4 files changed, 169 insertions(+), 40 deletions(-) diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx index 99f218a..919d54e 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx @@ -802,15 +802,21 @@ type DateTimeFieldProps = { const DateTimeField = ({ label, value, defaultTime, onChange, hint, error }: DateTimeFieldProps) => { const dateValue = parseFormDate(value) const timeValue = extractTime(value, defaultTime) + // flex-wrap вместо fixed grid: на узких ячейках (drawer 520px ÷ 2 cols = + // ~210px) time wraps под date. На wide (880px ÷ 2 = ~410px) — inline. + // min-w на date обеспечивает разумный размер до wrap'a. Время 6rem (96px) + // достаточно для HH:mm + tap target 44px touch-safe. return (
-
- onChange(combineDateTime(d, timeValue, value ?? ''))} - /> -
+
+
+ onChange(combineDateTime(d, timeValue, value ?? ''))} + /> +
+
void dictionaryName: string businessKey: string | undefined + /** Optional — invoked when user clicks "Сравнить с текущим" на старой версии. + * Parent открывает diff view (например ConflictDiffModal с server vs that + * version'ом). Если не предоставлен, кнопка скрыта. */ + onCompare?: (versionData: unknown, displayVersion: number) => void + /** Optional — invoked для "Откатить к v1.X.X". Parent submits update + * mutation с этой версией как payload. Если undefined — кнопка скрыта. */ + onRevert?: (versionData: unknown, displayVersion: number) => void } -export const RecordHistoryDrawer = ({ open, onClose, dictionaryName, businessKey }: Props) => { +export const RecordHistoryDrawer = ({ + open, onClose, dictionaryName, businessKey, onCompare, onRevert, +}: Props) => { const { t } = useTranslation() const { data, isLoading, error } = useRecordHistory(dictionaryName, open ? businessKey : undefined) + // Per-row collapsible JSON viewer — выносим из
чтобы хранить + // expanded state поверх renders. + const [expanded, setExpanded] = useState>(new Set()) return ( )}
-
- - {t('history.viewData')} - -
+                  {/* Per-version action row — Compare / Revert + JSON toggle.
+                    * Compare показываем для не-current versions (нечего сравнивать
+                    * с самим собой). Revert тоже только для старых (на текущей
+                    * uno-op). Wrap чтобы планшетный portrait делал stack. */}
+                  
+ + {!isLatest && onCompare && ( + + )} + {!isLatest && onRevert && ( + + )} +
+ {expanded.has(rec.id) && ( +
                       {JSON.stringify(rec.data, null, 2)}
                     
-
+ )} ) })} diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index df02456..0fa6458 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -389,6 +389,10 @@ i18n 'history.validTo': 'действует до', 'history.updatedBy': 'кем', 'history.viewData': 'данные записи (JSON)', + 'history.hideData': 'скрыть JSON', + 'history.compare': 'Сравнить с текущим', + 'history.revert': 'Откатить к v{{v}}', + 'history.revertConfirm': 'Откатить к v{{v}}? Создастся новая активная версия.', // === Lineage (dict-relationships-v2 Phase 1) === 'lineage.usedBy.title': 'На этот словарь ссылаются', 'lineage.usedBy.count_one': '{{count}} связь', @@ -895,6 +899,10 @@ i18n 'history.validTo': 'valid to', 'history.updatedBy': 'by', 'history.viewData': 'record data (JSON)', + 'history.hideData': 'hide JSON', + 'history.compare': 'Compare with current', + 'history.revert': 'Revert to v{{v}}', + 'history.revertConfirm': 'Revert to v{{v}}? A new active version will be created.', // === Lineage (dict-relationships-v2 Phase 1) === 'lineage.usedBy.title': 'Used by', 'lineage.usedBy.count_one': '{{count}} reference', diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index 7ef1c1b..7ecc17d 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -7,6 +7,11 @@ import { Badge, Button, Checkbox, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, EmptyState, IconButton, LoadingBlock, @@ -20,7 +25,7 @@ import { TableRow, TextInput, } from '@/ui' -import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react' +import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, CaretLeftIcon, CaretRightIcon, DotsThreeVerticalIcon } from '@phosphor-icons/react' import { useAudit, useDictionaryDetail, @@ -897,7 +902,12 @@ function DictionaryDetail() { ))} {t('dict.col.scope')} {t('dict.col.validFrom')} - {t('dict.col.actions')} + {/* Actions column — без подписи per redesign prototype: + * 3-dot trigger открывает menu с History / Edit / Close. + * Aria-label на cell хедере для screen readers (visual: пусто). */} + + {t('dict.col.actions')} + @@ -976,29 +986,44 @@ function DictionaryDetail() { e.stopPropagation()}> -
- } - onClick={() => setHistoryKey(r.businessKey)} - /> - {canMutate && ( - <> - } - onClick={() => setEdit({ kind: 'edit', record: r })} - /> - } - onClick={() => setEdit({ kind: 'close-confirm', record: r })} - /> - - )} +
+ {/* 3-dot DropdownMenu per redesign prototype. Заменяет + * inline icon trio (history / edit / close). Trigger: + * 36px touch target (tablet-safe). Items: History, + * Edit, Close (Edit/Close — только для canMutate). */} + + + + + + setHistoryKey(r.businessKey)}> + + {t('history.title')} + + {canMutate && ( + <> + setEdit({ kind: 'edit', record: r })}> + + {t('dict.action.edit')} + + + setEdit({ kind: 'close-confirm', record: r })} + className="text-mars focus:text-mars focus:bg-mars/8" + > + + {t('dict.action.close')} + + + )} + +
@@ -1191,6 +1216,45 @@ function DictionaryDetail() { onClose={() => setHistoryKey(undefined)} dictionaryName={name} businessKey={historyKey} + // Compare: opens the conflict diff modal сравнивает selected version + // данные с текущим server state. Re-use ConflictDiffModal — оно + // exactly показывает "что было vs что есть" diff. + onCompare={canMutate ? (versionData) => { + if (!historyKey) return + const fakePayload: CreateRecordRequest = { + businessKey: historyKey, + data: versionData as Record, + validFrom: undefined, + validTo: undefined, + } + setConflict({ payload: fakePayload, businessKey: historyKey }) + } : undefined} + // Revert: submit update mutation с old version's data как payload. + // Backend создаст новую active version с этими data — старая history + // сохраняется (bitemporal). 409 на conflict перехватываем как при + // обычном edit. + onRevert={canMutate ? (versionData, version) => { + if (!historyKey) return + const confirmed = window.confirm( + t('history.revertConfirm', { v: version, defaultValue: `Откатить к v${version}? Создастся новая активная версия.` }) as string + ) + if (!confirmed) return + updateMut.mutate({ + businessKey: historyKey, + payload: { + businessKey: historyKey, + data: versionData as Record, + validFrom: undefined, + validTo: undefined, + }, + idempotencyKey: recordIdempotencyKey, + }, { + onSuccess: () => { + setHistoryKey(undefined) + resetRecordIdempotencyKey() + }, + }) + } : undefined} />