@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
@@ -11,6 +11,7 @@ import type { Allocation, AllocationIssue, OpenDocument } from '../primitives/al
11
11
  import type {
12
12
  ActionFieldDef,
13
13
  DocumentFormLines,
14
+ DocumentFormSource,
14
15
  DocumentFormOpenDocuments,
15
16
  DocumentFormsManifest,
16
17
  DocumentFormType,
@@ -24,8 +25,10 @@ const ADVANCED_SECTIONS = new Set(['fiscal', 'advanced'])
24
25
  export type EditorLinesConfig = DocumentFormLines & { field: string }
25
26
 
26
27
  /** true si el tipo se abre en el DocumentEditor (`layout: "editor"`). */
27
- export function isEditorLayout(type: Pick<DocumentFormType, 'layout'> | null | undefined): boolean {
28
- return type?.layout === 'editor'
28
+ export function isEditorLayout(type: Pick<DocumentFormType, 'layout' | 'sources'> | null | undefined): boolean {
29
+ // «Cargar desde…» vive en el editor: un tipo con `sources` lo usa salvo que
30
+ // pida explícitamente el wizard.
31
+ return type?.layout === 'editor' || (type?.layout !== 'wizard' && (type?.sources?.length ?? 0) > 0)
29
32
  }
30
33
 
31
34
  /** Paso de renglones efectivo del tipo (o undefined si no tiene). */
@@ -189,13 +192,22 @@ export function creditStatus(
189
192
  * (`description: product_name`, `discount: discount_pct`). Si el origen trae
190
193
  * `tax_amount` y `subtotal` pero no la tasa, la deriva. `credit` (NC) topa la
191
194
  * cantidad en lo facturado (o en `max_quantity` si el origen la trae).
195
+ *
196
+ * Con `remaining_quantity` (lo sirve el host: cantidad − lo ya facturado o
197
+ * devuelto) la cantidad sugerida y el tope son lo pendiente y los renglones ya
198
+ * cubiertos se omiten. Cada línea guarda `source_line_id` para el vínculo.
192
199
  */
193
- export function linesFromSource(rows: unknown, map: Record<string, string> = {}, opts: { kind?: DocumentFormLines['kind'] } = {}): LineItem[] {
200
+ export function linesFromSource(
201
+ rows: unknown,
202
+ map: Record<string, string> = {},
203
+ opts: { kind?: DocumentFormLines['kind']; discountMode?: DocumentFormLines['discount_mode'] } = {},
204
+ ): LineItem[] {
194
205
  if (!Array.isArray(rows)) return []
195
- return rows.map((raw) => {
206
+ return rows.filter((raw) => !isFullyConsumed(raw)).map((raw) => {
196
207
  const r = (raw ?? {}) as Record<string, any>
197
208
  const get = (k: string) => r[map[k] ?? k]
198
- const qty = toAmount(get('quantity') ?? 1)
209
+ const pending = hasValue(r.remaining_quantity) ? toAmount(r.remaining_quantity) : undefined
210
+ const qty = pending ?? toAmount(get('quantity') ?? 1)
199
211
  let rate = get('tax_rate')
200
212
  if (!hasValue(rate)) {
201
213
  const sub = toAmount(get('subtotal'))
@@ -213,11 +225,34 @@ export function linesFromSource(rows: unknown, map: Record<string, string> = {},
213
225
  tax_rate: r2 > 1 ? r2 / 100 : r2,
214
226
  unit: get('unit') ?? undefined,
215
227
  })
216
- if (opts.kind === 'credit') line.max_quantity = hasValue(get('max_quantity')) ? toAmount(get('max_quantity')) : qty
228
+ // El descuento del origen se lee en la unidad del documento (importe en
229
+ // modelos cuyo subtotal resta un monto).
230
+ if (opts.discountMode === 'amount') line.discount_kind = 'amount'
231
+ if (pending != null) line.max_quantity = pending
232
+ else if (opts.kind === 'credit') line.max_quantity = hasValue(get('max_quantity')) ? toAmount(get('max_quantity')) : qty
233
+ const sourceLine = r.source_line_id ?? r.id
234
+ if (hasValue(sourceLine)) line.source_line_id = String(sourceLine)
217
235
  return line
218
236
  })
219
237
  }
220
238
 
239
+ function isFullyConsumed(raw: unknown): boolean {
240
+ const r = (raw ?? {}) as Record<string, any>
241
+ return hasValue(r.remaining_quantity) && toAmount(r.remaining_quantity) <= 0
242
+ }
243
+
244
+ /** Cuántos renglones del origen se cargaron y cuántos se omitieron por estar ya cubiertos. */
245
+ export function sourceLoadSummary(rows: unknown): { loaded: number; covered: number } {
246
+ if (!Array.isArray(rows)) return { loaded: 0, covered: 0 }
247
+ const covered = rows.filter(isFullyConsumed).length
248
+ return { loaded: rows.length - covered, covered }
249
+ }
250
+
251
+ /** Fuente que precarga desde lo pendiente servido por el host (no desde la relación cruda). */
252
+ export function sourceTracksRemaining(src: Pick<DocumentFormSource, 'line_link_field' | 'remaining_qty_field' | 'remaining_endpoint'>): boolean {
253
+ return !!(src.line_link_field || src.remaining_qty_field || src.remaining_endpoint)
254
+ }
255
+
221
256
  export interface EditorIssue {
222
257
  field?: string
223
258
  severity: 'error' | 'warning'
@@ -234,6 +269,13 @@ export function localIssues(lines: LineItem[], opts: { requireLines?: boolean; r
234
269
  items.forEach((l, i) => {
235
270
  if (!l.description.trim()) issues.push({ field: `lines.${i}`, severity: 'error', message: `Renglón ${i + 1}: falta la descripción.` })
236
271
  if (toAmount(l.unit_price) <= 0) issues.push({ field: `lines.${i}`, severity: 'warning', message: `Renglón ${i + 1}: precio en cero.` })
272
+ if (l.max_quantity != null && toAmount(l.quantity) > toAmount(l.max_quantity)) {
273
+ issues.push({
274
+ field: `lines.${i}`,
275
+ severity: 'error',
276
+ message: `Renglón ${i + 1}: la cantidad excede lo pendiente del documento origen (${l.max_quantity}).`,
277
+ })
278
+ }
237
279
  if (opts.requireTax && toAmount(l.tax_rate) === 0) {
238
280
  issues.push({ field: `lines.${i}`, severity: 'warning', message: `Renglón ${i + 1}: sin impuesto.` })
239
281
  }
@@ -23,7 +23,7 @@ import { Button } from '@asteby/metacore-ui/primitives'
23
23
  import { Loader2 } from 'lucide-react'
24
24
  import { toast } from 'sonner'
25
25
  import { useApi } from '../api-context'
26
- import { useOrgTaxRate } from '../org-runtime-context'
26
+ import { useOrgTaxRate, useTimeZone } from '../org-runtime-context'
27
27
  import { FieldCell, FieldGrid, FieldLabel } from '../field-grid'
28
28
  import { FormErrorBanner } from './feedback'
29
29
  import { DocumentLinesGrid, type DocumentLinesGridProps, type LineItemsColumn } from './line-items-editor'
@@ -38,6 +38,8 @@ import {
38
38
  editorLinesConfig,
39
39
  linesFromSource,
40
40
  localIssues,
41
+ sourceLoadSummary,
42
+ sourceTracksRemaining,
41
43
  partyDefaults,
42
44
  partySummaryRows,
43
45
  splitEditorFields,
@@ -112,6 +114,7 @@ export function DocumentEditor({
112
114
  const fmt = useFormatter({ currency })
113
115
  const installed = useInstalledAddons()
114
116
  const orgTaxRate = useOrgTaxRate()
117
+ const orgTimeZone = useTimeZone()
115
118
  const tl = useCallback((s: string) => t(s, { defaultValue: s }), [t])
116
119
 
117
120
  const lineCfg = useMemo(() => editorLinesConfig(type, forms), [type, forms])
@@ -120,7 +123,7 @@ export function DocumentEditor({
120
123
  const amountKey = isAllocation ? allocationAmountField(type.fields) : undefined
121
124
 
122
125
  const [header, setHeader] = useState<Record<string, any>>(() => ({
123
- ...buildFieldDefaults(type.fields, record),
126
+ ...buildFieldDefaults(type.fields, record, undefined, { timeZone: orgTimeZone, now: today }),
124
127
  ...(type.defaults ?? {}),
125
128
  }))
126
129
  const [lines, setLines] = useState<LineItem[]>([])
@@ -142,6 +145,10 @@ export function DocumentEditor({
142
145
  const [attempted, setAttempted] = useState(false)
143
146
  const [sourceKey, setSourceKey] = useState<string>(initialSource?.key ?? '')
144
147
  const [sourceId, setSourceId] = useState<string>(initialSource?.id ?? '')
148
+ // Fuente de la que salieron los renglones: su `line_link_field` viaja en cada
149
+ // renglón para que el servidor descuente lo ya facturado/devuelto y valide.
150
+ const [loadedSource, setLoadedSource] = useState<DocumentFormSource | null>(null)
151
+ const [sourceNote, setSourceNote] = useState<string | undefined>()
145
152
  const relCache = useRef(new Map<string, Promise<any[]>>())
146
153
 
147
154
  const taxRate = defaultTaxRate ?? orgTaxRate
@@ -273,10 +280,13 @@ export function DocumentEditor({
273
280
  const p: Record<string, unknown> = { ...(type.defaults ?? {}), ...header }
274
281
  if (forms.type_field && !type.submit_action) p[forms.type_field] = type.value ?? type.key
275
282
  if (lineCfg) {
276
- p[lineCfg.field] = isAllocation && openCfg ? allocationPayload(allocation.allocations, openCfg) : serializeLineItems(lines)
283
+ p[lineCfg.field] =
284
+ isAllocation && openCfg
285
+ ? allocationPayload(allocation.allocations, openCfg)
286
+ : serializeLineItems(lines, { sourceLineField: loadedSource?.line_link_field })
277
287
  }
278
288
  return p
279
- }, [type, header, forms.type_field, lineCfg, isAllocation, openCfg, allocation, lines])
289
+ }, [type, header, forms.type_field, lineCfg, isAllocation, openCfg, allocation, lines, loadedSource])
280
290
 
281
291
  const baseUrl = type.endpoint ?? endpoint ?? `/dynamic/${model}`
282
292
 
@@ -304,27 +314,72 @@ export function DocumentEditor({
304
314
  }
305
315
 
306
316
  // ---- Cargar desde… --------------------------------------------------------
317
+ /**
318
+ * Renglones del origen. Con lo pendiente declarado (`line_link_field`,
319
+ * `remaining_qty_field` o `remaining_endpoint`) los sirve el host ya con
320
+ * `remaining_quantity` (cantidad − lo ya facturado/devuelto, calculado en el
321
+ * servidor); si el host aún no tiene `source-lines` (404) o la fuente no lo
322
+ * declara, se leen de la relación como antes.
323
+ */
324
+ const fetchSourceRows = useCallback(
325
+ async (src: DocumentFormSource, id: string): Promise<unknown[]> => {
326
+ if (src.remaining_endpoint) {
327
+ const res = await api.get(src.remaining_endpoint, { params: { id } })
328
+ return res?.data?.data ?? []
329
+ }
330
+ if (sourceTracksRemaining(src)) {
331
+ try {
332
+ const res = await api.get(`${baseUrl}/source-lines`, {
333
+ params: { source: src.key, id, ...(draftId ? { exclude: draftId } : {}) },
334
+ })
335
+ return res?.data?.data ?? []
336
+ } catch (err: any) {
337
+ if (err?.response?.status !== 404) throw err
338
+ }
339
+ }
340
+ let rels = relCache.current.get(src.model)
341
+ if (!rels) {
342
+ rels = api
343
+ .get(`/metadata/table/${src.model}`)
344
+ .then((r: any) => r?.data?.data?.relations ?? r?.data?.relations ?? [])
345
+ relCache.current.set(src.model, rels)
346
+ }
347
+ const call = prefillFromFieldRelationRequest(
348
+ { fieldKey: lineCfg?.field ?? 'lines', refModel: src.model, refId: id, spec: { $prefillFromRecord: src.lines } },
349
+ await rels,
350
+ )
351
+ if (!call) throw new Error(`${src.model}: falta la relación «${src.lines}»`)
352
+ const res = await api.get(call.endpoint, { params: call.params })
353
+ return res?.data?.data ?? []
354
+ },
355
+ [api, baseUrl, draftId, lineCfg?.field],
356
+ )
357
+
307
358
  const loadFromSource = useCallback(
308
359
  async (src: DocumentFormSource, id: string) => {
309
360
  if (!lineCfg || !id) return
310
361
  setBusy('source')
311
362
  try {
312
363
  if (!isAllocation) {
313
- let rels = relCache.current.get(src.model)
314
- if (!rels) {
315
- rels = api
316
- .get(`/metadata/table/${src.model}`)
317
- .then((r: any) => r?.data?.data?.relations ?? r?.data?.relations ?? [])
318
- relCache.current.set(src.model, rels)
319
- }
320
- const call = prefillFromFieldRelationRequest(
321
- { fieldKey: lineCfg.field, refModel: src.model, refId: id, spec: { $prefillFromRecord: src.lines } },
322
- await rels,
323
- )
324
- if (!call) throw new Error(`${src.model}: falta la relación «${src.lines}»`)
325
- const res = await api.get(call.endpoint, { params: call.params })
326
- setLines(linesFromSource(res?.data?.data ?? [], src.map, { kind }))
364
+ const rows = await fetchSourceRows(src, id)
365
+ const loaded = linesFromSource(rows, src.map, { kind, discountMode: lineCfg.discount_mode })
366
+ const { covered } = sourceLoadSummary(rows)
367
+ setLines(loaded)
368
+ setLoadedSource(src)
327
369
  setLinesFromSourceDoc(kind === 'credit')
370
+ setSourceNote(
371
+ loaded.length === 0 && covered > 0
372
+ ? t('documentEditor.source_all_covered', {
373
+ defaultValue: 'Todo el documento ya está registrado en otros documentos; no queda nada pendiente.',
374
+ })
375
+ : covered > 0
376
+ ? t('documentEditor.source_loaded_partial', {
377
+ defaultValue: '{{count}} renglones con lo pendiente · {{covered}} ya cubiertos se omitieron',
378
+ count: loaded.length,
379
+ covered,
380
+ })
381
+ : t('documentEditor.source_loaded', { defaultValue: '{{count}} renglones cargados', count: loaded.length }),
382
+ )
328
383
  }
329
384
  if (src.header || src.link_field) {
330
385
  const h = await api.get(`/data/${src.model}`, { params: { f_id: `eq:${id}`, per_page: 1 } })
@@ -345,7 +400,7 @@ export function DocumentEditor({
345
400
  setBusy(null)
346
401
  }
347
402
  },
348
- [api, lineCfg, isAllocation, kind, t],
403
+ [api, lineCfg, isAllocation, kind, t, fetchSourceRows],
349
404
  )
350
405
 
351
406
  // Prefill al abrir desde un documento origen (acción de fila «Crear NC», «Facturar venta»…).
@@ -538,7 +593,7 @@ export function DocumentEditor({
538
593
  <EditorSection
539
594
  slot="load-from-source"
540
595
  title={t('documentEditor.load_from', { defaultValue: 'Cargar desde…' })}
541
- hint={busy === 'source' ? t('documentEditor.loading_source', { defaultValue: 'Cargando…' }) : undefined}
596
+ hint={busy === 'source' ? t('documentEditor.loading_source', { defaultValue: 'Cargando…' }) : sourceNote}
542
597
  >
543
598
  <div className="flex flex-wrap items-center gap-2">
544
599
  {sources.length > 1 && (
@@ -32,6 +32,12 @@ export interface LineItem {
32
32
  cost?: number
33
33
  /** Tope de cantidad (NC: facturado − acreditado; devolución: vendido − devuelto). */
34
34
  max_quantity?: number
35
+ /**
36
+ * Id del renglón del documento origen («crear desde»). Viaja en la columna
37
+ * `line_link_field` de la fuente para que el servidor descuente y valide lo
38
+ * pendiente; un renglón libre no lo lleva.
39
+ */
40
+ source_line_id?: string
35
41
  technician_id?: string
36
42
  lot?: string
37
43
  dot?: string
@@ -203,7 +209,10 @@ export function validateLineItems(lines: LineItem[], policy: LineItemsPolicy = {
203
209
  * Forma que se manda al backend: números reales (nunca `""`), sin claves de UI,
204
210
  * opcionales vacíos omitidos y `position` 1-based conservando el orden.
205
211
  */
206
- export function serializeLineItems(lines: LineItem[]): Array<Record<string, unknown>> {
212
+ export function serializeLineItems(
213
+ lines: LineItem[],
214
+ opts: { /** Columna que recibe `source_line_id` (la `line_link_field` de la fuente). */ sourceLineField?: string } = {},
215
+ ): Array<Record<string, unknown>> {
207
216
  return lines.map((l, i) => {
208
217
  const out: Record<string, unknown> = {
209
218
  position: i + 1,
@@ -222,6 +231,12 @@ export function serializeLineItems(lines: LineItem[]): Array<Record<string, unkn
222
231
  if (typeof v === 'string' && v.trim()) out[k] = v.trim()
223
232
  }
224
233
  if (l.extensions && Object.keys(l.extensions).length > 0) out.fiscal_data = { ...l.extensions }
234
+ if (opts.sourceLineField && l.source_line_id) out[opts.sourceLineField] = l.source_line_id
235
+ // Importes ya calculados: un modelo de renglón sin tasa (solo
236
+ // `tax_amount`/`subtotal`) los guarda tal cual en vez de perder el IVA.
237
+ const a = computeLine(l)
238
+ out.subtotal = a.net
239
+ out.tax_amount = a.tax
225
240
  }
226
241
  return out
227
242
  })
@@ -26,3 +26,39 @@ export function isCalendarDayValue(v: unknown): boolean {
26
26
  if (typeof v !== 'string') return false
27
27
  return /^\d{4}-\d{2}-\d{2}(?:[T ]00:00:00(?:\.0+)?(?:Z|\+00:00)?)?$/.test(v.trim())
28
28
  }
29
+
30
+ /**
31
+ * Today's calendar day ("YYYY-MM-DD") in `timeZone` (IANA, e.g. the org's
32
+ * `America/Mexico_City`), or in the browser zone when absent/invalid. A form
33
+ * that seeds «hoy» must use the org's day, not UTC's: at 19:00 in Mexico City
34
+ * UTC is already tomorrow.
35
+ */
36
+ export function todayInZone(timeZone?: string, now: Date = new Date()): string {
37
+ if (timeZone) {
38
+ try {
39
+ // en-CA formats as YYYY-MM-DD.
40
+ return new Intl.DateTimeFormat('en-CA', {
41
+ timeZone,
42
+ year: 'numeric',
43
+ month: '2-digit',
44
+ day: '2-digit',
45
+ }).format(now)
46
+ } catch {
47
+ /* unknown zone → browser day */
48
+ }
49
+ }
50
+ const y = now.getFullYear()
51
+ const m = String(now.getMonth() + 1).padStart(2, '0')
52
+ const d = String(now.getDate()).padStart(2, '0')
53
+ return `${y}-${m}-${d}`
54
+ }
55
+
56
+ /**
57
+ * A field default that names «today» instead of a fixed day: `$today` (the
58
+ * token flows already use) or `today`. Case-insensitive.
59
+ */
60
+ export function isTodayToken(v: unknown): boolean {
61
+ if (typeof v !== 'string') return false
62
+ const s = v.trim().toLowerCase()
63
+ return s === '$today' || s === 'today'
64
+ }
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'
8
8
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@asteby/metacore-ui/primitives'
9
9
  import { cn } from '@asteby/metacore-ui/lib'
10
10
  import { ActionBar } from './action-bar'
11
+ import { DocumentSecondaryBar } from './secondary-bar'
11
12
  import { DocumentHeader, type HeaderBadge, type HeaderMetric } from './document-header'
12
13
  import { SmartButtons, type SmartButtonItem } from './smart-buttons'
13
14
  import { StatusBadge } from './status-badge'
@@ -175,6 +176,20 @@ export function DocumentPage(props: DocumentPageProps) {
175
176
  </TabsContent>
176
177
  ))}
177
178
  </Tabs>
179
+
180
+ {/* Compartir / imprimir / correo / PDF-XML y lo que aporten los
181
+ addons instalados: al pie, no compitiendo con la primaria. */}
182
+ <DocumentSecondaryBar
183
+ items={layout.footer.map((a) => ({
184
+ key: a.def.key,
185
+ label: a.def.label,
186
+ icon: a.def.icon,
187
+ blockedReason: a.blockedReason,
188
+ run: () => handleAction(a),
189
+ }))}
190
+ model={spec.model}
191
+ record={record}
192
+ />
178
193
  </div>
179
194
  )
180
195
  }
@@ -1,3 +1,4 @@
1
+ import { classifyActionPriority } from '../action-priority'
1
2
  import type {
2
3
  ActionLayout,
3
4
  DocumentActionDef,
@@ -130,12 +131,15 @@ export function resolveActions(spec: DocumentSpec, ctx: DocumentContext): Resolv
130
131
  const destructive = many(layout.destructive)
131
132
  const secondary: ResolvedAction[] = []
132
133
  const more: ResolvedAction[] = []
134
+ const footer: ResolvedAction[] = []
133
135
  for (const a of [...many(layout.secondary)]) {
134
136
  if (a.def.destructive) destructive.push(a)
137
+ else if (isFooterAction(a.def)) footer.push(a)
135
138
  else secondary.push(a)
136
139
  }
137
140
  for (const a of many(layout.more)) {
138
141
  if (a.def.destructive) destructive.push(a)
142
+ else if (isFooterAction(a.def)) footer.push(a)
139
143
  else more.push(a)
140
144
  }
141
145
  const overflow = secondary.splice(MAX_VISIBLE_SECONDARY)
@@ -146,7 +150,19 @@ export function resolveActions(spec: DocumentSpec, ctx: DocumentContext): Resolv
146
150
  destructive.unshift(primary)
147
151
  primary = undefined
148
152
  }
149
- return { primary, secondary, more, destructive }
153
+ return { primary, secondary, more, destructive, footer }
154
+ }
155
+
156
+ /**
157
+ * ¿Va al pie? `priority: secondary`, o la convención por clave sobre la acción
158
+ * del modelo (`modelAction ?? key`): compartir, imprimir, correo, chat, PDF/XML.
159
+ */
160
+ function isFooterAction(def: DocumentActionDef): boolean {
161
+ if (def.priority) return def.priority === 'secondary'
162
+ return (
163
+ classifyActionPriority({ key: def.key }) === 'secondary' ||
164
+ (!!def.modelAction && classifyActionPriority({ key: def.modelAction }) === 'secondary')
165
+ )
150
166
  }
151
167
 
152
168
  /** Interpola `{{campo}}` con valores del registro (sin evaluar código). */
@@ -13,6 +13,7 @@ export {
13
13
  export { formatFieldValue, type FieldFormatOptions } from './format'
14
14
  export { StatusBadge, STATUS_TONE_STYLE, type StatusBadgeProps } from './status-badge'
15
15
  export { ActionBar, type ActionBarProps } from './action-bar'
16
+ export { DocumentSecondaryBar, type DocumentSecondaryBarProps, type SecondaryBarItem } from './secondary-bar'
16
17
  export { DocumentHeader, type DocumentHeaderProps, type HeaderBadge, type HeaderMetric } from './document-header'
17
18
  export { SmartButtons, type SmartButtonItem, type SmartButtonsProps } from './smart-buttons'
18
19
  export { DocumentTimeline, type DocumentTimelineProps, type TimelineEntry } from './document-timeline'
@@ -0,0 +1,92 @@
1
+ // DocumentSecondaryBar — pie del documento con las acciones secundarias:
2
+ // compartir, imprimir/descargar PDF o XML, enviar por correo, enviar al chat…
3
+ // La cabecera se queda con UNA primaria (y sus principales); esto va abajo,
4
+ // discreto. Junta dos fuentes:
5
+ // - `items`: acciones del propio documento/modelo ya clasificadas como
6
+ // secundarias (resolveActions → `footer`, o splitActionsByPriority);
7
+ // - las aportadas por addons/host con registerRecordAction, sólo si su
8
+ // proveedor está instalado y activo (useRecordActions).
9
+ // Sin nada que mostrar no pinta nada.
10
+ import { useState, type ReactNode } from 'react'
11
+ import { useTranslation } from 'react-i18next'
12
+ import { Loader2 } from 'lucide-react'
13
+ import { Button } from '@asteby/metacore-ui/primitives'
14
+ import { cn } from '@asteby/metacore-ui/lib'
15
+ import { DynamicIcon } from '../dynamic-icon'
16
+ import { useRecordActions } from '../record-actions'
17
+
18
+ export interface SecondaryBarItem {
19
+ key: string
20
+ label: string
21
+ icon?: string
22
+ /** Deshabilitada con su motivo (no desaparece). */
23
+ blockedReason?: string
24
+ run: () => unknown
25
+ }
26
+
27
+ export interface DocumentSecondaryBarProps {
28
+ items?: SecondaryBarItem[]
29
+ /** Modelo + registro: resuelven las acciones aportadas por addons. */
30
+ model?: string
31
+ record?: Record<string, unknown> | null
32
+ /** Controles extra del host (al final). */
33
+ children?: ReactNode
34
+ className?: string
35
+ }
36
+
37
+ export function DocumentSecondaryBar({ items = [], model, record, children, className }: DocumentSecondaryBarProps) {
38
+ const { t } = useTranslation()
39
+ const contributed = useRecordActions(model ?? '', model ? record : null)
40
+ const [busy, setBusy] = useState<string | null>(null)
41
+
42
+ const run = async (key: string, fn?: () => unknown) => {
43
+ if (!fn || busy) return
44
+ setBusy(key)
45
+ try {
46
+ await fn()
47
+ } finally {
48
+ setBusy(null)
49
+ }
50
+ }
51
+
52
+ const all: SecondaryBarItem[] = [
53
+ ...items,
54
+ ...contributed.filter((c) => c.run).map((c) => ({ key: c.key, label: c.label, icon: c.icon, run: c.run! })),
55
+ ]
56
+ const rendered = contributed.filter((c) => c.render && !c.run)
57
+ if (all.length === 0 && rendered.length === 0 && !children) return null
58
+
59
+ return (
60
+ <footer
61
+ className={cn('flex flex-wrap items-center gap-1.5 border-t pt-3', className)}
62
+ data-document-secondary=''
63
+ aria-label={t('datatable.secondary_actions', { defaultValue: 'Compartir y documentos' })}
64
+ >
65
+ {all.map((a) => (
66
+ <Button
67
+ key={a.key}
68
+ type='button'
69
+ size='sm'
70
+ variant='ghost'
71
+ className='text-muted-foreground'
72
+ data-action={a.key}
73
+ disabled={!!a.blockedReason || (!!busy && busy !== a.key)}
74
+ title={a.blockedReason ? t(a.blockedReason, { defaultValue: a.blockedReason }) : undefined}
75
+ onClick={() => void run(a.key, a.run)}
76
+ >
77
+ {busy === a.key ? (
78
+ <Loader2 className='mr-1.5 h-4 w-4 animate-spin' />
79
+ ) : a.icon ? (
80
+ <DynamicIcon name={a.icon} className='mr-1.5 h-4 w-4' />
81
+ ) : null}
82
+ {t(a.label, { defaultValue: a.label })}
83
+ </Button>
84
+ ))}
85
+ {rendered.map((c) => {
86
+ const C = c.render!
87
+ return <C key={c.key} model={model!} record={record!} />
88
+ })}
89
+ {children}
90
+ </footer>
91
+ )
92
+ }
@@ -64,6 +64,12 @@ export interface DocumentActionDef {
64
64
  href?: string
65
65
  openUrl?: string
66
66
  tab?: string
67
+ /**
68
+ * "secondary" (o, sin valor, una clave de compartir/imprimir/correo/chat/
69
+ * PDF/XML por convención) saca la acción de la cabecera y la pone en la
70
+ * barra secundaria del pie — salvo que el layout la haga primaria.
71
+ */
72
+ priority?: 'primary' | 'secondary'
67
73
  /** Estilo crítico: siempre va en la zona destructiva. */
68
74
  destructive?: boolean
69
75
  /** Se muestra deshabilitada con el motivo en lugar de desaparecer. */
@@ -200,6 +206,8 @@ export interface ResolvedActionLayout {
200
206
  secondary: ResolvedAction[]
201
207
  more: ResolvedAction[]
202
208
  destructive: ResolvedAction[]
209
+ /** Secundarias (compartir, imprimir, correo…) → DocumentSecondaryBar, no la cabecera. */
210
+ footer: ResolvedAction[]
203
211
  }
204
212
 
205
213
  export type DocumentSlotRenderer = (ctx: DocumentContext) => ReactNode