From 750395da09dc11a4048744417933864df147708d Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 11 May 2026 14:39:38 +0300 Subject: [PATCH] feat(record): RecordDrawer slide-over per Stage 3.5 handoff (Phase A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заменяет Modal-based record create/edit на right slide-over drawer per design_handoff_ordinis_mdm/README.md Screen 3. What's в Phase A (drawer shell + sticky chrome): - src/components/record/RecordDrawer.tsx (NEW) - Two widths: 520px default / 880px wide, toggle через ‹/› button. Persists в localStorage 'ord-drawer-wide'. - Sticky header: caption (cap style) + recordId mono + width-toggle + history button (edit mode) + ×. - Sticky footer: [Удалить] (danger, edit mode) · 'не сохранено · N полей' caption · [Отмена] · [Сохранить] (drives form submit через form attr). - Built на shadcn Sheet primitive + Radix Dialog. Override sheetVariants inline для transition-able max-width (variant size не toggle'ится smoothly без animate prop). - aria-labelledby wires DialogTitle к содержимому header. - Mobile <880px: w-full fullscreen (per handoff responsive table). - src/components/form/SchemaDrivenForm.tsx - New optional props: formId, onDirtyChange. - Когда formId передан — внутренний FormActions footer прячется (drawer footer driving submit через - - + {/* Когда formId не передан — внутренний footer (backward compat). + * Когда передан — drawer footer (см. RecordDrawer) рисует кнопки и + * сабмитит через атрибут form={formId} на + + + )} ) } diff --git a/ordinis-admin-ui/src/components/record/RecordDrawer.tsx b/ordinis-admin-ui/src/components/record/RecordDrawer.tsx new file mode 100644 index 0000000..e7c8493 --- /dev/null +++ b/ordinis-admin-ui/src/components/record/RecordDrawer.tsx @@ -0,0 +1,227 @@ +import { useCallback, useState, useId, type ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { + ArrowLeftToLine, + ArrowRightToLine, + History, + Trash2, + X, +} from 'lucide-react' +import { Sheet, SheetPortal } from '@/ui' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { Button } from '@/ui' +import { cn } from '@/lib/utils' + +/** + * RecordDrawer — right slide-over для create/edit записи. Stage 3.5 per + * design_handoff_ordinis_mdm/README.md (Screen 3). + * + *

Layout (sticky frame, scrolling body): + *

+ *   ┌───────────────────────────────────────┐
+ *   │ HEADER (sticky)                       │
+ *   │  caption · id · [‹/›] [⏱] [×]         │
+ *   ├───────────────────────────────────────┤
+ *   │ BODY (scroll)                         │
+ *   │  children — SchemaDrivenForm          │
+ *   ├───────────────────────────────────────┤
+ *   │ FOOTER (sticky)                       │
+ *   │ [Удалить] · "N полей" · [Отмена][Save]│
+ *   └───────────────────────────────────────┘
+ * 
+ * + *

Two widths: + *

+ * + *

Phase 1 (current): drawer shell + width toggle + sticky chrome. + * Phase 2 (TODO Stage 3.5b): section anchor chips, wide-mode ToC rail + * (180px), search field, "только заполненные" toggle. Требует {@code x-section} + * metadata в schemaJson — пока секционирование делает SchemaDrivenForm своими + * 3 tabs. + * + *

Drives form submit через атрибут {@code form={formId}} на footer Save + * button. SchemaDrivenForm должен получить тот же {@code formId} prop и + * скрыть свой внутренний footer. + */ + +const LS_KEY = 'ord-drawer-wide' + +/** Hook: persisted boolean (drawer wide mode). */ +function useDrawerWide(): [boolean, () => void] { + const [wide, setWide] = useState(() => { + if (typeof window === 'undefined') return false + return window.localStorage.getItem(LS_KEY) === '1' + }) + const toggle = useCallback(() => { + setWide((prev) => { + const next = !prev + try { + window.localStorage.setItem(LS_KEY, next ? '1' : '0') + } catch { + // localStorage может быть disabled (Safari private). Игнорируем — + // в памяти state сохраняется до закрытия таба, persist опционален. + } + return next + }) + }, []) + return [wide, toggle] +} + +export type RecordDrawerProps = { + open: boolean + onClose: () => void + /** Title caption — "редактирование записи · {dictionary}" or "новая запись · {dictionary}". */ + caption: string + /** Record business key / id — shown в header mono. Undefined для create. */ + recordId?: string + /** Save button form attribute target. SchemaDrivenForm renders

. */ + formId: string + /** Dirty-field count для caption "не сохранено · N полей". 0 hides it. */ + dirtyCount?: number + /** Disable footer buttons (mutation в полёте). */ + isPending?: boolean + /** Show [Удалить] danger button (только edit mode, не create). */ + onDelete?: () => void + /** Show [История] button + handler (только edit mode). */ + onHistory?: () => void + children: ReactNode +} + +export function RecordDrawer({ + open, + onClose, + caption, + recordId, + formId, + dirtyCount = 0, + isPending = false, + onDelete, + onHistory, + children, +}: RecordDrawerProps) { + const { t } = useTranslation() + const [wide, toggleWide] = useDrawerWide() + // Уникальный id для DialogTitle/Description чтобы Radix mapping aria-labelledby. + const titleId = useId() + + // Reset focus к save button при открытии — обычно user закончил поиск и хочет + // быстро save после check. Radix focus traps inside, default landing — first + // focusable. Save в footer первой стрелкой Tab был бы удобнее, но это + // нарушает default flow. Пока default. + + return ( + { if (!v) onClose() }}> + + + + {/* === HEADER (sticky top) === */} +
+
+ + {caption} + + {recordId && ( +
{recordId}
+ )} +
+
+ + {onHistory && ( + + )} + + + +
+
+ + {/* === BODY (scrolling) === + * Phase 2 здесь будет: sticky toolbar (search + filled-only toggle + + * counter) → section anchor chips strip → optional left ToC rail в + * wide mode (CSS grid 180px / 1fr). Пока children — SchemaDrivenForm. */} +
{children}
+ + {/* === FOOTER (sticky bottom) === */} +
+ {onDelete && ( + + )} +
+ {dirtyCount > 0 && t('form.unsavedNFields', { count: dirtyCount })} +
+ + +
+
+
+
+ ) +} diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index f0a0a02..6e225bb 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -324,7 +324,17 @@ i18n 'form.cancel': 'Отмена', 'form.save': 'Сохранить', 'form.saving': 'Сохранение...', + 'form.delete': 'Удалить', 'form.error.required': 'Обязательное поле', + 'form.unsavedNFields_one': 'не сохранено · {{count}} поле', + 'form.unsavedNFields_few': 'не сохранено · {{count}} поля', + 'form.unsavedNFields_many': 'не сохранено · {{count}} полей', + 'form.unsavedNFields_other': 'не сохранено · {{count}} полей', + 'drawer.collapse': 'Свернуть до 520px', + 'drawer.expand': 'Развернуть до 880px', + 'drawer.close': 'Закрыть', + 'drawer.captionEdit': 'редактирование записи · {{dict}}', + 'drawer.captionCreate': 'новая запись · {{dict}}', 'scope.PUBLIC': 'PUBLIC — публичный', 'scope.INTERNAL': 'INTERNAL — внутренний', 'scope.RESTRICTED': 'RESTRICTED — ограниченный', @@ -822,6 +832,14 @@ i18n 'form.cancel': 'Cancel', 'form.save': 'Save', 'form.saving': 'Saving...', + 'form.delete': 'Delete', + 'form.unsavedNFields_one': 'unsaved · {{count}} field', + 'form.unsavedNFields_other': 'unsaved · {{count}} fields', + 'drawer.collapse': 'Collapse to 520px', + 'drawer.expand': 'Expand to 880px', + 'drawer.close': 'Close', + 'drawer.captionEdit': 'edit record · {{dict}}', + 'drawer.captionCreate': 'new record · {{dict}}', 'form.error.required': 'Required field', 'scope.PUBLIC': 'PUBLIC', 'scope.INTERNAL': 'INTERNAL', diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index 4f3f004..23e3fea 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -39,6 +39,7 @@ import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDepend 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 { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog' import { TimeTravelPicker } from '@/components/timetravel/TimeTravelPicker' import { nowIsoLocal } from '@/lib/dates' @@ -172,6 +173,8 @@ function DictionaryDetail() { const [edit, setEdit] = useState({ kind: 'closed' }) const [closeReason, setCloseReason] = useState('') + // Dirty-field count surfaced by SchemaDrivenForm → shown в RecordDrawer footer. + const [recordDirtyCount, setRecordDirtyCount] = useState(0) const [schemaEditOpen, setSchemaEditOpen] = useState(false) const [historyKey, setHistoryKey] = useState(undefined) const [aoiOpen, setAoiOpen] = useState(false) @@ -897,19 +900,31 @@ function DictionaryDetail() { )} {/* === end records tab content === */} - setEdit({ kind: 'closed' })} - title={ - edit.kind === 'create' - ? t('dict.action.create') - : edit.kind === 'edit' - ? `${t('dict.action.edit')}: ${edit.record.businessKey}` - : '' + { + setEdit({ kind: 'closed' }) + setRecordDirtyCount(0) + }} + caption={ + edit.kind === 'edit' + ? t('drawer.captionEdit', { dict: name }) + : t('drawer.captionCreate', { dict: name }) + } + recordId={edit.kind === 'edit' ? edit.record.businessKey : undefined} + formId="record-form" + dirtyCount={recordDirtyCount} + isPending={Boolean(activeMutation?.isPending)} + onDelete={ + edit.kind === 'edit' + ? () => setEdit({ kind: 'close-confirm', record: edit.record }) + : undefined + } + onHistory={ + edit.kind === 'edit' + ? () => setHistoryKey(edit.record.businessKey) + : undefined } - maxWidth="max-w-4xl" - panelClassName="max-h-[calc(100vh-2rem)] my-4 flex flex-col" - bodyClassName="overflow-y-auto flex-1" > {detailQuery.data && edit.kind === 'create' && ( setEdit({ kind: 'closed' })} + formId="record-form" + onDirtyChange={setRecordDirtyCount} /> )} @@ -948,16 +965,18 @@ function DictionaryDetail() { // Edit в bitemporal model = «создаём новую версию с этого момента, // старая закрывается». validFrom оставляем пустым — handleSubmit // подставит nowIsoLocal() at submit-time (review #5: иначе stale - // если user долго держит modal открытым). + // если user долго держит drawer открытым). // validTo пустой = бессрочно (или до следующей правки). validFrom: '', validTo: '', }} onSubmit={handleSubmit} onCancel={() => setEdit({ kind: 'closed' })} + formId="record-form" + onDirtyChange={setRecordDirtyCount} /> )} - +