@asteby/metacore-runtime-react 48.0.0 → 49.1.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 (97) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/action-modal-dispatcher.d.ts +33 -3
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +84 -11
  5. package/dist/action-priority.d.ts +34 -0
  6. package/dist/action-priority.d.ts.map +1 -0
  7. package/dist/action-priority.js +60 -0
  8. package/dist/business/catalog-product-search.d.ts +7 -0
  9. package/dist/business/catalog-product-search.d.ts.map +1 -1
  10. package/dist/business/catalog-product-search.js +14 -0
  11. package/dist/business/document-editor-model.d.ts +14 -2
  12. package/dist/business/document-editor-model.d.ts.map +1 -1
  13. package/dist/business/document-editor-model.js +42 -4
  14. package/dist/business/document-editor.d.ts.map +1 -1
  15. package/dist/business/document-editor.js +68 -19
  16. package/dist/business/line-items.d.ts +9 -1
  17. package/dist/business/line-items.d.ts.map +1 -1
  18. package/dist/business/line-items.js +8 -1
  19. package/dist/calendar-date.d.ts +12 -0
  20. package/dist/calendar-date.d.ts.map +1 -1
  21. package/dist/calendar-date.js +36 -0
  22. package/dist/document/document-page.d.ts.map +1 -1
  23. package/dist/document/document-page.js +8 -1
  24. package/dist/document/evaluate.d.ts.map +1 -1
  25. package/dist/document/evaluate.js +17 -1
  26. package/dist/document/index.d.ts +1 -0
  27. package/dist/document/index.d.ts.map +1 -1
  28. package/dist/document/index.js +1 -0
  29. package/dist/document/secondary-bar.d.ts +20 -0
  30. package/dist/document/secondary-bar.d.ts.map +1 -0
  31. package/dist/document/secondary-bar.js +44 -0
  32. package/dist/document/types.d.ts +8 -0
  33. package/dist/document/types.d.ts.map +1 -1
  34. package/dist/document-form-dialog.d.ts +23 -2
  35. package/dist/document-form-dialog.d.ts.map +1 -1
  36. package/dist/document-form-dialog.js +48 -9
  37. package/dist/dynamic-columns.d.ts +1 -11
  38. package/dist/dynamic-columns.d.ts.map +1 -1
  39. package/dist/dynamic-columns.js +7 -17
  40. package/dist/dynamic-crud-page.d.ts +19 -0
  41. package/dist/dynamic-crud-page.d.ts.map +1 -1
  42. package/dist/dynamic-crud-page.js +6 -5
  43. package/dist/dynamic-row-actions.d.ts +8 -0
  44. package/dist/dynamic-row-actions.d.ts.map +1 -1
  45. package/dist/dynamic-row-actions.js +12 -2
  46. package/dist/dynamic-table.d.ts.map +1 -1
  47. package/dist/dynamic-table.js +64 -63
  48. package/dist/index.d.ts +5 -2
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +9 -2
  51. package/dist/primitives/contributions.d.ts +14 -3
  52. package/dist/primitives/contributions.d.ts.map +1 -1
  53. package/dist/primitives/contributions.js +50 -13
  54. package/dist/record-actions.d.ts +21 -0
  55. package/dist/record-actions.d.ts.map +1 -0
  56. package/dist/record-actions.js +76 -0
  57. package/dist/row-actions-menu.d.ts +32 -0
  58. package/dist/row-actions-menu.d.ts.map +1 -0
  59. package/dist/row-actions-menu.js +59 -0
  60. package/dist/slot.d.ts +4 -21
  61. package/dist/slot.d.ts.map +1 -1
  62. package/dist/slot.js +14 -36
  63. package/dist/types.d.ts +39 -0
  64. package/dist/types.d.ts.map +1 -1
  65. package/package.json +3 -3
  66. package/src/__tests__/document-editor-model.test.ts +55 -1
  67. package/src/__tests__/document-editor.test.tsx +80 -1
  68. package/src/__tests__/document-form-create-model.test.tsx +143 -0
  69. package/src/__tests__/document-form-dialog.test.tsx +40 -0
  70. package/src/__tests__/field-default-today.test.tsx +116 -0
  71. package/src/__tests__/prefill-scalar-from-record.test.ts +52 -1
  72. package/src/__tests__/row-action-link-navigation.test.ts +17 -0
  73. package/src/__tests__/secondary-actions.test.tsx +261 -0
  74. package/src/action-modal-dispatcher.tsx +97 -17
  75. package/src/action-priority.ts +82 -0
  76. package/src/business/business.test.ts +1 -1
  77. package/src/business/catalog-product-search.ts +18 -0
  78. package/src/business/document-editor-model.ts +48 -6
  79. package/src/business/document-editor.tsx +75 -20
  80. package/src/business/line-items.ts +16 -1
  81. package/src/calendar-date.ts +36 -0
  82. package/src/document/document-page.tsx +15 -0
  83. package/src/document/evaluate.ts +17 -1
  84. package/src/document/index.ts +1 -0
  85. package/src/document/secondary-bar.tsx +92 -0
  86. package/src/document/types.ts +8 -0
  87. package/src/document-form-dialog.tsx +67 -8
  88. package/src/dynamic-columns.tsx +13 -57
  89. package/src/dynamic-crud-page.tsx +31 -4
  90. package/src/dynamic-row-actions.tsx +12 -2
  91. package/src/dynamic-table.tsx +3 -0
  92. package/src/index.ts +31 -0
  93. package/src/primitives/contributions.ts +59 -12
  94. package/src/record-actions.ts +101 -0
  95. package/src/row-actions-menu.tsx +159 -0
  96. package/src/slot.tsx +18 -53
  97. package/src/types.ts +35 -0
@@ -0,0 +1,261 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Acciones secundarias de un registro (P2 UX Pitsline): una sola primaria
4
+ // destacada; compartir / imprimir / correo / chat van al «Más…» de la fila y al
5
+ // pie del documento; lo que aporta un addon (registerRecordAction) sólo aparece
6
+ // con su proveedor instalado y activo. Y la infraestructura de registro es una
7
+ // sola: lo que un addon registra por AddonAPI (modal, slot) llega a los mismos
8
+ // lectores que registerFederatedModal / <Slot>.
9
+ import { afterEach, describe, expect, it, vi } from 'vitest'
10
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
11
+
12
+ const I18N_T = (_k: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? _k
13
+ const USE_TRANSLATION = { t: I18N_T, i18n: { language: 'es' } }
14
+ vi.mock('react-i18next', () => ({ useTranslation: () => USE_TRANSLATION }))
15
+
16
+ import { Registry, registerRecordAction, type ActionMetadata } from '@asteby/metacore-sdk'
17
+ import { InstalledAddonsProvider } from '../installed-addons-context'
18
+ import {
19
+ classifyActionPriority,
20
+ isActionProviderActive,
21
+ resolveRecordActions,
22
+ splitActionsByPriority,
23
+ } from '../record-actions'
24
+ import { RowActionsMenu } from '../row-actions-menu'
25
+ import { DocumentSecondaryBar } from '../document/secondary-bar'
26
+ import { resolveActions } from '../document/evaluate'
27
+ import type { DocumentSpec } from '../document/types'
28
+ import { Slot } from '../slot'
29
+ import { ActionModalDispatcher } from '../action-modal-dispatcher'
30
+
31
+ const disposers: Array<() => void> = []
32
+ afterEach(() => {
33
+ cleanup()
34
+ while (disposers.length) disposers.pop()!()
35
+ })
36
+
37
+ const ROW = { id: 'inv-1', folio: 'A-1' }
38
+ const ACTIONS = [
39
+ { key: 'stamp', label: 'Timbrar', icon: 'Stamp', priority: 'primary' },
40
+ { key: 'cancel', label: 'Cancelar', icon: 'X' },
41
+ { key: 'send_cfdi_email', label: 'Enviar por correo', icon: 'Mail' },
42
+ { key: 'reprint_cfdi', label: 'Reimprimir', icon: 'Printer' },
43
+ { key: 'approve', label: 'Aprobar', icon: 'Check', priority: 'primary' },
44
+ ]
45
+
46
+ function shareToChat() {
47
+ const run = vi.fn()
48
+ disposers.push(
49
+ registerRecordAction(
50
+ { id: 'team_chat.share', label: 'Enviar al chat', icon: 'MessageSquareShare', requires: { addon: 'link' }, run },
51
+ 'host',
52
+ ),
53
+ )
54
+ return run
55
+ }
56
+
57
+ async function openMenu() {
58
+ const trigger = screen.getByText('Abrir menú').closest('button')!
59
+ await act(async () => {
60
+ fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' })
61
+ })
62
+ }
63
+
64
+ describe('clasificación primaria / secundaria', () => {
65
+ it('convención por segmento de clave; `priority` explícito manda', () => {
66
+ expect(classifyActionPriority({ key: 'send_cfdi_email' })).toBe('secondary')
67
+ expect(classifyActionPriority({ key: 'download_acuse' })).toBe('secondary')
68
+ expect(classifyActionPriority({ key: 'send_whatsapp' })).toBe('secondary')
69
+ expect(classifyActionPriority({ key: 'send' })).toBeUndefined() // enviar cotización = flujo principal
70
+ expect(classifyActionPriority({ key: 'create_mailbox' })).toBeUndefined()
71
+ expect(classifyActionPriority({ key: 'delete' })).toBeUndefined()
72
+ expect(classifyActionPriority({ key: 'print_label', priority: 'primary' })).toBe('primary')
73
+ })
74
+
75
+ it('una sola primaria: la primera que la reclama; las demás quedan como principales', () => {
76
+ const { primary, main, secondary } = splitActionsByPriority(ACTIONS)
77
+ expect(primary?.key).toBe('stamp')
78
+ expect(main.map((a) => a.key)).toEqual(['cancel', 'approve'])
79
+ expect(secondary.map((a) => a.key)).toEqual(['send_cfdi_email', 'reprint_cfdi'])
80
+ })
81
+
82
+ it('acción por capacidad sin proveedor activo no se ofrece', () => {
83
+ const a = { key: 'send_whatsapp', trigger: { type: 'capability', capability: 'messaging.whatsapp.send' } }
84
+ expect(isActionProviderActive(a, null)).toBe(true)
85
+ expect(isActionProviderActive(a, { addons: new Set(), capabilities: new Set() })).toBe(false)
86
+ expect(isActionProviderActive(a, { addons: new Set(['link']), capabilities: new Set(['messaging.whatsapp.send']) })).toBe(true)
87
+ })
88
+ })
89
+
90
+ describe('resolveRecordActions (registro de acciones aportadas)', () => {
91
+ const installed = (addons: string[], caps: string[] = []) => ({ addons: new Set(addons), capabilities: new Set(caps) })
92
+ const row = (c: Parameters<typeof registerRecordAction>[0], owner?: string) => ({ contribution: c, owner })
93
+
94
+ it('por addon, por capacidad o por dueño; modelos y expand', () => {
95
+ const rows = [
96
+ row({ id: 'a.chat', label: 'Chat', requires: { addon: 'link' }, run: () => {} }, 'host'),
97
+ row({ id: 'b.wa', label: 'WhatsApp', requires: { capability: 'messaging.whatsapp.send' }, run: () => {} }, 'host'),
98
+ row({ id: 'c.own', label: 'Propia', run: () => {} }, 'fiscal_mexico'),
99
+ row({ id: 'd.only', label: 'Sólo facturas', models: ['Invoice'], run: () => {} }),
100
+ row({ id: 'e.pdf', expand: () => [{ key: 'ticket', label: 'Ticket', run: () => {} }] }),
101
+ ]
102
+ const keys = (inst: ReturnType<typeof installed> | null, model = 'Sale') =>
103
+ resolveRecordActions(rows, { model, record: ROW }, inst).map((r) => r.key)
104
+ expect(keys(installed([]))).toEqual(['e.pdf:ticket'])
105
+ expect(keys(installed(['link', 'fiscal_mexico'], ['messaging.whatsapp.send']))).toEqual(['a.chat', 'b.wa', 'c.own', 'e.pdf:ticket'])
106
+ expect(keys(installed([]), 'customers.Invoice')).toEqual(['d.only', 'e.pdf:ticket'])
107
+ // Sin InstalledAddonsProvider = desconocido: se muestra (el servidor revalida).
108
+ expect(keys(null)).toEqual(['a.chat', 'b.wa', 'c.own', 'e.pdf:ticket'])
109
+ })
110
+ })
111
+
112
+ describe('RowActionsMenu', () => {
113
+ it('«Enviar al chat» aparece sólo con el addon proveedor instalado y activo', async () => {
114
+ shareToChat()
115
+ const { unmount } = render(
116
+ <InstalledAddonsProvider addons={['fiscal_mexico']}>
117
+ <RowActionsMenu actions={[]} row={ROW} model="Invoice" />
118
+ </InstalledAddonsProvider>,
119
+ )
120
+ expect(screen.queryByText('Abrir menú')).toBeNull() // nada que ofrecer
121
+ unmount()
122
+
123
+ render(
124
+ <InstalledAddonsProvider addons={['fiscal_mexico', 'link']}>
125
+ <RowActionsMenu actions={[]} row={ROW} model="Invoice" />
126
+ </InstalledAddonsProvider>,
127
+ )
128
+ await openMenu()
129
+ expect(screen.getByText('Enviar al chat')).toBeTruthy()
130
+ })
131
+
132
+ it('primaria única destacada; las secundarias van bajo «Más…», no en el primer nivel', async () => {
133
+ const run = shareToChat()
134
+ render(
135
+ <InstalledAddonsProvider addons={['link']}>
136
+ <RowActionsMenu actions={ACTIONS} row={ROW} model="Invoice" />
137
+ </InstalledAddonsProvider>,
138
+ )
139
+ await openMenu()
140
+ const top = [...document.querySelectorAll('[role="menu"] > [role="menuitem"]')].map(
141
+ (el) => el.getAttribute('data-action') ?? el.textContent,
142
+ )
143
+ expect(top).toEqual(['stamp', 'cancel', 'approve', 'Más…'])
144
+ expect(document.querySelectorAll('[data-action].font-semibold')).toHaveLength(1)
145
+ expect(document.querySelector('[data-action="stamp"]')?.className).toContain('font-semibold')
146
+ expect(screen.queryByText('Enviar por correo')).toBeNull()
147
+ expect(screen.queryByText('Enviar al chat')).toBeNull()
148
+
149
+ const more = screen.getByText('Más…')
150
+ await act(async () => {
151
+ fireEvent.keyDown(more, { key: 'ArrowRight' })
152
+ })
153
+ expect(screen.getByText('Enviar por correo')).toBeTruthy()
154
+ await act(async () => {
155
+ fireEvent.click(screen.getByText('Enviar al chat'))
156
+ })
157
+ expect(run).toHaveBeenCalledWith({ model: 'Invoice', record: ROW })
158
+ })
159
+ })
160
+
161
+ describe('documento: secundarias en el pie', () => {
162
+ const spec: DocumentSpec = {
163
+ model: 'invoices',
164
+ title: '{{folio}}',
165
+ statuses: [],
166
+ actions: [
167
+ { key: 'register_payment', label: 'Registrar pago' },
168
+ { key: 'send', label: 'Enviar por correo', modelAction: 'send_cfdi_email' },
169
+ { key: 'download_pdf', label: 'Descargar PDF', openUrl: '{{pdf}}' },
170
+ { key: 'credit_note', label: 'Nota de crédito' },
171
+ { key: 'share_link', label: 'Enlace', priority: 'secondary' },
172
+ ],
173
+ layouts: [],
174
+ defaultLayout: {
175
+ primary: 'register_payment',
176
+ secondary: ['send', 'download_pdf', 'credit_note'],
177
+ more: ['share_link'],
178
+ },
179
+ tabs: [],
180
+ }
181
+
182
+ it('resolveActions saca compartir/correo/PDF de la cabecera al pie', () => {
183
+ const layout = resolveActions(spec, { record: ROW, sources: {}, derived: {} })
184
+ expect(layout.primary?.def.key).toBe('register_payment')
185
+ expect(layout.secondary.map((a) => a.def.key)).toEqual(['credit_note'])
186
+ expect(layout.more).toEqual([])
187
+ expect(layout.footer.map((a) => a.def.key)).toEqual(['send', 'download_pdf', 'share_link'])
188
+ })
189
+
190
+ it('la primaria de un layout nunca se va al pie aunque sea una descarga', () => {
191
+ const layout = resolveActions(
192
+ { ...spec, defaultLayout: { primary: 'download_pdf', secondary: ['send'] } },
193
+ { record: ROW, sources: {}, derived: {} },
194
+ )
195
+ expect(layout.primary?.def.key).toBe('download_pdf')
196
+ expect(layout.footer.map((a) => a.def.key)).toEqual(['send'])
197
+ })
198
+
199
+ it('DocumentSecondaryBar junta las del documento y las aportadas, filtradas por proveedor', () => {
200
+ shareToChat()
201
+ const items = [{ key: 'download_pdf', label: 'Descargar PDF', icon: 'FileDown', run: () => {} }]
202
+ const { rerender } = render(
203
+ <InstalledAddonsProvider addons={[]}>
204
+ <DocumentSecondaryBar items={items} model="Invoice" record={ROW} />
205
+ </InstalledAddonsProvider>,
206
+ )
207
+ const bar = () => document.querySelector('[data-document-secondary]')!
208
+ expect([...bar().querySelectorAll('[data-action]')].map((b) => b.getAttribute('data-action'))).toEqual(['download_pdf'])
209
+
210
+ rerender(
211
+ <InstalledAddonsProvider addons={['link']}>
212
+ <DocumentSecondaryBar items={items} model="Invoice" record={ROW} />
213
+ </InstalledAddonsProvider>,
214
+ )
215
+ expect([...bar().querySelectorAll('[data-action]')].map((b) => b.getAttribute('data-action'))).toEqual([
216
+ 'download_pdf',
217
+ 'team_chat.share',
218
+ ])
219
+ })
220
+
221
+ it('sin nada secundario no pinta el pie', () => {
222
+ render(<DocumentSecondaryBar items={[]} model="Invoice" record={ROW} />)
223
+ expect(document.querySelector('[data-document-secondary]')).toBeNull()
224
+ })
225
+ })
226
+
227
+ describe('registro único: AddonAPI → lectores de runtime-react', () => {
228
+ it('api.registry.registerSlot se pinta en <Slot> y se oculta si su addon no está instalado', () => {
229
+ const reg = new Registry()
230
+ const Widget = () => <span>widget de returns</span>
231
+ reg.scope('returns').registerSlot({ name: 'invoice.footer', component: Widget })
232
+ disposers.push(() => void reg.unbind('returns'))
233
+ const { rerender } = render(
234
+ <InstalledAddonsProvider addons={['returns']}>
235
+ <Slot name="invoice.footer" />
236
+ </InstalledAddonsProvider>,
237
+ )
238
+ expect(screen.getByText('widget de returns')).toBeTruthy()
239
+ rerender(
240
+ <InstalledAddonsProvider addons={[]}>
241
+ <Slot name="invoice.footer" />
242
+ </InstalledAddonsProvider>,
243
+ )
244
+ expect(screen.queryByText('widget de returns')).toBeNull()
245
+ })
246
+
247
+ it('api.registry.registerModal resuelve `action.modal` por slug en el dispatcher', () => {
248
+ const reg = new Registry()
249
+ const Modal = (p: { recordId: string; close: () => void }) => <div>modal {p.recordId}</div>
250
+ reg.scope('fiscal_mexico').registerModal({ slug: 'fiscal_mexico.import_cfdi', component: Modal as never })
251
+ disposers.push(() => void reg.unbind('fiscal_mexico'))
252
+ const action: ActionMetadata = { key: 'import_cfdi', label: 'Importar', icon: 'Upload', modal: 'fiscal_mexico.import_cfdi' }
253
+ render(
254
+ <InstalledAddonsProvider addons={['fiscal_mexico']}>
255
+ <ActionModalDispatcher open onOpenChange={() => {}} action={action} model="SupplierInvoice" record={{ id: 9 }} onSuccess={() => {}} />
256
+ </InstalledAddonsProvider>,
257
+ )
258
+ expect(screen.getByText('modal 9')).toBeTruthy()
259
+ expect(screen.queryByTestId('federated-action-loading')).toBeNull()
260
+ })
261
+ })
@@ -84,6 +84,8 @@ import type { ActionFieldDef, TableMetadata, ColumnDefinition } from './types'
84
84
  import {
85
85
  type ActionMetadata,
86
86
  type ActionModalProps,
87
+ type BridgedActionProps,
88
+ adaptActionProps,
87
89
  getActionComponent,
88
90
  subscribeActionComponents,
89
91
  } from '@asteby/metacore-sdk'
@@ -91,6 +93,8 @@ import { requestFederatedAction } from './federated-action-loader'
91
93
  import { useInstalledAddons } from './installed-addons-context'
92
94
  import { federatedModalComponent, resolveFederatedModal } from './primitives/contributions'
93
95
  import { installedPredicate, useContributionsVersion } from './primitives/use-contributions'
96
+ import { isTodayToken, todayInZone } from './calendar-date'
97
+ import { useTimeZone } from './org-runtime-context'
94
98
 
95
99
  export type { ActionMetadata, ActionModalProps }
96
100
 
@@ -338,8 +342,45 @@ function defaultFromRecordSpec(field: ActionFieldDef): string | string[] | undef
338
342
  return f.defaultFromRecord ?? f.default_from_record
339
343
  }
340
344
 
341
- /** Scalar seed for one action field from the row being acted on. */
342
- export function scalarDefaultFromRecord(field: ActionFieldDef, record: any): unknown {
345
+ /** `customers.Invoice`, `Invoice`, `invoices`, `sales_orders` → forma comparable. */
346
+ function modelToken(m: string): string {
347
+ const last = m.split('.').pop() ?? m
348
+ return last.replace(/[^a-z0-9]/gi, '').toLowerCase()
349
+ }
350
+
351
+ /**
352
+ * ¿`ref` (FK de un campo) apunta al modelo `model` de la fila? Tolera prefijo de
353
+ * addon, PascalCase vs tabla y el plural de la tabla (`Invoice` ↔ `invoices`,
354
+ * `CustomerAddress` ↔ `customer_addresses`).
355
+ */
356
+ export function refTargetsModel(ref: string | undefined | null, model: string | undefined | null): boolean {
357
+ if (!ref || !model) return false
358
+ const a = modelToken(ref)
359
+ const b = modelToken(model)
360
+ if (!a || !b) return false
361
+ return a === b || a + 's' === b || a + 'es' === b || b + 's' === a || b + 'es' === a
362
+ }
363
+
364
+ function fieldRef(field: ActionFieldDef): string | undefined {
365
+ if (field.ref) return field.ref
366
+ const opts = (field as { options?: unknown }).options
367
+ if (opts && !Array.isArray(opts) && typeof opts === 'object') {
368
+ const src = (opts as { source?: unknown }).source
369
+ if (typeof src === 'string' && src) return src
370
+ }
371
+ return undefined
372
+ }
373
+
374
+ /**
375
+ * Scalar seed for one action field from the row being acted on.
376
+ *
377
+ * Sin `default_from_record` declarado, un campo se siembra con la columna
378
+ * homónima de la fila; y un selector que apunta al MISMO modelo de la fila y
379
+ * que la fila no tiene como columna (`invoice_id` → Invoice en «Registrar pago»
380
+ * de una factura) se siembra con el id de esa fila: la acción se abrió desde
381
+ * ella (retest Pitsline 2026-10-05).
382
+ */
383
+ export function scalarDefaultFromRecord(field: ActionFieldDef, record: any, model?: string): unknown {
343
384
  if (!record) return undefined
344
385
  const spec = defaultFromRecordSpec(field)
345
386
  if (typeof spec === 'string') {
@@ -353,6 +394,12 @@ export function scalarDefaultFromRecord(field: ActionFieldDef, record: any): unk
353
394
  } else if (field.key) {
354
395
  const v = readRecordPath(record, field.key)
355
396
  if (v !== undefined && v !== null && v !== '') return v
397
+ // Sólo si la fila NO tiene esa columna: un `parent_id` vacío de una
398
+ // categoría es «sin padre», no «yo mismo».
399
+ const id = unwrapRecordScalar(record.id)
400
+ if (!(field.key in record) && id !== undefined && id !== null && id !== '' && refTargetsModel(fieldRef(field), model)) {
401
+ return id
402
+ }
356
403
  }
357
404
  return undefined
358
405
  }
@@ -402,17 +449,14 @@ export function ActionModalDispatcher({
402
449
  }
403
450
 
404
451
  if (federatedModal) {
405
- const FederatedComponent = federatedModalComponent<ActionModalProps>(federatedModal)
452
+ // Modal por slug del store canónico: lo registró `api.registry.registerModal`
453
+ // (contrato recordId/payload/close) o registerFederatedModal
454
+ // (ActionModalProps). Recibe ambos contratos.
455
+ const FederatedComponent = federatedModalComponent<BridgedActionProps>(federatedModal)
406
456
  return (
407
457
  <Suspense fallback={<LoadingActionDialog open={open} onOpenChange={onOpenChange} action={action} />}>
408
458
  <FederatedComponent
409
- open={open}
410
- onOpenChange={onOpenChange}
411
- action={action}
412
- model={model}
413
- record={record}
414
- endpoint={endpoint}
415
- onSuccess={onSuccess}
459
+ {...adaptActionProps({ open, onOpenChange, action, model, record, endpoint, onSuccess })}
416
460
  />
417
461
  </Suspense>
418
462
  )
@@ -979,6 +1023,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
979
1023
  const reasonPrompt = useReasonPrompt()
980
1024
  const supervisor = useSupervisor()
981
1025
  const branchGate = useBranchCreateGate()
1026
+ const orgTimeZone = useTimeZone()
982
1027
  const [formData, setFormData] = useState<Record<string, any>>({})
983
1028
  const [executing, setExecuting] = useState(false)
984
1029
  // Per-field validation errors (localized), shown inline under each input.
@@ -1016,11 +1061,11 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
1016
1061
 
1017
1062
  useEffect(() => {
1018
1063
  if (open && action.fields) {
1019
- setFormData(buildFieldDefaults(action.fields, record))
1064
+ setFormData(buildFieldDefaults(action.fields, record, model, { timeZone: orgTimeZone }))
1020
1065
  setFieldErrors({})
1021
1066
  setFormError(undefined)
1022
1067
  }
1023
- }, [open, action.fields, record])
1068
+ }, [open, action.fields, record, model, orgTimeZone])
1024
1069
 
1025
1070
  // Lines the list row does not carry: load them from the declared relation
1026
1071
  // (see prefillRelationRequests) and seed the line-items grid.
@@ -1311,10 +1356,44 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
1311
1356
  )
1312
1357
  }
1313
1358
 
1359
+ /** Context a declared field default is resolved against. */
1360
+ export interface FieldDefaultContext {
1361
+ /** Org IANA timezone: `$today` is the org's day, not the browser's or UTC's. */
1362
+ timeZone?: string
1363
+ /** Clock (tests). */
1364
+ now?: Date
1365
+ }
1366
+
1367
+ const DATE_FIELD_TYPES = new Set(['date', 'datetime', 'timestamp', 'timestamptz'])
1368
+
1369
+ /**
1370
+ * The declared scalar default of a field, resolved: `defaultValue` (host
1371
+ * camelCase) or `default` (raw manifest, the kernel serves action/document-form
1372
+ * fields without renaming it). A date field may declare `$today` / `today` and
1373
+ * gets the org's calendar day. An unknown `$token` never lands in a date field
1374
+ * (undefined): a literal "$today" posted as invoice_date is a 422, an empty
1375
+ * field is just a required field to fill.
1376
+ */
1377
+ export function resolveFieldDefault(field: ActionFieldDef, ctx: FieldDefaultContext = {}): unknown {
1378
+ const f = field as { defaultValue?: unknown; default?: unknown }
1379
+ const raw = f.defaultValue ?? f.default
1380
+ if (raw === undefined || raw === null) return undefined
1381
+ if (DATE_FIELD_TYPES.has(String(field.type ?? '').toLowerCase()) && typeof raw === 'string') {
1382
+ if (isTodayToken(raw)) return todayInZone(ctx.timeZone, ctx.now)
1383
+ if (raw.trim().startsWith('$')) return undefined
1384
+ }
1385
+ return raw
1386
+ }
1387
+
1314
1388
  // buildFieldDefaults seeds formData for a set of action fields, honoring the
1315
1389
  // same line-items prefill spec + boolean/empty rules GenericActionModal uses, so
1316
1390
  // wizard steps and single-page forms initialize identically.
1317
- export function buildFieldDefaults(fields: ActionFieldDef[], record: any): Record<string, any> {
1391
+ export function buildFieldDefaults(
1392
+ fields: ActionFieldDef[],
1393
+ record: any,
1394
+ model?: string,
1395
+ ctx: FieldDefaultContext = {},
1396
+ ): Record<string, any> {
1318
1397
  const defaults: Record<string, any> = {}
1319
1398
  for (const field of fields) {
1320
1399
  if (isLineItemsField(field)) {
@@ -1326,12 +1405,12 @@ export function buildFieldDefaults(fields: ActionFieldDef[], record: any): Recor
1326
1405
  : []
1327
1406
  continue
1328
1407
  }
1329
- const fromRecord = scalarDefaultFromRecord(field, record)
1408
+ const fromRecord = scalarDefaultFromRecord(field, record, model)
1330
1409
  if (fromRecord !== undefined && fromRecord !== null && fromRecord !== '') {
1331
1410
  defaults[field.key] = fromRecord
1332
1411
  continue
1333
1412
  }
1334
- defaults[field.key] = field.defaultValue ?? (field.type === 'boolean' ? false : '')
1413
+ defaults[field.key] = resolveFieldDefault(field, ctx) ?? (field.type === 'boolean' ? false : '')
1335
1414
  }
1336
1415
  return defaults
1337
1416
  }
@@ -1362,6 +1441,7 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1362
1441
  const api = useApi()
1363
1442
  const reasonPrompt = useReasonPrompt()
1364
1443
  const supervisor = useSupervisor()
1444
+ const orgTimeZone = useTimeZone()
1365
1445
  const steps = action.steps ?? []
1366
1446
  const [stepIndex, setStepIndex] = useState(0)
1367
1447
  const [formData, setFormData] = useState<Record<string, any>>({})
@@ -1374,10 +1454,10 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1374
1454
  useEffect(() => {
1375
1455
  if (!open) return
1376
1456
  const allFields = steps.flatMap((s) => s.fields ?? [])
1377
- setFormData(buildFieldDefaults(allFields, record))
1457
+ setFormData(buildFieldDefaults(allFields, record, model, { timeZone: orgTimeZone }))
1378
1458
  setStepIndex(0)
1379
1459
  setFormError(undefined)
1380
- }, [open, action.steps, record])
1460
+ }, [open, action.steps, record, model, orgTimeZone])
1381
1461
 
1382
1462
  const updateField = (key: string, value: any) =>
1383
1463
  setFormData((prev: Record<string, any>) => ({ ...prev, [key]: value }))
@@ -0,0 +1,82 @@
1
+ // Clasificación primaria / secundaria de acciones — puro (sin React), lo usan
2
+ // el menú de fila, la cabecera/pie del documento y resolveActions del
3
+ // DocumentPage. Ver record-actions.ts para las acciones aportadas por addons.
4
+ import type { ActionPriority } from '@asteby/metacore-sdk'
5
+ import type { InstalledAddonsValue } from './installed-addons-context'
6
+
7
+ export type { ActionPriority }
8
+
9
+ /** Lo mínimo que se lee de una acción de metadata para clasificarla. */
10
+ export interface PrioritizableAction {
11
+ key: string
12
+ priority?: string
13
+ trigger?: { type?: string; capability?: string } | null
14
+ }
15
+
16
+ // Segmentos de la clave (separados por _ . -) que marcan una acción de
17
+ // compartir/salida. Por segmento: `create_mailbox` no es «mail», `send` solo
18
+ // (enviar una cotización) sigue siendo flujo principal.
19
+ const SECONDARY_SEGMENTS = new Set([
20
+ 'print',
21
+ 'reprint',
22
+ 'share',
23
+ 'email',
24
+ 'mail',
25
+ 'whatsapp',
26
+ 'chat',
27
+ 'download',
28
+ 'pdf',
29
+ 'xml',
30
+ 'acuse',
31
+ ])
32
+
33
+ const CRUD_KEYS = new Set(['view', 'edit', 'delete'])
34
+
35
+ /** "primary" | "secondary" | undefined (= acción normal del menú). */
36
+ export function classifyActionPriority(action: PrioritizableAction): ActionPriority | undefined {
37
+ if (action.priority === 'primary' || action.priority === 'secondary') return action.priority
38
+ if (CRUD_KEYS.has(action.key)) return undefined
39
+ return action.key
40
+ .toLowerCase()
41
+ .split(/[_.-]+/)
42
+ .some((seg) => SECONDARY_SEGMENTS.has(seg))
43
+ ? 'secondary'
44
+ : undefined
45
+ }
46
+
47
+ export interface SplitActions<A> {
48
+ /** La única destacada (la primera que se declara `primary`). */
49
+ primary?: A
50
+ /** El resto de acciones principales, en orden. */
51
+ main: A[]
52
+ /** Compartir / imprimir / correo / chat… */
53
+ secondary: A[]
54
+ }
55
+
56
+ /**
57
+ * Separa primaria, principales y secundarias conservando el orden. Si varias
58
+ * reclaman `primary`, sólo la primera lo es; las demás quedan como principales.
59
+ */
60
+ export function splitActionsByPriority<A extends PrioritizableAction>(actions: readonly A[]): SplitActions<A> {
61
+ let primary: A | undefined
62
+ const main: A[] = []
63
+ const secondary: A[] = []
64
+ for (const a of actions) {
65
+ const p = classifyActionPriority(a)
66
+ if (p === 'secondary') secondary.push(a)
67
+ else if (p === 'primary' && !primary) primary = a
68
+ else main.push(a)
69
+ }
70
+ return { primary, main, secondary }
71
+ }
72
+
73
+ /**
74
+ * Una acción que despacha por capacidad (`trigger.type: "capability"`) sólo se
75
+ * ofrece si algún addon instalado la provee. Sin provider = desconocido → se
76
+ * muestra (el servidor revalida igual).
77
+ */
78
+ export function isActionProviderActive(action: PrioritizableAction, installed: InstalledAddonsValue | null): boolean {
79
+ const t = action.trigger
80
+ if (!installed || !t || t.type !== 'capability' || !t.capability) return true
81
+ return installed.capabilities.has(t.capability)
82
+ }
@@ -94,7 +94,7 @@ describe('line items', () => {
94
94
  makeLine({ description: ' Llanta ', quantity: '2' as unknown as number, unit_price: '' as unknown as number, sku: '', lot: ' L1 ' }),
95
95
  makeLine({ kind: 'note', description: 'Nota' }),
96
96
  ])
97
- expect(out[0]).toEqual({ position: 1, kind: 'item', description: 'Llanta', quantity: 2, unit_price: 0, discount: 0, tax_rate: 0, lot: 'L1' })
97
+ expect(out[0]).toEqual({ position: 1, kind: 'item', description: 'Llanta', quantity: 2, unit_price: 0, discount: 0, tax_rate: 0, lot: 'L1', subtotal: 0, tax_amount: 0 })
98
98
  expect(out[1]).toEqual({ position: 2, kind: 'note', description: 'Nota' })
99
99
  expect(out[0]).not.toHaveProperty('key')
100
100
  expect(out[0]).not.toHaveProperty('catalog_price')
@@ -85,3 +85,21 @@ export function createCatalogProductSearch(api: ApiLike, opts: CatalogProductSea
85
85
  .map((r) => catalogRecordToProduct(r as Record<string, any>, opts.defaultTaxRate))
86
86
  }
87
87
  }
88
+
89
+ /**
90
+ * Buscador del host (`searchProducts`) con el IVA de la org como respaldo: un
91
+ * producto que no trae su tasa entra con la de la org en vez de 0 (retest
92
+ * Pitsline 2026-10-05: el buscador de ops no devolvía tasa y el renglón de la
93
+ * factura quedaba sin IVA). Sin tasa de org, el buscador queda tal cual.
94
+ */
95
+ export function withDefaultTaxRate<Q, S>(
96
+ search: (query: Q, signal: S) => Promise<ProductResult[]>,
97
+ defaultTaxRate: number | undefined,
98
+ ): (query: Q, signal: S) => Promise<ProductResult[]> {
99
+ if (defaultTaxRate == null) return search
100
+ return async (query, signal) => {
101
+ const rows = await search(query, signal)
102
+ return rows.map((p) => (p.tax_rate == null ? { ...p, tax_rate: defaultTaxRate } : p))
103
+ }
104
+ }
105
+