feat(record): RecordDrawer slide-over per Stage 3.5 handoff (Phase A)

Заменяет 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 через <button form={formId} type=submit>).
  - Dirty-count считается из RHF formState.dirtyFields (top-level + data.*).
  - useEffect surfaces count к parent — drawer footer caption updates live.

- src/routes/dictionaries.$name.tsx
  - Modal create/edit -> RecordDrawer.
  - State: + recordDirtyCount (reset на close).
  - onDelete wires к existing close-confirm flow (kind: 'close-confirm').
  - onHistory wires к existing setHistoryKey RecordHistoryDrawer.

- src/i18n.ts
  - + form.delete, form.unsavedNFields_one/few/many/other (i18next plural rules).
  - + drawer.{collapse,expand,close,captionEdit,captionCreate}.

Phase B (TODO Stage 3.5b — separate MR):
- Section anchor chips (sticky strip)
- Wide-mode left ToC rail (180px)
- Sticky toolbar: search + 'только заполненные' toggle + counter
- Requires x-section schema metadata в backend properties (CEO open question #5 в handoff)

Tests: 8 files, 116 tests pass. TS strict clean.
This commit is contained in:
Zimin A.N.
2026-05-11 14:39:38 +03:00
parent b94912789f
commit 750395da09
4 changed files with 320 additions and 24 deletions
@@ -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<EditState>({ 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<string | undefined>(undefined)
const [aoiOpen, setAoiOpen] = useState(false)
@@ -897,19 +900,31 @@ function DictionaryDetail() {
)}
{/* === end records tab content === */}
<Modal
isOpen={edit.kind === 'create' || edit.kind === 'edit'}
onClose={() => setEdit({ kind: 'closed' })}
title={
edit.kind === 'create'
? t('dict.action.create')
: edit.kind === 'edit'
? `${t('dict.action.edit')}: ${edit.record.businessKey}`
: ''
<RecordDrawer
open={edit.kind === 'create' || edit.kind === 'edit'}
onClose={() => {
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' && (
<SchemaDrivenForm
@@ -921,6 +936,8 @@ function DictionaryDetail() {
serverError={serverError}
onSubmit={handleSubmit}
onCancel={() => 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}
/>
)}
</Modal>
</RecordDrawer>
<Modal
isOpen={edit.kind === 'close-confirm'}