// Mandatory reason on destructive operations (PER-4). // // A manifest model declares `reason_required` (delete and/or actions such as // cancel). The kernel then answers a DELETE / action WITHOUT a reason with // 422 `errors.reason: [{code: "required" | "min", params: {min}}]`. Instead of // every screen carrying its own "motivo" prompt, `useReasonPrompt().run()` wraps // the request: it tries it, and when the server says a reason is needed it asks // once (a dialog) and retries with the reason attached. Screens that already know // they need one can pass `reason` up front through the same helper. // // The reason is stamped on the canonical event by the kernel, so the activity log // (RecordHistory) shows who removed/cancelled what, when, and why. import React, { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Loader2 } from 'lucide-react' import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Textarea, } from '@asteby/metacore-ui/primitives' /** What the server told us about the missing reason. */ export interface ReasonRequiredInfo { /** Minimum trimmed length the policy demands (default 3). */ min: number /** True when a reason WAS sent but was too short. */ tooShort: boolean } export const DEFAULT_REASON_MIN = 3 /** * Detects the kernel's mandatory-reason refusal in an axios error (or a bare * response body): HTTP 422 with `errors.reason[0].code` of `required` | `min`. * Returns null for any other error. */ export function reasonRequiredInfo(err: unknown): ReasonRequiredInfo | null { const data = (err as { response?: { data?: unknown } } | undefined)?.response?.data ?? err const issues = (data as { errors?: { reason?: unknown } } | undefined)?.errors?.reason if (!Array.isArray(issues) || issues.length === 0) return null const first = issues[0] as { code?: string; params?: { min?: number } } | string const code = typeof first === 'string' ? first : first?.code if (code !== 'required' && code !== 'min') return null const min = typeof first === 'object' && typeof first?.params?.min === 'number' ? first.params.min : DEFAULT_REASON_MIN return { min, tooShort: code === 'min' } } export interface ReasonPromptDialogProps { open: boolean title: string description?: React.ReactNode /** Minimum trimmed length; the submit button stays disabled below it. */ min?: number /** Label of the confirm button (default "Continuar"). */ confirmLabel?: string /** Style the confirm button as destructive (delete / cancel). */ destructive?: boolean /** Inline error from a previous attempt. */ error?: string | null busy?: boolean onSubmit: (reason: string) => void onCancel: () => void } export function ReasonPromptDialog({ open, title, description, min = DEFAULT_REASON_MIN, confirmLabel, destructive = true, error, busy, onSubmit, onCancel, }: ReasonPromptDialogProps) { const { t } = useTranslation() const [reason, setReason] = useState('') useEffect(() => { if (open) setReason('') }, [open]) const ready = reason.trim().length >= min return ( !o && !busy && onCancel()}> {title} {description ? {description} : null}