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. * *

Поток: *

    *
  1. Открывается из existing close flow когда DELETE возвращает 409 * (x_references_blocked_by_dependents или x_references_cascade_required).
  2. *
  3. Загружает preview через {@code GET /cascade-preview}.
  4. *
  5. Если есть BLOCKERS — показывает их + объясняет что resolve first; * confirm button disabled.
  6. *
  7. Если есть только CASCADE/WARN — показывает per-source breakdown + * sample 3 records per source, plus "Type CONFIRM" guard если N {@literal >} 50.
  8. *
  9. Confirm → {@code POST /cascade-close?confirmed=true} → success message.
  10. *
  11. Cancel → close dialog без изменений.
  12. *
* *

UX guards: *

*/ 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() 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 (
{preview.isLoading && } {preview.error && ( {String(preview.error)} )} {plan && ( <> {/* Header summary */}

{hasBlockers ? t('cascade.summary.blocked', { blockers: plan.blockers.length, target: businessKey, }) : t('cascade.summary.willClose', { total: totalToClose, target: businessKey, })}

{/* BLOCKERS section — non-resolvable. */} {plan.blockers.length > 0 && (
    {plan.blockers.slice(0, 5).map((b) => (
  • {b.sourceDict}/{b.businessKey} .{b.sourceField}
  • ))} {plan.blockers.length > 5 && (
  • {t('cascade.blockers.more', { count: plan.blockers.length - 5 })}
  • )}

{t('cascade.blockers.resolveFirst')}

)} {/* CASCADE section — preview per-source. */} {!hasBlockers && plan.cascade.length > 0 && (

{t('cascade.cascade.title', { count: plan.cascade.length })}

    {cascadeBySource.map(([key, entries]) => { const first = entries[0] const sample = entries.slice(0, SAMPLE_PER_SOURCE) const overflow = entries.length - sample.length return (
  • {first.sourceDisplayName ?? first.sourceDict} .{first.sourceField} {t(`lineage.onClose.${first.onClose}`)} {entries.length}
      {sample.map((e) => (
    • · {e.businessKey}
    • ))} {overflow > 0 && (
    • {t('cascade.cascade.more', { count: overflow })}
    • )}
  • ) })}
)} {/* WARNINGS section — proceed but emit warnings. */} {!hasBlockers && plan.warnings.length > 0 && (

{t('cascade.warnings.note')}

)} {/* Reason input — only when going to proceed. */} {!hasBlockers && (plan.cascade.length > 0 || plan.warnings.length > 0) && (
setReason(e.target.value)} placeholder={t('cascade.reason.placeholder')} />
)} {/* Type CONFIRM gate when N > 50. */} {!hasBlockers && requiresGate && (
setConfirmText(e.target.value)} placeholder="CONFIRM" />
)} {cascadeMut.error && ( {String(cascadeMut.error)} )} )} {/* Footer actions */}
{!hasBlockers && plan && (plan.cascade.length > 0 || plan.warnings.length > 0) && ( )}
) }