5a2220d779
UI redesign matching prototype (Ordinis (3) dump): - ExportModal: format (CSV/JSON/Excel/SQL) + scope (all/filtered/selected) + column toggles + encoding/delimiter + preview filename per redesign line 1059-1092. CSV uses backend mutation, JSON client-side from filtered rows, Excel/SQL disabled (backend pending). - TimeTravelModal: full-page compare СЕЙЧАС vs ТОЧКА ВО ВРЕМЕНИ + quick presets (сейчас/неделя/месяц/год/при создании) + step toggle версия/день + slider + tabs (Что изменилось / Записи на момент / Структура и поля) + "Открыть как readonly" CTA. Replaces inline TimeTravelPicker. - InfoPanel ← используют merge: rich rows с field path + active records count + onClose policy chip (BLOCK/WARN/CASCADE). Inline DictionaryDependentsPanel above records table removed (one source of truth). - Sidebar logo: match redesign/ui-kit.html .brand SVG (outer ring + inner faded ring + orbit dot offset right cx=22.5). ORDINIS Tektur tracking 0.22em + MDM smaller mute 0.32em. - Bg neutralized: #fbf8ee Earth cream → #faf9f5 warm neutral default (per user preference, Earth cream still described in comment как opt-in). - WorkflowBanner: Case 1 Live (approvalRequired=false) returns null — permanent "Опубликовано" banner = noise, status already visible в InfoPanel scope/version/updatedAt. Banner показывается только когда есть actionable state. Critical bug fixes: - fix(CascadeConfirmDialog): infinite render loop "Maximum update depth exceeded". useEffect had cascadeMut (TanStack Query mutation wrapper) в deps — wrapper identity changes every render → effect fires → reset() → store update → render → loop. Extract stable `reset` reference перед useEffect. Симптомы у user: создание/редактирование записи не работали. - fix(semantic-release): add missing peer dep conventional-changelog-conventionalcommits (9.3.1). semantic-release 22+ больше не bundles preset. Без неё release job падал с "Cannot find module 'conventional-changelog-conventionalcommits'".
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { Alert, Badge, Button, LoadingBlock, Modal, TextInput } from '@/ui'
|
||
import { WarningIcon, XCircleIcon } from '@phosphor-icons/react'
|
||
import { useCascadePreview } from '@/api/queries'
|
||
import { useCascadeCloseRecord } from '@/api/mutations'
|
||
import type { CascadeEntry, OnCloseAction } from '@/api/client'
|
||
|
||
/**
|
||
* Cascade close confirmation dialog — Phase 3 dict-relationships-v2.
|
||
*
|
||
* <p>Поток:
|
||
* <ol>
|
||
* <li>Открывается из existing close flow когда DELETE возвращает 409
|
||
* (x_references_blocked_by_dependents или x_references_cascade_required).</li>
|
||
* <li>Загружает preview через {@code GET /cascade-preview}.</li>
|
||
* <li>Если есть BLOCKERS — показывает их + объясняет что resolve first;
|
||
* confirm button disabled.</li>
|
||
* <li>Если есть только CASCADE/WARN — показывает per-source breakdown +
|
||
* sample 3 records per source, plus "Type CONFIRM" guard если N {@literal >} 50.</li>
|
||
* <li>Confirm → {@code POST /cascade-close?confirmed=true} → success message.</li>
|
||
* <li>Cancel → close dialog без изменений.</li>
|
||
* </ol>
|
||
*
|
||
* <p>UX guards:
|
||
* <ul>
|
||
* <li>"Type CONFIRM" gate когда total > 50 (защита от accidental cascade).</li>
|
||
* <li>Reason field — optional; пустой = backend audit fields пустые.</li>
|
||
* </ul>
|
||
*/
|
||
type Props = {
|
||
open: boolean
|
||
onClose: () => void
|
||
/** Called after successful cascade close. */
|
||
onSuccess: () => void
|
||
dictionaryName: string
|
||
businessKey: string | undefined
|
||
}
|
||
|
||
const onCloseVariant = (
|
||
a: OnCloseAction,
|
||
): 'neutral' | 'warning' | 'error' => {
|
||
if (a === 'CASCADE') return 'error'
|
||
if (a === 'WARN') return 'warning'
|
||
return 'neutral'
|
||
}
|
||
|
||
const SAMPLE_PER_SOURCE = 3
|
||
const CONFIRM_GATE_THRESHOLD = 50
|
||
|
||
export const CascadeConfirmDialog = ({
|
||
open,
|
||
onClose,
|
||
onSuccess,
|
||
dictionaryName,
|
||
businessKey,
|
||
}: Props) => {
|
||
const { t } = useTranslation()
|
||
const preview = useCascadePreview(dictionaryName, open ? businessKey : undefined)
|
||
const cascadeMut = useCascadeCloseRecord(dictionaryName)
|
||
// TanStack Query гарантирует что `reset` / `mutate` стабильны между
|
||
// renders, а сам wrapper object — нет. Экстрактим reset чтобы не
|
||
// включать unstable mutation object в useEffect deps (infinite loop:
|
||
// mutationObject identity → effect fires → reset → store update → render).
|
||
const cascadeReset = cascadeMut.reset
|
||
const [reason, setReason] = useState('')
|
||
const [confirmText, setConfirmText] = useState('')
|
||
|
||
// Reset state каждый раз когда dialog открывается с новой target.
|
||
useEffect(() => {
|
||
if (!open) {
|
||
setReason('')
|
||
setConfirmText('')
|
||
cascadeReset()
|
||
}
|
||
}, [open, businessKey, cascadeReset])
|
||
|
||
const plan = preview.data
|
||
const hasBlockers = (plan?.blockers.length ?? 0) > 0
|
||
const totalToClose = 1 + (plan?.cascade.length ?? 0)
|
||
const requiresGate = totalToClose > CONFIRM_GATE_THRESHOLD
|
||
const gatePassed = !requiresGate || confirmText.trim().toUpperCase() === 'CONFIRM'
|
||
|
||
// Group cascade entries by source dict для compact display.
|
||
const cascadeBySource = useMemo(() => {
|
||
const map = new Map<string, CascadeEntry[]>()
|
||
for (const e of plan?.cascade ?? []) {
|
||
const key = `${e.sourceDict}.${e.sourceField}`
|
||
if (!map.has(key)) map.set(key, [])
|
||
map.get(key)!.push(e)
|
||
}
|
||
return Array.from(map.entries())
|
||
}, [plan?.cascade])
|
||
|
||
const handleConfirm = () => {
|
||
if (!businessKey) return
|
||
cascadeMut.mutate(
|
||
{ businessKey, reason: reason.trim() || undefined, confirmed: true },
|
||
{
|
||
onSuccess: () => {
|
||
onSuccess()
|
||
onClose()
|
||
},
|
||
},
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Modal
|
||
isOpen={open}
|
||
onClose={onClose}
|
||
title={t('cascade.title')}
|
||
maxWidth="max-w-2xl"
|
||
>
|
||
<div className="space-y-4">
|
||
{preview.isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||
|
||
{preview.error && (
|
||
<Alert variant="error" title={t('error.failed')}>
|
||
{String(preview.error)}
|
||
</Alert>
|
||
)}
|
||
|
||
{plan && (
|
||
<>
|
||
{/* Header summary */}
|
||
<p className="text-body text-ink">
|
||
{hasBlockers
|
||
? t('cascade.summary.blocked', {
|
||
blockers: plan.blockers.length,
|
||
target: businessKey,
|
||
})
|
||
: t('cascade.summary.willClose', {
|
||
total: totalToClose,
|
||
target: businessKey,
|
||
})}
|
||
</p>
|
||
|
||
{/* BLOCKERS section — non-resolvable. */}
|
||
{plan.blockers.length > 0 && (
|
||
<Alert
|
||
variant="error"
|
||
title={t('cascade.blockers.title', { count: plan.blockers.length })}
|
||
>
|
||
<ul className="space-y-1.5 mt-2 text-cell">
|
||
{plan.blockers.slice(0, 5).map((b) => (
|
||
<li key={b.recordId} className="flex items-center gap-2">
|
||
<XCircleIcon weight="bold" size={12} className="text-aurora shrink-0" />
|
||
<span className="font-mono">
|
||
{b.sourceDict}/{b.businessKey}
|
||
</span>
|
||
<span className="text-mute">.{b.sourceField}</span>
|
||
</li>
|
||
))}
|
||
{plan.blockers.length > 5 && (
|
||
<li className="text-mute">
|
||
{t('cascade.blockers.more', { count: plan.blockers.length - 5 })}
|
||
</li>
|
||
)}
|
||
</ul>
|
||
<p className="mt-2 text-cell text-ink-2">
|
||
{t('cascade.blockers.resolveFirst')}
|
||
</p>
|
||
</Alert>
|
||
)}
|
||
|
||
{/* CASCADE section — preview per-source. */}
|
||
{!hasBlockers && plan.cascade.length > 0 && (
|
||
<section className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<WarningIcon weight="bold" size={14} className="text-horizon" />
|
||
<h4 className="text-body font-sans">
|
||
{t('cascade.cascade.title', { count: plan.cascade.length })}
|
||
</h4>
|
||
</div>
|
||
<ul className="space-y-2">
|
||
{cascadeBySource.map(([key, entries]) => {
|
||
const first = entries[0]
|
||
const sample = entries.slice(0, SAMPLE_PER_SOURCE)
|
||
const overflow = entries.length - sample.length
|
||
return (
|
||
<li
|
||
key={key}
|
||
className="border border-line rounded-sm px-3 py-2 text-cell"
|
||
>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<span className="font-mono text-accent">
|
||
{first.sourceDisplayName ?? first.sourceDict}
|
||
</span>
|
||
<span className="font-mono text-ink-2">
|
||
.{first.sourceField}
|
||
</span>
|
||
<Badge variant={onCloseVariant(first.onClose)}>
|
||
{t(`lineage.onClose.${first.onClose}`)}
|
||
</Badge>
|
||
<Badge variant="neutral">{entries.length}</Badge>
|
||
</div>
|
||
<ul className="text-cell text-ink-2 ml-1 space-y-0.5">
|
||
{sample.map((e) => (
|
||
<li key={e.recordId} className="font-mono">
|
||
· {e.businessKey}
|
||
</li>
|
||
))}
|
||
{overflow > 0 && (
|
||
<li className="text-mute">
|
||
{t('cascade.cascade.more', { count: overflow })}
|
||
</li>
|
||
)}
|
||
</ul>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
</section>
|
||
)}
|
||
|
||
{/* WARNINGS section — proceed but emit warnings. */}
|
||
{!hasBlockers && plan.warnings.length > 0 && (
|
||
<Alert
|
||
variant="warning"
|
||
title={t('cascade.warnings.title', { count: plan.warnings.length })}
|
||
>
|
||
<p className="text-cell">{t('cascade.warnings.note')}</p>
|
||
</Alert>
|
||
)}
|
||
|
||
{/* Reason input — only when going to proceed. */}
|
||
{!hasBlockers && (plan.cascade.length > 0 || plan.warnings.length > 0) && (
|
||
<div>
|
||
<label
|
||
htmlFor="cascade-reason"
|
||
className="text-cap text-ink-2"
|
||
>
|
||
{t('cascade.reason.label')}
|
||
</label>
|
||
<TextInput
|
||
id="cascade-reason"
|
||
value={reason}
|
||
onChange={(e) => setReason(e.target.value)}
|
||
placeholder={t('cascade.reason.placeholder')}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Type CONFIRM gate when N > 50. */}
|
||
{!hasBlockers && requiresGate && (
|
||
<div>
|
||
<label
|
||
htmlFor="cascade-confirm"
|
||
className="text-cap text-ink-2"
|
||
>
|
||
{t('cascade.confirmGate.label', { word: 'CONFIRM' })}
|
||
</label>
|
||
<TextInput
|
||
id="cascade-confirm"
|
||
value={confirmText}
|
||
onChange={(e) => setConfirmText(e.target.value)}
|
||
placeholder="CONFIRM"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{cascadeMut.error && (
|
||
<Alert variant="error" title={t('error.failed')}>
|
||
{String(cascadeMut.error)}
|
||
</Alert>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Footer actions */}
|
||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-line">
|
||
<Button
|
||
variant="secondary"
|
||
onClick={onClose}
|
||
disabled={cascadeMut.isPending}
|
||
>
|
||
{t('cascade.action.cancel')}
|
||
</Button>
|
||
{!hasBlockers && plan && (plan.cascade.length > 0 || plan.warnings.length > 0) && (
|
||
<Button
|
||
variant="danger"
|
||
onClick={handleConfirm}
|
||
disabled={cascadeMut.isPending || !gatePassed}
|
||
loading={cascadeMut.isPending}
|
||
>
|
||
{t('cascade.action.confirm', { count: totalToClose })}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|