// useDynamicRowActions — the single source of truth for what a per-row action // (the "…" kebab → Ver / Editar / Eliminar / custom / link) DOES when clicked. // // Extracted verbatim from DynamicTable so the kanban board's per-card menu // behaves IDENTICALLY to a table row (it used to forward the raw action object // to the host and silently no-op). Both renderers call the returned // `handleInternalAction(actionKey, row)` with the action's STRING key and mount // the returned `dialogs` node once in their tree: // // delete → opens the SDK's own confirm dialog, then DELETEs. // view / edit → host `onAction(action, row)` when provided (string // contract), else the built-in record dialog. // link action → navigate to the action's templated `linkUrl`. // requiresAddon → opens the "requires addon" dialog; the action is // never dispatched (no modal, no backend call). // custom (modal/fields/ // confirm/executable) → opens the ActionModal via the dispatcher. // anything else → host `onAction` + refresh (or just refresh). // // The host `onAction` contract is a STRING action key on purpose — the existing // ops `handleAction(action: string, row)` keeps working unchanged. import { useCallback, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@asteby/metacore-ui/primitives' import { useApi } from './api-context' import { useReasonPrompt } from './reason-prompt' import { ActionModalDispatcher } from './action-modal-dispatcher' import { DynamicRecordDialog } from './dialogs/dynamic-record' import { RequiresAddonDialog, resolveRequiresAddon } from './requires-addon' import type { TableMetadata, ActionMetadata, RequiresAddon } from './types' /** * Destino de una acción link. La query (`?create=…&from_id=…`) va como `search`: * metida en `to` el router la tomaba como parte del path y la ruta no casaba. */ export function linkNavigation( url: string, ): { to: '/m/$model'; params: { model: string }; search?: Record } | { to: string; search?: Record } { const i = url.indexOf('?') const path = i < 0 ? url : url.slice(0, i) const search = i < 0 ? undefined : Object.fromEntries(new URLSearchParams(url.slice(i + 1))) // /m/?create=&from=&from_id= → ruta parametrizada (retest r6b: el menú // «Nota de crédito» de la factura aterrizaba en la lista sin abrir el editor). const m = path.match(/^\/m\/([^/?#]+)\/?$/) if (m) return { to: '/m/$model', params: { model: m[1] }, search } return search ? { to: path, search } : { to: path } } export interface UseDynamicRowActionsParams { /** Model key as registered on the backend (e.g. "issue"). */ model: string /** * Data endpoint base. The per-record DELETE hits `/`; when * omitted it falls back to `/data//`. Pass the SAME base the * caller lists from (e.g. `/data//me`) so writes stay org-scoped. */ endpoint?: string /** * Endpoint base for WRITES when it differs from the list endpoint. Some * hosts list from a role-scoped path (`/dynamic//me`) that does NOT * accept `DELETE /` — pass the mutable base here (e.g. * `/dynamic/`). Falls back to `endpoint`. */ mutationEndpoint?: string /** Raw model metadata — used to resolve link/custom action definitions. */ metadata: TableMetadata | null /** * Host hook for `view`/`edit` (and any unhandled key). STRING contract: * receives the action key, not the action object. When absent, `view`/`edit` * fall back to the built-in record dialog. */ onAction?: (action: string, row: any) => void /** Called after a successful delete / custom action to refetch the list. */ onRefresh: () => void } export interface DynamicRowActions { /** Dispatch a row action by its STRING key. */ handleInternalAction: (action: string, row: any) => Promise /** * The delete-confirm dialog + record dialog + action modal this handler * drives. Render it ONCE in the consuming component's tree. */ dialogs: React.ReactElement } /** * Owns the state + dialogs + dispatch logic for per-row actions, shared by * DynamicTable and DynamicKanban so both behave identically. */ export function useDynamicRowActions({ model, endpoint, mutationEndpoint, metadata, onAction, onRefresh, }: UseDynamicRowActionsParams): DynamicRowActions { const writeBase = mutationEndpoint ?? endpoint const { t } = useTranslation() const api = useApi() const navigate = useNavigate() const [recordDialog, setRecordDialog] = useState<{ open: boolean mode: 'view' | 'edit' | 'create' recordId: string | null }>({ open: false, mode: 'view', recordId: null }) const [rowToDelete, setRowToDelete] = useState(null) const [isDeleting, setIsDeleting] = useState(false) // Models that declare `reason_required.delete` answer a bare DELETE with 422 // errors.reason; the prompt asks once and retries with `?reason=`. const reasonPrompt = useReasonPrompt() const [actionModal, setActionModal] = useState<{ open: boolean action: ActionMetadata | null record: any | null }>({ open: false, action: null, record: null }) const [missingAddon, setMissingAddon] = useState(null) const handleInternalAction = useCallback(async (action: string, row: any) => { // A missing optional addon gates the action BEFORE any other routing: // it must not reach the dispatcher, the host, or the backend. const requirement = resolveRequiresAddon(metadata?.actions?.find((a) => a.key === action)) if (requirement) { setMissingAddon(requirement); return } if (action === 'delete') { setRowToDelete(row); return } if (action === 'view' || action === 'edit') { if (onAction) await Promise.resolve(onAction(action, row)) else setRecordDialog({ open: true, mode: action, recordId: row.id }) return } const linkDef = metadata?.actions?.find((a) => a.key === action && a.type === 'link') if (linkDef?.linkUrl) { const url = linkDef.linkUrl.replace(/\{(\w+)\}/g, (_: string, field: string) => String(row[field] ?? '')) navigate(linkNavigation(url) as any) return } const actionDef = metadata?.actions?.find((a) => a.key === action) // Open the dispatcher when there is ANY UI surface (federated `modal`, // fields, wizard steps, confirm bool, confirmMessage, or host // `executable`). A confirm_message without confirm:true used to fall // through / open and then render null — derive confirm so the // ConfirmActionDialog path wins. Pass the WHOLE definition: a // cherry-picked copy dropped `modal` and the dispatcher fell back to // the generic confirm while the remote was still loading. if (actionDef && (actionDef.modal || actionDef.fields?.length || actionDef.steps?.length || actionDef.confirm || actionDef.confirmMessage || actionDef.executable)) { setActionModal({ open: true, action: { ...actionDef, icon: actionDef.icon || 'Zap', confirm: !!(actionDef.confirm || actionDef.confirmMessage), }, record: row, }) return } if (onAction) { await Promise.resolve(onAction(action, row)); onRefresh() } else onRefresh() }, [onAction, onRefresh, metadata, navigate]) const confirmDelete = async () => { if (!rowToDelete) return setIsDeleting(true) try { const deleteEndpoint = writeBase ? `${writeBase}/${rowToDelete.id}` : `/data/${model}/${rowToDelete.id}` const res = await reasonPrompt.run({ title: t('dynamic.delete_reason_title', { defaultValue: 'Eliminar registro' }), description: t('dynamic.delete_reason_desc', { defaultValue: 'Este modelo pide el motivo de la eliminación.' }), confirmLabel: t('dynamic.delete_confirm', { defaultValue: 'Eliminar' }), request: (reason) => api.delete(deleteEndpoint, reason ? { params: { reason } } : undefined), }) // El operador canceló el motivo: no se eliminó nada. if (!res) return // CRUD estándar: no usar res.data.message (el endpoint dinámico // devuelve texto en inglés que se filtraría al toast). String localizado. if (res.data.success) { toast.success(t('dynamic.delete_success', { defaultValue: 'Registro eliminado correctamente' })); onRefresh() } else toast.error(t('dynamic.delete_error', { defaultValue: 'No se pudo eliminar el registro' })) } catch (error) { console.error('Error al eliminar', error) toast.error(t('dynamic.delete_error', { defaultValue: 'No se pudo eliminar el registro' })) } finally { setIsDeleting(false) setRowToDelete(null) } } const dialogs = ( <> {reasonPrompt.dialog} !open && setRowToDelete(null)}> ¿Está absolutamente seguro? Esta acción no se puede deshacer. Esto eliminará permanentemente el registro seleccionado de nuestros servidores. {t('common.cancel')} { e.preventDefault(); confirmDelete() }} className="bg-red-600 hover:bg-red-700" disabled={isDeleting}> {isDeleting ? 'Eliminando...' : 'Eliminar'} setRecordDialog((prev) => ({ ...prev, open }))} mode={recordDialog.mode} model={model} recordId={recordDialog.recordId} endpoint={endpoint} onSaved={onRefresh} /> {actionModal.action && ( setActionModal((prev) => ({ ...prev, open }))} action={actionModal.action} model={model} record={actionModal.record} endpoint={endpoint} onSuccess={onRefresh} /> )} !open && setMissingAddon(null)} /> ) return { handleInternalAction, dialogs } }