@asteby/metacore-runtime-react 44.0.0 → 45.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/approval-events.d.ts +16 -0
  3. package/dist/approval-events.d.ts.map +1 -0
  4. package/dist/approval-events.js +27 -0
  5. package/dist/approval-gate.d.ts +39 -0
  6. package/dist/approval-gate.d.ts.map +1 -0
  7. package/dist/approval-gate.js +110 -0
  8. package/dist/approval-pin-dialog.d.ts +25 -0
  9. package/dist/approval-pin-dialog.d.ts.map +1 -0
  10. package/dist/approval-pin-dialog.js +66 -0
  11. package/dist/business/catalog.d.ts +16 -0
  12. package/dist/business/catalog.d.ts.map +1 -0
  13. package/dist/business/catalog.js +42 -0
  14. package/dist/business/customer-picker.d.ts +39 -0
  15. package/dist/business/customer-picker.d.ts.map +1 -0
  16. package/dist/business/customer-picker.js +51 -0
  17. package/dist/business/feedback.d.ts +72 -0
  18. package/dist/business/feedback.d.ts.map +1 -0
  19. package/dist/business/feedback.js +52 -0
  20. package/dist/business/field-errors.d.ts +26 -0
  21. package/dist/business/field-errors.d.ts.map +1 -0
  22. package/dist/business/field-errors.js +59 -0
  23. package/dist/business/format.d.ts +49 -0
  24. package/dist/business/format.d.ts.map +1 -0
  25. package/dist/business/format.js +128 -0
  26. package/dist/business/index.d.ts +14 -0
  27. package/dist/business/index.d.ts.map +1 -0
  28. package/dist/business/index.js +13 -0
  29. package/dist/business/line-items-editor.d.ts +25 -0
  30. package/dist/business/line-items-editor.d.ts.map +1 -0
  31. package/dist/business/line-items-editor.js +36 -0
  32. package/dist/business/line-items.d.ts +69 -0
  33. package/dist/business/line-items.d.ts.map +1 -0
  34. package/dist/business/line-items.js +133 -0
  35. package/dist/business/payment-capture.d.ts +21 -0
  36. package/dist/business/payment-capture.d.ts.map +1 -0
  37. package/dist/business/payment-capture.js +24 -0
  38. package/dist/business/payment.d.ts +61 -0
  39. package/dist/business/payment.d.ts.map +1 -0
  40. package/dist/business/payment.js +74 -0
  41. package/dist/business/product-picker.d.ts +24 -0
  42. package/dist/business/product-picker.d.ts.map +1 -0
  43. package/dist/business/product-picker.js +48 -0
  44. package/dist/business/product-search.d.ts +70 -0
  45. package/dist/business/product-search.d.ts.map +1 -0
  46. package/dist/business/product-search.js +51 -0
  47. package/dist/business/refund-destination.d.ts +19 -0
  48. package/dist/business/refund-destination.d.ts.map +1 -0
  49. package/dist/business/refund-destination.js +31 -0
  50. package/dist/business/refund.d.ts +29 -0
  51. package/dist/business/refund.d.ts.map +1 -0
  52. package/dist/business/refund.js +43 -0
  53. package/dist/business/use-async-search.d.ts +10 -0
  54. package/dist/business/use-async-search.d.ts.map +1 -0
  55. package/dist/business/use-async-search.js +38 -0
  56. package/dist/document/action-bar.d.ts +12 -0
  57. package/dist/document/action-bar.d.ts.map +1 -0
  58. package/dist/document/action-bar.js +39 -0
  59. package/dist/document/cancel-with-reason.d.ts +47 -0
  60. package/dist/document/cancel-with-reason.d.ts.map +1 -0
  61. package/dist/document/cancel-with-reason.js +52 -0
  62. package/dist/document/document-header.d.ts +41 -0
  63. package/dist/document/document-header.d.ts.map +1 -0
  64. package/dist/document/document-header.js +30 -0
  65. package/dist/document/document-page.d.ts +37 -0
  66. package/dist/document/document-page.d.ts.map +1 -0
  67. package/dist/document/document-page.js +137 -0
  68. package/dist/document/document-timeline.d.ts +20 -0
  69. package/dist/document/document-timeline.d.ts.map +1 -0
  70. package/dist/document/document-timeline.js +26 -0
  71. package/dist/document/evaluate.d.ts +31 -0
  72. package/dist/document/evaluate.d.ts.map +1 -0
  73. package/dist/document/evaluate.js +170 -0
  74. package/dist/document/format.d.ts +9 -0
  75. package/dist/document/format.d.ts.map +1 -0
  76. package/dist/document/format.js +52 -0
  77. package/dist/document/index.d.ts +13 -0
  78. package/dist/document/index.d.ts.map +1 -0
  79. package/dist/document/index.js +12 -0
  80. package/dist/document/registry.d.ts +20 -0
  81. package/dist/document/registry.d.ts.map +1 -0
  82. package/dist/document/registry.js +20 -0
  83. package/dist/document/smart-buttons.d.ts +16 -0
  84. package/dist/document/smart-buttons.d.ts.map +1 -0
  85. package/dist/document/smart-buttons.js +16 -0
  86. package/dist/document/status-badge.d.ts +16 -0
  87. package/dist/document/status-badge.d.ts.map +1 -0
  88. package/dist/document/status-badge.js +23 -0
  89. package/dist/document/types.d.ts +196 -0
  90. package/dist/document/types.d.ts.map +1 -0
  91. package/dist/document/types.js +1 -0
  92. package/dist/document/use-document-sources.d.ts +14 -0
  93. package/dist/document/use-document-sources.d.ts.map +1 -0
  94. package/dist/document/use-document-sources.js +72 -0
  95. package/dist/dynamic-columns.d.ts +11 -0
  96. package/dist/dynamic-columns.d.ts.map +1 -1
  97. package/dist/dynamic-columns.js +13 -1
  98. package/dist/dynamic-kanban.d.ts.map +1 -1
  99. package/dist/dynamic-kanban.js +4 -5
  100. package/dist/dynamic-row-actions.d.ts.map +1 -1
  101. package/dist/dynamic-row-actions.js +12 -1
  102. package/dist/index.d.ts +5 -0
  103. package/dist/index.d.ts.map +1 -1
  104. package/dist/index.js +6 -0
  105. package/dist/model-action-toolbar.d.ts.map +1 -1
  106. package/dist/model-action-toolbar.js +16 -3
  107. package/dist/requires-addon.d.ts +37 -0
  108. package/dist/requires-addon.d.ts.map +1 -0
  109. package/dist/requires-addon.js +104 -0
  110. package/dist/server-error.d.ts.map +1 -1
  111. package/dist/server-error.js +19 -0
  112. package/dist/types.d.ts +23 -0
  113. package/dist/types.d.ts.map +1 -1
  114. package/package.json +3 -3
  115. package/src/__tests__/approval-gate.test.tsx +134 -0
  116. package/src/__tests__/requires-addon.test.tsx +201 -0
  117. package/src/approval-events.ts +39 -0
  118. package/src/approval-gate.tsx +180 -0
  119. package/src/approval-pin-dialog.tsx +162 -0
  120. package/src/business/business.test.ts +164 -0
  121. package/src/business/catalog.ts +60 -0
  122. package/src/business/customer-picker.tsx +201 -0
  123. package/src/business/feedback.tsx +141 -0
  124. package/src/business/field-errors.ts +92 -0
  125. package/src/business/format.ts +151 -0
  126. package/src/business/index.ts +13 -0
  127. package/src/business/line-items-editor.tsx +259 -0
  128. package/src/business/line-items.ts +190 -0
  129. package/src/business/payment-capture.tsx +158 -0
  130. package/src/business/payment.ts +125 -0
  131. package/src/business/product-picker.tsx +171 -0
  132. package/src/business/product-search.ts +108 -0
  133. package/src/business/refund-destination.tsx +121 -0
  134. package/src/business/refund.ts +71 -0
  135. package/src/business/use-async-search.ts +43 -0
  136. package/src/document/__tests__/document-page.test.tsx +107 -0
  137. package/src/document/__tests__/evaluate.test.ts +140 -0
  138. package/src/document/action-bar.tsx +140 -0
  139. package/src/document/cancel-with-reason.tsx +214 -0
  140. package/src/document/document-header.tsx +140 -0
  141. package/src/document/document-page.tsx +268 -0
  142. package/src/document/document-timeline.tsx +66 -0
  143. package/src/document/evaluate.ts +180 -0
  144. package/src/document/format.ts +59 -0
  145. package/src/document/index.ts +28 -0
  146. package/src/document/registry.ts +39 -0
  147. package/src/document/smart-buttons.tsx +54 -0
  148. package/src/document/status-badge.tsx +44 -0
  149. package/src/document/types.ts +203 -0
  150. package/src/document/use-document-sources.ts +91 -0
  151. package/src/dynamic-columns.tsx +36 -6
  152. package/src/dynamic-kanban.tsx +6 -7
  153. package/src/dynamic-row-actions.tsx +15 -1
  154. package/src/index.ts +34 -0
  155. package/src/model-action-toolbar.tsx +28 -3
  156. package/src/requires-addon.tsx +160 -0
  157. package/src/server-error.ts +20 -0
  158. package/src/types.ts +24 -0
@@ -0,0 +1,54 @@
1
+ // SmartButtons — accesos con contador al estilo Odoo (Pagos (2) · REP (1) ·
2
+ // Notas de crédito (0)…). Un botón sin registros se ve atenuado, no oculto,
3
+ // salvo que la definición pida `hideWhenEmpty`.
4
+ import { useTranslation } from 'react-i18next'
5
+ import { cn } from '@asteby/metacore-ui/lib'
6
+ import { DynamicIcon } from '../dynamic-icon'
7
+
8
+ export interface SmartButtonItem {
9
+ key: string
10
+ label: string
11
+ icon?: string
12
+ /** undefined = botón sin contador (p. ej. «Pedido origen»). */
13
+ count?: number
14
+ /** Deshabilitado (sin destino / vacío). */
15
+ disabled?: boolean
16
+ onClick?: () => void
17
+ }
18
+
19
+ export interface SmartButtonsProps {
20
+ items: SmartButtonItem[]
21
+ className?: string
22
+ }
23
+
24
+ export function SmartButtons({ items, className }: SmartButtonsProps) {
25
+ const { t } = useTranslation()
26
+ if (items.length === 0) return null
27
+ return (
28
+ <nav aria-label={t('document.related', { defaultValue: 'Relacionados' })} className={cn('flex flex-wrap gap-2', className)} data-smart-buttons=''>
29
+ {items.map((it) => {
30
+ const empty = it.count === 0
31
+ return (
32
+ <button
33
+ key={it.key}
34
+ type='button'
35
+ disabled={it.disabled}
36
+ onClick={it.onClick}
37
+ data-smart-button={it.key}
38
+ className={cn(
39
+ 'inline-flex items-center gap-2 rounded-md border bg-card px-3 py-1.5 text-sm transition-colors',
40
+ 'hover:bg-accent disabled:cursor-default disabled:opacity-50 disabled:hover:bg-card',
41
+ empty && 'text-muted-foreground',
42
+ )}
43
+ >
44
+ {it.icon ? <DynamicIcon name={it.icon} className='h-4 w-4' /> : null}
45
+ <span>{t(it.label, { defaultValue: it.label })}</span>
46
+ {it.count !== undefined ? (
47
+ <span className='rounded-full bg-muted px-1.5 text-xs font-semibold tabular-nums'>{it.count}</span>
48
+ ) : null}
49
+ </button>
50
+ )
51
+ })}
52
+ </nav>
53
+ )
54
+ }
@@ -0,0 +1,44 @@
1
+ // StatusBadge — badge semántico con texto explícito e ícono por tono: el color
2
+ // nunca es la única señal (benchmark §6.3). El catálogo de estados de cada
3
+ // documento vive en su `StatusMachine`; aquí solo se pinta.
4
+ import { useTranslation } from 'react-i18next'
5
+ import { AlertOctagon, AlertTriangle, CheckCircle2, Circle, Clock, Info, type LucideIcon } from 'lucide-react'
6
+ import { Badge } from '@asteby/metacore-ui/primitives'
7
+ import { cn } from '@asteby/metacore-ui/lib'
8
+ import type { StatusDef, StatusTone } from './types'
9
+
10
+ type BadgeVariant = 'muted' | 'info' | 'success' | 'warning' | 'danger'
11
+
12
+ export const STATUS_TONE_STYLE: Record<StatusTone, { variant: BadgeVariant; icon: LucideIcon }> = {
13
+ neutral: { variant: 'muted', icon: Circle },
14
+ info: { variant: 'info', icon: Info },
15
+ success: { variant: 'success', icon: CheckCircle2 },
16
+ caution: { variant: 'warning', icon: Clock },
17
+ warning: { variant: 'warning', icon: AlertTriangle },
18
+ critical: { variant: 'danger', icon: AlertOctagon },
19
+ }
20
+
21
+ export interface StatusBadgeProps {
22
+ status: Pick<StatusDef, 'label' | 'tone' | 'strike'>
23
+ /** Descripción para lectores de pantalla («Estado fiscal»). */
24
+ srLabel?: string
25
+ className?: string
26
+ }
27
+
28
+ export function StatusBadge({ status, srLabel, className }: StatusBadgeProps) {
29
+ const { t } = useTranslation()
30
+ const style = STATUS_TONE_STYLE[status.tone] ?? STATUS_TONE_STYLE.neutral
31
+ const Icon = style.icon
32
+ const text = t(status.label, { defaultValue: status.label })
33
+ return (
34
+ <Badge
35
+ variant={style.variant}
36
+ data-tone={status.tone}
37
+ className={cn(status.strike && 'line-through', className)}
38
+ >
39
+ <Icon aria-hidden />
40
+ {srLabel ? <span className='sr-only'>{srLabel}: </span> : null}
41
+ {text}
42
+ </Badge>
43
+ )
44
+ }
@@ -0,0 +1,203 @@
1
+ // Patrón «página de documento» (benchmark §5.2 / §6 / §7): cabecera con badges,
2
+ // barra de acciones por estado, botones inteligentes con contador, pestañas e
3
+ // historial. Todo se describe con un `DocumentSpec` de datos puros (sin
4
+ // funciones) para que cualquier modelo lo declare y, más adelante, un manifest
5
+ // pueda proyectarlo sin cambiar los componentes.
6
+ import type { ReactNode } from 'react'
7
+
8
+ /** Tono semántico de un estado. El color nunca es la única señal: cada tono trae ícono y texto. */
9
+ export type StatusTone = 'neutral' | 'info' | 'success' | 'caution' | 'warning' | 'critical'
10
+
11
+ /** Un estado del catálogo de un documento. */
12
+ export interface StatusDef {
13
+ value: string
14
+ /** Texto explícito (o clave i18n; se traduce con `t`). */
15
+ label: string
16
+ tone: StatusTone
17
+ /** Texto tachado (Cancelada, Anulada): conserva el rastro sin alarmar. */
18
+ strike?: boolean
19
+ }
20
+
21
+ /** Una máquina de estados con su propio badge (p. ej. fiscal y cobro, independientes). */
22
+ export interface StatusMachine {
23
+ id: string
24
+ /** Etiqueta accesible del badge («Estado fiscal»). */
25
+ label?: string
26
+ /** Ruta del valor en el contexto de evaluación (`record.x`, `$.x`). */
27
+ field: string
28
+ states: StatusDef[]
29
+ /** Estado a mostrar cuando el valor no está en el catálogo. */
30
+ fallback?: StatusDef
31
+ }
32
+
33
+ export type PredicateOp = 'eq' | 'neq' | 'in' | 'not_in' | 'truthy' | 'falsy' | 'gt' | 'gte' | 'lt' | 'lte'
34
+
35
+ /** Condición sobre una ruta del contexto. Un arreglo de predicados es un AND. */
36
+ export interface Predicate {
37
+ field: string
38
+ op: PredicateOp
39
+ value?: unknown
40
+ }
41
+ export type When = Predicate | Predicate[]
42
+
43
+ /** Regla de bloqueo: si `when` se cumple, la acción se ve deshabilitada y `reason` explica qué hacer antes. */
44
+ export interface BlockRule {
45
+ when: When
46
+ reason: string
47
+ }
48
+
49
+ /** Un botón del documento (acción). `key` enlaza con `ActionDefinition.key` del modelo si existe. */
50
+ export interface DocumentActionDef {
51
+ key: string
52
+ label: string
53
+ /** Nombre de ícono lucide. */
54
+ icon?: string
55
+ /** Acción del modelo (metadata) que se dispara; por defecto `key`. */
56
+ modelAction?: string
57
+ /**
58
+ * Acciones que resuelve la propia página sin acción del modelo (las plantillas
59
+ * interpolan `{{campo}}` / `{{$.derivado}}`):
60
+ * - `href`: navega dentro de la app (`onNavigate` del host).
61
+ * - `openUrl`: abre una URL en otra pestaña (descargar PDF/XML).
62
+ * - `tab`: selecciona una pestaña (ver detalle del error, ver pagos).
63
+ */
64
+ href?: string
65
+ openUrl?: string
66
+ tab?: string
67
+ /** Estilo crítico: siempre va en la zona destructiva. */
68
+ destructive?: boolean
69
+ /** Se muestra deshabilitada con el motivo en lugar de desaparecer. */
70
+ blockedWhen?: BlockRule | BlockRule[]
71
+ /** Se oculta por completo cuando se cumple (p. ej. sin permiso o sin conector). */
72
+ hiddenWhen?: When
73
+ }
74
+
75
+ /** Distribución de acciones para un estado (§5.4): 1 primaria, 2–3 secundarias, resto en «Más…». */
76
+ export interface ActionLayout {
77
+ primary?: string
78
+ secondary?: string[]
79
+ more?: string[]
80
+ destructive?: string[]
81
+ }
82
+
83
+ /** Regla: si `when` se cumple, se usa `layout`. Gana la primera que coincide. */
84
+ export interface ActionLayoutRule {
85
+ when: When
86
+ layout: ActionLayout
87
+ }
88
+
89
+ /** Botón inteligente con contador (estilo Odoo): navega a lo relacionado. */
90
+ export interface SmartButtonDef {
91
+ key: string
92
+ label: string
93
+ icon?: string
94
+ /** Fuente (`DocumentSourceDef.key`) cuyo total alimenta el contador; sin ella no hay contador. */
95
+ source?: string
96
+ /** Ruta de un valor del registro cuyo truthy activa el botón sin fuente (p. ej. «Pedido origen»). */
97
+ field?: string
98
+ /** Modelo destino para navegar a la lista filtrada. */
99
+ href?: string
100
+ /** No se muestra cuando el contador es 0 (por defecto se muestra deshabilitado). */
101
+ hideWhenEmpty?: boolean
102
+ /** Selecciona esta pestaña al hacer clic. */
103
+ tab?: string
104
+ }
105
+
106
+ /** Consulta a un modelo relacionado con el registro (`{{id}}` se interpola). */
107
+ export interface DocumentSourceDef {
108
+ key: string
109
+ model: string
110
+ /** Columna del modelo relacionado que apunta al registro. */
111
+ foreignKey: string
112
+ /** Filtros estáticos extra (`f_<col>=eq:<val>`). */
113
+ where?: Record<string, string>
114
+ limit?: number
115
+ /**
116
+ * Relación de dos saltos: en vez de filtrar por el id del registro, filtra
117
+ * `foreignKey` con los valores de `field` de las filas de otra fuente ya
118
+ * cargada (`via.source`). Ej.: los REP de una factura cuelgan del pago
119
+ * (`source_id = payment_id`), y los pagos salen de las aplicaciones.
120
+ */
121
+ via?: { source: string; field: string }
122
+ }
123
+
124
+ export interface DocumentFieldDef {
125
+ label: string
126
+ /** Ruta en el contexto. */
127
+ path: string
128
+ format?: 'text' | 'money' | 'date' | 'datetime' | 'mono' | 'percent' | 'boolean'
129
+ /** Ancho completo de la rejilla. */
130
+ wide?: boolean
131
+ /** Oculta el campo cuando el valor está vacío. */
132
+ hideEmpty?: boolean
133
+ }
134
+
135
+ export interface DocumentFieldGroup {
136
+ title?: string
137
+ fields: DocumentFieldDef[]
138
+ }
139
+
140
+ export interface DocumentColumnDef {
141
+ label: string
142
+ path: string
143
+ format?: DocumentFieldDef['format']
144
+ /** Catálogo de estados (id de máquina registrada) para pintar un badge. */
145
+ status?: string
146
+ }
147
+
148
+ export type DocumentTabDef =
149
+ | { key: string; label: string; kind: 'fields'; groups: DocumentFieldGroup[] }
150
+ | { key: string; label: string; kind: 'records'; source: string; columns: DocumentColumnDef[]; empty?: string }
151
+ | { key: string; label: string; kind: 'slot'; slot: string; count?: string }
152
+
153
+ /** Métrica del encabezado (total, saldo, fechas…). */
154
+ export interface DocumentMetricDef extends DocumentFieldDef {
155
+ tone?: 'default' | 'critical'
156
+ }
157
+
158
+ export interface DocumentSpec {
159
+ /** Clave del modelo (`invoices`). */
160
+ model: string
161
+ /** Rutas candidatas para el título, en orden; `{{a}}{{b}}` se interpola. */
162
+ title: string
163
+ /** Texto bajo el título (cliente…). */
164
+ subtitle?: DocumentFieldDef
165
+ /** Campo que se copia con botón (UUID). */
166
+ copyable?: DocumentFieldDef
167
+ /** Badges independientes (fiscal, cobro…). */
168
+ statuses: StatusMachine[]
169
+ /** Catálogos extra que solo pintan celdas de pestañas (`column.status`), sin badge en la cabecera. */
170
+ catalogs?: StatusMachine[]
171
+ metrics?: DocumentMetricDef[]
172
+ actions: DocumentActionDef[]
173
+ layouts: ActionLayoutRule[]
174
+ /** Layout si ninguna regla coincide. */
175
+ defaultLayout?: ActionLayout
176
+ smartButtons?: SmartButtonDef[]
177
+ sources?: DocumentSourceDef[]
178
+ tabs: DocumentTabDef[]
179
+ }
180
+
181
+ /** Contexto de evaluación: registro, fuentes cargadas y derivados. */
182
+ export interface DocumentContext {
183
+ record: Record<string, unknown>
184
+ /** Filas por fuente (`sources[key]`). */
185
+ sources: Record<string, Record<string, unknown>[]>
186
+ /** Derivados (`$`). */
187
+ derived: Record<string, unknown>
188
+ }
189
+
190
+ export interface ResolvedAction {
191
+ def: DocumentActionDef
192
+ /** Motivo cuando la acción está bloqueada. */
193
+ blockedReason?: string
194
+ }
195
+
196
+ export interface ResolvedActionLayout {
197
+ primary?: ResolvedAction
198
+ secondary: ResolvedAction[]
199
+ more: ResolvedAction[]
200
+ destructive: ResolvedAction[]
201
+ }
202
+
203
+ export type DocumentSlotRenderer = (ctx: DocumentContext) => ReactNode
@@ -0,0 +1,91 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import { useApi } from '../api-context'
3
+ import { buildRelationFilterParams } from '../dynamic-relation-helpers'
4
+ import type { DocumentSourceDef } from './types'
5
+
6
+ const DEFAULT_LIMIT = 50
7
+ // El kernel ≥ 0.139.2 acepta listas `in:` largas; se parte para no exceder la URL.
8
+ const VIA_CHUNK = 40
9
+
10
+ type Rows = Record<string, unknown>[]
11
+
12
+ export interface DocumentSourcesState {
13
+ sources: Record<string, Record<string, unknown>[]>
14
+ loading: boolean
15
+ /** Vuelve a pedir todas las fuentes (tras ejecutar una acción). */
16
+ reload: () => void
17
+ }
18
+
19
+ /**
20
+ * Carga las filas relacionadas con el documento (pagos, REP, notas de crédito…)
21
+ * que alimentan contadores, pestañas y derivados. Un fallo en una fuente deja
22
+ * esa fuente vacía: la página sigue siendo usable.
23
+ */
24
+ export function useDocumentSources(
25
+ defs: DocumentSourceDef[] | undefined,
26
+ recordId: string | number | undefined,
27
+ ): DocumentSourcesState {
28
+ const api = useApi()
29
+ const [sources, setSources] = useState<Record<string, Record<string, unknown>[]>>({})
30
+ const [loading, setLoading] = useState(false)
31
+ const [nonce, setNonce] = useState(0)
32
+ const reload = useCallback(() => setNonce((n) => n + 1), [])
33
+
34
+ useEffect(() => {
35
+ if (!defs || defs.length === 0 || recordId === undefined || recordId === null) {
36
+ setSources({})
37
+ return
38
+ }
39
+ let cancelled = false
40
+ setLoading(true)
41
+ const fetchRows = async (d: DocumentSourceDef, params: Record<string, string>): Promise<Rows> => {
42
+ try {
43
+ const res = await api.get(`/data/${d.model}`, {
44
+ params: { ...params, page: 1, per_page: d.limit ?? DEFAULT_LIMIT },
45
+ })
46
+ const body = res.data
47
+ const rows = (body?.data ?? body) as unknown
48
+ return Array.isArray(rows) ? (rows as Rows) : []
49
+ } catch {
50
+ return []
51
+ }
52
+ }
53
+ const direct = defs.filter((d) => !d.via)
54
+ const hopped = defs.filter((d) => d.via)
55
+ ;(async () => {
56
+ const out: Record<string, Rows> = {}
57
+ await Promise.all(
58
+ direct.map(async (d) => {
59
+ out[d.key] = await fetchRows(d, buildRelationFilterParams(d.foreignKey, recordId, d.where))
60
+ }),
61
+ )
62
+ // Segundo salto: depende de las filas del primero.
63
+ for (const d of hopped) {
64
+ const ids = Array.from(
65
+ new Set((out[d.via!.source] ?? []).map((r) => r[d.via!.field]).filter((v) => v != null && v !== '').map(String)),
66
+ )
67
+ const chunks: string[][] = []
68
+ for (let i = 0; i < ids.length; i += VIA_CHUNK) chunks.push(ids.slice(i, i + VIA_CHUNK))
69
+ const parts = await Promise.all(
70
+ chunks.map((c) => {
71
+ const params: Record<string, string> = { [`f_${d.foreignKey}`]: `in:${c.join(',')}` }
72
+ for (const [k, v] of Object.entries(d.where ?? {})) params[`f_${k}`] = `eq:${v}`
73
+ return fetchRows(d, params)
74
+ }),
75
+ )
76
+ out[d.key] = parts.flat()
77
+ }
78
+ return out
79
+ })()
80
+ .then((out) => {
81
+ if (cancelled) return
82
+ setSources(out)
83
+ setLoading(false)
84
+ })
85
+ return () => {
86
+ cancelled = true
87
+ }
88
+ }, [api, defs, recordId, nonce])
89
+
90
+ return { sources, loading, reload }
91
+ }
@@ -57,6 +57,7 @@ import { DynamicIcon, isLucideIconName } from './dynamic-icon'
57
57
  import { CollectionCell } from './collection-cell'
58
58
  import { isNilUuid, normalizeNilUuid } from './nil-uuid'
59
59
  import { useOptionsResolver } from './use-options-resolver'
60
+ import { RequiresAddonLock, resolveRequiresAddon, useRequiresAddonLabel } from './requires-addon'
60
61
  import type { TableMetadata, ColumnDefinition } from './types'
61
62
  import { isColumnVisibleInTable } from './column-visibility'
62
63
  import type {
@@ -207,6 +208,36 @@ const CodeCell: React.FC<{ text: string; maxLength?: number }> = ({ text, maxLen
207
208
  )
208
209
  }
209
210
 
211
+ /**
212
+ * One entry of the row "…" menu. An action gated by a missing optional addon
213
+ * (`requiresAddon`) stays listed with a lock + tooltip; the click still goes to
214
+ * `onAction`, whose shared handler (useDynamicRowActions) opens the
215
+ * requires-addon dialog instead of executing.
216
+ */
217
+ export function RowActionMenuItem({
218
+ action,
219
+ label,
220
+ onSelect,
221
+ }: {
222
+ action: any
223
+ label: React.ReactNode
224
+ onSelect: (e: React.MouseEvent) => void
225
+ }) {
226
+ const requirement = resolveRequiresAddon(action)
227
+ const requiresAddonLabel = useRequiresAddonLabel()
228
+ return (
229
+ <DropdownMenuItem
230
+ onClick={onSelect}
231
+ title={requirement ? requiresAddonLabel(requirement) : undefined}
232
+ data-requires-addon={requirement?.key}
233
+ >
234
+ <DynamicIcon name={action.icon || 'Zap'} className="mr-2 h-4 w-4" />
235
+ {label}
236
+ {requirement && <RequiresAddonLock requirement={requirement} />}
237
+ </DropdownMenuItem>
238
+ )
239
+ }
240
+
210
241
  /**
211
242
  * Lifecycle column used by `requiresState`: prefer `status` (workshop, vehicles,
212
243
  * …) and fall back to `state` (purchases, inventory transfers, …). Empty string
@@ -1528,13 +1559,12 @@ export function makeDefaultGetDynamicColumns(
1528
1559
  {resolvedActions
1529
1560
  .filter((action) => isRowActionVisible(action, row.original))
1530
1561
  .map((action) => (
1531
- <DropdownMenuItem
1562
+ <RowActionMenuItem
1532
1563
  key={action.key}
1533
- onClick={() => onAction && onAction(action.key, row.original)}
1534
- >
1535
- <DynamicIcon name={action.icon} className="mr-2 h-4 w-4" />
1536
- {translateMetadataLabel(action.label, t)}
1537
- </DropdownMenuItem>
1564
+ action={action}
1565
+ label={translateMetadataLabel(action.label, t)}
1566
+ onSelect={() => onAction && onAction(action.key, row.original)}
1567
+ />
1538
1568
  ))}
1539
1569
  </DropdownMenuContent>
1540
1570
  </DropdownMenu>
@@ -96,7 +96,6 @@ import {
96
96
  CardContent,
97
97
  DropdownMenu,
98
98
  DropdownMenuContent,
99
- DropdownMenuItem,
100
99
  DropdownMenuTrigger,
101
100
  Input,
102
101
  Popover,
@@ -161,6 +160,7 @@ import {
161
160
  formatAggregateTotal,
162
161
  isRowActionVisible,
163
162
  relationKeyFor,
163
+ RowActionMenuItem,
164
164
  } from './dynamic-columns'
165
165
  import { useCan, usePermissionsActive, resolveRowActions } from './permissions-context'
166
166
  import { useDynamicRowActions } from './dynamic-row-actions'
@@ -3113,16 +3113,15 @@ function KanbanCard({
3113
3113
  </DropdownMenuTrigger>
3114
3114
  <DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
3115
3115
  {visibleActions.map((a) => (
3116
- <DropdownMenuItem
3116
+ <RowActionMenuItem
3117
3117
  key={a.key}
3118
- onClick={(e) => {
3118
+ action={a}
3119
+ label={a.label}
3120
+ onSelect={(e) => {
3119
3121
  e.stopPropagation()
3120
3122
  onAction(a.key, card)
3121
3123
  }}
3122
- >
3123
- <DynamicIcon name={a.icon || 'Zap'} className="mr-2 h-4 w-4" />
3124
- {a.label}
3125
- </DropdownMenuItem>
3124
+ />
3126
3125
  ))}
3127
3126
  </DropdownMenuContent>
3128
3127
  </DropdownMenu>
@@ -11,6 +11,8 @@
11
11
  // view / edit → host `onAction(action, row)` when provided (string
12
12
  // contract), else the built-in record dialog.
13
13
  // link action → navigate to the action's templated `linkUrl`.
14
+ // requiresAddon → opens the "requires addon" dialog; the action is
15
+ // never dispatched (no modal, no backend call).
14
16
  // custom (modal/fields/
15
17
  // confirm/executable) → opens the ActionModal via the dispatcher.
16
18
  // anything else → host `onAction` + refresh (or just refresh).
@@ -34,7 +36,8 @@ import {
34
36
  import { useApi } from './api-context'
35
37
  import { ActionModalDispatcher } from './action-modal-dispatcher'
36
38
  import { DynamicRecordDialog } from './dialogs/dynamic-record'
37
- import type { TableMetadata, ActionMetadata } from './types'
39
+ import { RequiresAddonDialog, resolveRequiresAddon } from './requires-addon'
40
+ import type { TableMetadata, ActionMetadata, RequiresAddon } from './types'
38
41
 
39
42
  export interface UseDynamicRowActionsParams {
40
43
  /** Model key as registered on the backend (e.g. "issue"). */
@@ -106,7 +109,13 @@ export function useDynamicRowActions({
106
109
  record: any | null
107
110
  }>({ open: false, action: null, record: null })
108
111
 
112
+ const [missingAddon, setMissingAddon] = useState<RequiresAddon | null>(null)
113
+
109
114
  const handleInternalAction = useCallback(async (action: string, row: any) => {
115
+ // A missing optional addon gates the action BEFORE any other routing:
116
+ // it must not reach the dispatcher, the host, or the backend.
117
+ const requirement = resolveRequiresAddon(metadata?.actions?.find((a) => a.key === action))
118
+ if (requirement) { setMissingAddon(requirement); return }
110
119
  if (action === 'delete') { setRowToDelete(row); return }
111
120
  if (action === 'view' || action === 'edit') {
112
121
  if (onAction) await Promise.resolve(onAction(action, row))
@@ -200,6 +209,11 @@ export function useDynamicRowActions({
200
209
  onSuccess={onRefresh}
201
210
  />
202
211
  )}
212
+
213
+ <RequiresAddonDialog
214
+ requirement={missingAddon}
215
+ onOpenChange={(open: boolean) => !open && setMissingAddon(null)}
216
+ />
203
217
  </>
204
218
  )
205
219
 
package/src/index.ts CHANGED
@@ -218,11 +218,41 @@ export {
218
218
  type CanFn,
219
219
  type PermissionsProviderProps,
220
220
  } from './permissions-context'
221
+ export {
222
+ ApprovalPinDialog,
223
+ approvalErrorMessage,
224
+ type ApprovalPinDialogProps,
225
+ type ApprovalPinSubmit,
226
+ } from './approval-pin-dialog'
227
+ export {
228
+ ApprovalGateProvider,
229
+ useApprovalGate,
230
+ approvalCapability,
231
+ approvalRequiredInfo,
232
+ approvalGateAvailable,
233
+ APPROVAL_PARKED_EVENT,
234
+ APPROVAL_DECIDED_EVENT,
235
+ type ApprovalGateValue,
236
+ type ApprovalGrant,
237
+ type ApprovalRequiredInfo,
238
+ type RequestApprovalOptions,
239
+ } from './approval-gate'
221
240
  export {
222
241
  useDynamicRowActions,
223
242
  type UseDynamicRowActionsParams,
224
243
  type DynamicRowActions,
225
244
  } from './dynamic-row-actions'
245
+ export {
246
+ resolveRequiresAddon,
247
+ setAddonInstallHandler,
248
+ getAddonInstallHandler,
249
+ requiresAddonName,
250
+ useRequiresAddonLabel,
251
+ RequiresAddonLock,
252
+ RequiresAddonDialog,
253
+ type RequiresAddon,
254
+ type RequiresAddonDialogProps,
255
+ } from './requires-addon'
226
256
  export { ColorPickerField, DEFAULT_ROLE_COLOR, normalizeHex } from './color-picker-field'
227
257
  export {
228
258
  PermissionsManager,
@@ -271,6 +301,7 @@ export {
271
301
  type SidebarLayoutManagerProps,
272
302
  } from './sidebar-layout-manager'
273
303
  export * from './org-runtime-context'
304
+ export * from './business'
274
305
  export * from './org-runtime-provider'
275
306
  export * from './navigation-builder'
276
307
  export * from './i18n-provider'
@@ -570,3 +601,6 @@ export {
570
601
  type AttributeClass,
571
602
  type AttributeClassSection,
572
603
  } from './attribute-classes'
604
+
605
+ // Página de documento (benchmark §5/§6): cabecera, badges, acciones por estado, botones inteligentes.
606
+ export * from './document'
@@ -25,7 +25,13 @@ import { useMetadataCache } from './metadata-cache'
25
25
  import { DynamicIcon } from './dynamic-icon'
26
26
  import { ActionModalDispatcher } from './action-modal-dispatcher'
27
27
  import { useCan, modelCapability } from './permissions-context'
28
- import type { ActionDefinition, ActionMetadata, TableMetadata } from './types'
28
+ import {
29
+ RequiresAddonDialog,
30
+ RequiresAddonLock,
31
+ resolveRequiresAddon,
32
+ useRequiresAddonLabel,
33
+ } from './requires-addon'
34
+ import type { ActionDefinition, ActionMetadata, RequiresAddon, TableMetadata } from './types'
29
35
 
30
36
  export type ActionPlacement = 'row' | 'table' | 'create'
31
37
 
@@ -144,6 +150,8 @@ export function ModelActionToolbar({
144
150
  [all, can, model],
145
151
  )
146
152
  const [active, setActive] = useState<ActionMetadata | null>(null)
153
+ const [missingAddon, setMissingAddon] = useState<RequiresAddon | null>(null)
154
+ const requiresAddonLabel = useRequiresAddonLabel()
147
155
  const dataEndpoint = endpoint ?? `/data/${model}/me`
148
156
 
149
157
  if (surfaced.length === 0) return null
@@ -153,6 +161,9 @@ export function ModelActionToolbar({
153
161
  <div className={className ?? 'flex items-center gap-2'}>
154
162
  {surfaced.map((a) => {
155
163
  const isCreate = (a.placement ?? 'row') === 'create'
164
+ // Locked while its optional addon is missing: still shown,
165
+ // but a click explains + offers install instead of running.
166
+ const requirement = resolveRequiresAddon(a)
156
167
  return (
157
168
  <Button
158
169
  key={a.key}
@@ -162,12 +173,18 @@ export function ModelActionToolbar({
162
173
  ? `model.${model}.create`
163
174
  : `model.${model}.action.${a.key}`
164
175
  }
176
+ data-requires-addon={requirement?.key}
177
+ title={requirement ? requiresAddonLabel(requirement) : undefined}
165
178
  onClick={() => {
179
+ if (requirement) {
180
+ setMissingAddon(requirement)
181
+ return
182
+ }
166
183
  onActionIntent?.(a)
167
184
  setActive(toActionMetadata(a))
168
185
  }}
169
- onMouseEnter={() => onActionIntent?.(a)}
170
- onFocus={() => onActionIntent?.(a)}
186
+ onMouseEnter={() => !requirement && onActionIntent?.(a)}
187
+ onFocus={() => !requirement && onActionIntent?.(a)}
171
188
  style={a.color && !isCreate ? { borderColor: a.color, color: a.color } : undefined}
172
189
  >
173
190
  <DynamicIcon name={a.icon || (isCreate ? 'Plus' : 'Zap')} className="mr-2 h-4 w-4" />
@@ -179,6 +196,7 @@ export function ModelActionToolbar({
179
196
  already-localized label untouched; humanizeLastSegment avoids
180
197
  flashing the full dotted key when the bundle is still in flight. */}
181
198
  {t(a.label, { defaultValue: humanizeActionLabel(a.label) })}
199
+ {requirement && <RequiresAddonLock requirement={requirement} />}
182
200
  </Button>
183
201
  )
184
202
  })}
@@ -200,6 +218,13 @@ export function ModelActionToolbar({
200
218
  }}
201
219
  />
202
220
  )}
221
+
222
+ <RequiresAddonDialog
223
+ requirement={missingAddon}
224
+ onOpenChange={(open) => {
225
+ if (!open) setMissingAddon(null)
226
+ }}
227
+ />
203
228
  </>
204
229
  )
205
230
  }