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:
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user