@asteby/metacore-runtime-react 45.0.0 → 45.3.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 (177) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/action-modal-dispatcher.d.ts +29 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +115 -6
  5. package/dist/activity-diff.d.ts +2 -0
  6. package/dist/activity-diff.d.ts.map +1 -1
  7. package/dist/activity-diff.js +2 -2
  8. package/dist/approval-events.d.ts +16 -0
  9. package/dist/approval-events.d.ts.map +1 -0
  10. package/dist/approval-events.js +27 -0
  11. package/dist/approval-gate.d.ts +39 -0
  12. package/dist/approval-gate.d.ts.map +1 -0
  13. package/dist/approval-gate.js +110 -0
  14. package/dist/approval-pin-dialog.d.ts +25 -0
  15. package/dist/approval-pin-dialog.d.ts.map +1 -0
  16. package/dist/approval-pin-dialog.js +66 -0
  17. package/dist/business/catalog.d.ts +16 -0
  18. package/dist/business/catalog.d.ts.map +1 -0
  19. package/dist/business/catalog.js +50 -0
  20. package/dist/business/customer-picker.d.ts +39 -0
  21. package/dist/business/customer-picker.d.ts.map +1 -0
  22. package/dist/business/customer-picker.js +51 -0
  23. package/dist/business/feedback.d.ts +72 -0
  24. package/dist/business/feedback.d.ts.map +1 -0
  25. package/dist/business/feedback.js +52 -0
  26. package/dist/business/field-errors.d.ts +26 -0
  27. package/dist/business/field-errors.d.ts.map +1 -0
  28. package/dist/business/field-errors.js +59 -0
  29. package/dist/business/format.d.ts +49 -0
  30. package/dist/business/format.d.ts.map +1 -0
  31. package/dist/business/format.js +128 -0
  32. package/dist/business/index.d.ts +16 -0
  33. package/dist/business/index.d.ts.map +1 -0
  34. package/dist/business/index.js +15 -0
  35. package/dist/business/line-items-editor.d.ts +25 -0
  36. package/dist/business/line-items-editor.d.ts.map +1 -0
  37. package/dist/business/line-items-editor.js +36 -0
  38. package/dist/business/line-items.d.ts +69 -0
  39. package/dist/business/line-items.d.ts.map +1 -0
  40. package/dist/business/line-items.js +133 -0
  41. package/dist/business/payment-capture.d.ts +21 -0
  42. package/dist/business/payment-capture.d.ts.map +1 -0
  43. package/dist/business/payment-capture.js +24 -0
  44. package/dist/business/payment.d.ts +61 -0
  45. package/dist/business/payment.d.ts.map +1 -0
  46. package/dist/business/payment.js +74 -0
  47. package/dist/business/product-picker.d.ts +24 -0
  48. package/dist/business/product-picker.d.ts.map +1 -0
  49. package/dist/business/product-picker.js +48 -0
  50. package/dist/business/product-search.d.ts +70 -0
  51. package/dist/business/product-search.d.ts.map +1 -0
  52. package/dist/business/product-search.js +51 -0
  53. package/dist/business/refund-destination.d.ts +19 -0
  54. package/dist/business/refund-destination.d.ts.map +1 -0
  55. package/dist/business/refund-destination.js +31 -0
  56. package/dist/business/refund.d.ts +29 -0
  57. package/dist/business/refund.d.ts.map +1 -0
  58. package/dist/business/refund.js +44 -0
  59. package/dist/business/return-wizard.d.ts +23 -0
  60. package/dist/business/return-wizard.d.ts.map +1 -0
  61. package/dist/business/return-wizard.js +59 -0
  62. package/dist/business/return.d.ts +90 -0
  63. package/dist/business/return.d.ts.map +1 -0
  64. package/dist/business/return.js +112 -0
  65. package/dist/business/use-async-search.d.ts +10 -0
  66. package/dist/business/use-async-search.d.ts.map +1 -0
  67. package/dist/business/use-async-search.js +38 -0
  68. package/dist/document/action-bar.d.ts +12 -0
  69. package/dist/document/action-bar.d.ts.map +1 -0
  70. package/dist/document/action-bar.js +39 -0
  71. package/dist/document/cancel-with-reason.d.ts +47 -0
  72. package/dist/document/cancel-with-reason.d.ts.map +1 -0
  73. package/dist/document/cancel-with-reason.js +52 -0
  74. package/dist/document/document-header.d.ts +41 -0
  75. package/dist/document/document-header.d.ts.map +1 -0
  76. package/dist/document/document-header.js +30 -0
  77. package/dist/document/document-page.d.ts +37 -0
  78. package/dist/document/document-page.d.ts.map +1 -0
  79. package/dist/document/document-page.js +137 -0
  80. package/dist/document/document-timeline.d.ts +20 -0
  81. package/dist/document/document-timeline.d.ts.map +1 -0
  82. package/dist/document/document-timeline.js +26 -0
  83. package/dist/document/evaluate.d.ts +31 -0
  84. package/dist/document/evaluate.d.ts.map +1 -0
  85. package/dist/document/evaluate.js +170 -0
  86. package/dist/document/format.d.ts +9 -0
  87. package/dist/document/format.d.ts.map +1 -0
  88. package/dist/document/format.js +52 -0
  89. package/dist/document/index.d.ts +13 -0
  90. package/dist/document/index.d.ts.map +1 -0
  91. package/dist/document/index.js +12 -0
  92. package/dist/document/registry.d.ts +20 -0
  93. package/dist/document/registry.d.ts.map +1 -0
  94. package/dist/document/registry.js +20 -0
  95. package/dist/document/smart-buttons.d.ts +16 -0
  96. package/dist/document/smart-buttons.d.ts.map +1 -0
  97. package/dist/document/smart-buttons.js +16 -0
  98. package/dist/document/status-badge.d.ts +16 -0
  99. package/dist/document/status-badge.d.ts.map +1 -0
  100. package/dist/document/status-badge.js +23 -0
  101. package/dist/document/types.d.ts +196 -0
  102. package/dist/document/types.d.ts.map +1 -0
  103. package/dist/document/types.js +1 -0
  104. package/dist/document/use-document-sources.d.ts +14 -0
  105. package/dist/document/use-document-sources.d.ts.map +1 -0
  106. package/dist/document/use-document-sources.js +72 -0
  107. package/dist/dynamic-columns.d.ts.map +1 -1
  108. package/dist/dynamic-columns.js +3 -1
  109. package/dist/dynamic-relation.d.ts +4 -0
  110. package/dist/dynamic-relation.d.ts.map +1 -1
  111. package/dist/dynamic-relation.js +10 -1
  112. package/dist/dynamic-row-actions.d.ts.map +1 -1
  113. package/dist/dynamic-row-actions.js +14 -2
  114. package/dist/dynamic-table.d.ts.map +1 -1
  115. package/dist/dynamic-table.js +28 -3
  116. package/dist/index.d.ts +5 -0
  117. package/dist/index.d.ts.map +1 -1
  118. package/dist/index.js +6 -0
  119. package/dist/reason-prompt.d.ts +59 -0
  120. package/dist/reason-prompt.d.ts.map +1 -0
  121. package/dist/reason-prompt.js +111 -0
  122. package/dist/server-error.d.ts.map +1 -1
  123. package/dist/server-error.js +19 -0
  124. package/dist/use-addon-settings.d.ts +17 -3
  125. package/dist/use-addon-settings.d.ts.map +1 -1
  126. package/dist/use-addon-settings.js +20 -8
  127. package/package.json +1 -1
  128. package/src/__tests__/approval-gate.test.tsx +134 -0
  129. package/src/__tests__/prefill-from-record.test.ts +69 -0
  130. package/src/__tests__/reason-prompt.test.tsx +121 -0
  131. package/src/__tests__/use-addon-settings.test.ts +16 -1
  132. package/src/action-modal-dispatcher.tsx +145 -3
  133. package/src/activity-diff.tsx +10 -1
  134. package/src/approval-events.ts +39 -0
  135. package/src/approval-gate.tsx +180 -0
  136. package/src/approval-pin-dialog.tsx +162 -0
  137. package/src/business/business.test.ts +210 -0
  138. package/src/business/catalog.ts +68 -0
  139. package/src/business/customer-picker.tsx +201 -0
  140. package/src/business/feedback.tsx +141 -0
  141. package/src/business/field-errors.ts +92 -0
  142. package/src/business/format.ts +151 -0
  143. package/src/business/index.ts +15 -0
  144. package/src/business/line-items-editor.tsx +259 -0
  145. package/src/business/line-items.ts +190 -0
  146. package/src/business/payment-capture.tsx +158 -0
  147. package/src/business/payment.ts +125 -0
  148. package/src/business/product-picker.tsx +171 -0
  149. package/src/business/product-search.ts +108 -0
  150. package/src/business/refund-destination.tsx +121 -0
  151. package/src/business/refund.ts +72 -0
  152. package/src/business/return-wizard.tsx +223 -0
  153. package/src/business/return.ts +181 -0
  154. package/src/business/use-async-search.ts +43 -0
  155. package/src/document/__tests__/document-page.test.tsx +107 -0
  156. package/src/document/__tests__/evaluate.test.ts +140 -0
  157. package/src/document/action-bar.tsx +140 -0
  158. package/src/document/cancel-with-reason.tsx +214 -0
  159. package/src/document/document-header.tsx +140 -0
  160. package/src/document/document-page.tsx +268 -0
  161. package/src/document/document-timeline.tsx +66 -0
  162. package/src/document/evaluate.ts +180 -0
  163. package/src/document/format.ts +59 -0
  164. package/src/document/index.ts +28 -0
  165. package/src/document/registry.ts +39 -0
  166. package/src/document/smart-buttons.tsx +54 -0
  167. package/src/document/status-badge.tsx +44 -0
  168. package/src/document/types.ts +203 -0
  169. package/src/document/use-document-sources.ts +91 -0
  170. package/src/dynamic-columns.tsx +5 -1
  171. package/src/dynamic-relation.tsx +14 -1
  172. package/src/dynamic-row-actions.tsx +13 -1
  173. package/src/dynamic-table.tsx +22 -2
  174. package/src/index.ts +33 -0
  175. package/src/reason-prompt.tsx +247 -0
  176. package/src/server-error.ts +20 -0
  177. package/src/use-addon-settings.ts +26 -7
@@ -0,0 +1,66 @@
1
+ // DocumentTimeline — línea de tiempo de eventos de negocio del documento
2
+ // («creó», «timbró», «envió a…», «abrió», «pagó», «solicitó cancelación»,
3
+ // «respuesta del SAT»). Complementa a <RecordHistory> (que muestra diffs de
4
+ // campos): aquí cada entrada es un hecho legible, con actor y hora.
5
+ import { useTranslation } from 'react-i18next'
6
+ import { Circle } from 'lucide-react'
7
+ import { cn } from '@asteby/metacore-ui/lib'
8
+ import { DynamicIcon } from '../dynamic-icon'
9
+ import { formatFieldValue, type FieldFormatOptions } from './format'
10
+ import type { StatusTone } from './types'
11
+
12
+ export interface TimelineEntry {
13
+ id: string
14
+ at: string
15
+ /** Texto ya redactado («Timbró la factura»). */
16
+ label: string
17
+ actor?: string
18
+ detail?: string
19
+ icon?: string
20
+ tone?: StatusTone
21
+ }
22
+
23
+ export interface DocumentTimelineProps extends FieldFormatOptions {
24
+ entries: TimelineEntry[]
25
+ /** Texto del estado vacío; lleva `emptyAction` si el host da una acción. */
26
+ emptyText?: string
27
+ className?: string
28
+ }
29
+
30
+ const TONE_DOT: Record<StatusTone, string> = {
31
+ neutral: 'text-muted-foreground',
32
+ info: 'text-[var(--info,oklch(0.55_0.13_235))]',
33
+ success: 'text-[var(--success,oklch(0.6_0.15_155))]',
34
+ caution: 'text-[var(--warning,oklch(0.6_0.15_65))]',
35
+ warning: 'text-[var(--warning,oklch(0.6_0.15_65))]',
36
+ critical: 'text-destructive',
37
+ }
38
+
39
+ export function DocumentTimeline({ entries, emptyText, className, ...fmt }: DocumentTimelineProps) {
40
+ const { t } = useTranslation()
41
+ if (entries.length === 0) {
42
+ return (
43
+ <p className='rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground'>
44
+ {emptyText ?? t('document.timeline_empty', { defaultValue: 'Aún no hay actividad en este documento.' })}
45
+ </p>
46
+ )
47
+ }
48
+ const sorted = [...entries].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
49
+ return (
50
+ <ol className={cn('relative flex flex-col gap-4 border-l pl-5', className)} data-document-timeline=''>
51
+ {sorted.map((e) => (
52
+ <li key={e.id} className='relative'>
53
+ <span className={cn('absolute -left-[1.85rem] flex h-5 w-5 items-center justify-center rounded-full bg-card', TONE_DOT[e.tone ?? 'neutral'])}>
54
+ {e.icon ? <DynamicIcon name={e.icon} className='h-4 w-4' /> : <Circle className='h-3 w-3 fill-current' />}
55
+ </span>
56
+ <p className='text-sm font-medium'>{t(e.label, { defaultValue: e.label })}</p>
57
+ <p className='text-xs text-muted-foreground'>
58
+ {formatFieldValue(e.at, 'datetime', fmt)}
59
+ {e.actor ? ` · ${e.actor}` : ''}
60
+ </p>
61
+ {e.detail ? <p className='mt-1 whitespace-pre-wrap text-sm text-muted-foreground'>{e.detail}</p> : null}
62
+ </li>
63
+ ))}
64
+ </ol>
65
+ )
66
+ }
@@ -0,0 +1,180 @@
1
+ import type {
2
+ ActionLayout,
3
+ DocumentActionDef,
4
+ DocumentContext,
5
+ DocumentSpec,
6
+ Predicate,
7
+ ResolvedAction,
8
+ ResolvedActionLayout,
9
+ StatusDef,
10
+ StatusMachine,
11
+ When,
12
+ } from './types'
13
+
14
+ /** Máximo de secundarias visibles; el resto pasa a «Más…» (§5.2 / §6.2). */
15
+ export const MAX_VISIBLE_SECONDARY = 3
16
+
17
+ /**
18
+ * Lee una ruta del contexto. `$.x` son los derivados; `sources.k.length` es el
19
+ * total de una fuente; el resto cuelga del registro (`record.x` o `x`).
20
+ */
21
+ export function readPath(ctx: DocumentContext, path: string): unknown {
22
+ if (!path) return undefined
23
+ let parts = path.split('.')
24
+ let cur: unknown
25
+ if (parts[0] === '$') {
26
+ cur = ctx.derived
27
+ parts = parts.slice(1)
28
+ } else if (parts[0] === 'sources') {
29
+ cur = ctx.sources
30
+ parts = parts.slice(1)
31
+ } else {
32
+ cur = ctx.record
33
+ if (parts[0] === 'record') parts = parts.slice(1)
34
+ }
35
+ for (const p of parts) {
36
+ if (cur == null) return undefined
37
+ if (p === 'length' && Array.isArray(cur)) {
38
+ cur = cur.length
39
+ continue
40
+ }
41
+ cur = (cur as Record<string, unknown>)[p]
42
+ }
43
+ return cur
44
+ }
45
+
46
+ function sameValue(a: unknown, b: unknown): boolean {
47
+ if (a == null || b == null) return a == b
48
+ return String(a) === String(b)
49
+ }
50
+
51
+ function isTruthy(v: unknown): boolean {
52
+ if (v == null || v === false || v === '' || v === 0) return false
53
+ if (Array.isArray(v)) return v.length > 0
54
+ return true
55
+ }
56
+
57
+ function toNumber(v: unknown): number {
58
+ const n = typeof v === 'number' ? v : parseFloat(String(v))
59
+ return Number.isFinite(n) ? n : NaN
60
+ }
61
+
62
+ export function evaluatePredicate(p: Predicate, ctx: DocumentContext): boolean {
63
+ const v = readPath(ctx, p.field)
64
+ switch (p.op) {
65
+ case 'eq':
66
+ return sameValue(v, p.value)
67
+ case 'neq':
68
+ return !sameValue(v, p.value)
69
+ case 'in':
70
+ return Array.isArray(p.value) && p.value.some((x) => sameValue(v, x))
71
+ case 'not_in':
72
+ return !(Array.isArray(p.value) && p.value.some((x) => sameValue(v, x)))
73
+ case 'truthy':
74
+ return isTruthy(v)
75
+ case 'falsy':
76
+ return !isTruthy(v)
77
+ case 'gt':
78
+ return toNumber(v) > toNumber(p.value)
79
+ case 'gte':
80
+ return toNumber(v) >= toNumber(p.value)
81
+ case 'lt':
82
+ return toNumber(v) < toNumber(p.value)
83
+ case 'lte':
84
+ return toNumber(v) <= toNumber(p.value)
85
+ default:
86
+ return false
87
+ }
88
+ }
89
+
90
+ /** Un arreglo es un AND; sin condición siempre se cumple. */
91
+ export function evaluateWhen(when: When | undefined, ctx: DocumentContext): boolean {
92
+ if (!when) return true
93
+ const list = Array.isArray(when) ? when : [when]
94
+ return list.every((p) => evaluatePredicate(p, ctx))
95
+ }
96
+
97
+ /** Estado actual de una máquina para el registro (o su fallback / un estado desconocido explícito). */
98
+ export function resolveStatus(machine: StatusMachine, ctx: DocumentContext): StatusDef | undefined {
99
+ const raw = readPath(ctx, machine.field)
100
+ const value = raw == null ? '' : String(raw)
101
+ const found = machine.states.find((s) => s.value === value)
102
+ if (found) return found
103
+ if (machine.fallback) return machine.fallback
104
+ if (!value) return undefined
105
+ return { value, label: value, tone: 'neutral' }
106
+ }
107
+
108
+ /**
109
+ * Distribución de acciones para el estado actual. Una acción `hiddenWhen` se
110
+ * omite; una `blockedWhen` queda deshabilitada con motivo. Más de
111
+ * `MAX_VISIBLE_SECONDARY` secundarias se derraman a «Más…»; una acción marcada
112
+ * `destructive` siempre va a la zona destructiva aunque el layout la ponga en otro lado.
113
+ */
114
+ export function resolveActions(spec: DocumentSpec, ctx: DocumentContext): ResolvedActionLayout {
115
+ const byKey = new Map<string, DocumentActionDef>(spec.actions.map((a) => [a.key, a]))
116
+ const layout: ActionLayout =
117
+ spec.layouts.find((r) => evaluateWhen(r.when, ctx))?.layout ?? spec.defaultLayout ?? {}
118
+
119
+ const resolve = (key: string | undefined): ResolvedAction | undefined => {
120
+ if (!key) return undefined
121
+ const def = byKey.get(key)
122
+ if (!def || (def.hiddenWhen && evaluateWhen(def.hiddenWhen, ctx))) return undefined
123
+ const rules = def.blockedWhen ? (Array.isArray(def.blockedWhen) ? def.blockedWhen : [def.blockedWhen]) : []
124
+ const hit = rules.find((r) => evaluateWhen(r.when, ctx))
125
+ return { def, blockedReason: hit?.reason }
126
+ }
127
+ const many = (keys: string[] | undefined) =>
128
+ (keys ?? []).map(resolve).filter((a): a is ResolvedAction => !!a)
129
+
130
+ const destructive = many(layout.destructive)
131
+ const secondary: ResolvedAction[] = []
132
+ const more: ResolvedAction[] = []
133
+ for (const a of [...many(layout.secondary)]) {
134
+ if (a.def.destructive) destructive.push(a)
135
+ else secondary.push(a)
136
+ }
137
+ for (const a of many(layout.more)) {
138
+ if (a.def.destructive) destructive.push(a)
139
+ else more.push(a)
140
+ }
141
+ const overflow = secondary.splice(MAX_VISIBLE_SECONDARY)
142
+ more.unshift(...overflow)
143
+
144
+ let primary = resolve(layout.primary)
145
+ if (primary?.def.destructive) {
146
+ destructive.unshift(primary)
147
+ primary = undefined
148
+ }
149
+ return { primary, secondary, more, destructive }
150
+ }
151
+
152
+ /** Interpola `{{campo}}` con valores del registro (sin evaluar código). */
153
+ export function interpolate(template: string, ctx: DocumentContext): string {
154
+ return template.replace(/\{\{\s*([\w.$]+)\s*\}\}/g, (_, path: string) => {
155
+ const v = readPath(ctx, path)
156
+ return v == null ? '' : String(v)
157
+ })
158
+ }
159
+
160
+ /** Título: la interpolación puede dejar vacío el resultado; se prueban alternativas separadas por `||`. */
161
+ export function resolveTitle(template: string, ctx: DocumentContext): string {
162
+ for (const alt of template.split('||')) {
163
+ const out = interpolate(alt.trim(), ctx).trim()
164
+ if (out) return out
165
+ }
166
+ return ''
167
+ }
168
+
169
+ /**
170
+ * Quita del spec las acciones que dependen de una acción del modelo que el
171
+ * backend (o el addon que la aporta) no declara: mejor un botón menos que uno
172
+ * que abre un modal vacío. Las que resuelve la propia página (`href`/`openUrl`/`tab`) siempre se conservan.
173
+ */
174
+ export function pruneUnavailableActions(spec: DocumentSpec, availableModelActions: Iterable<string>): DocumentSpec {
175
+ const have = new Set(availableModelActions)
176
+ return {
177
+ ...spec,
178
+ actions: spec.actions.filter((a) => a.href || a.openUrl || a.tab || have.has(a.modelAction ?? a.key)),
179
+ }
180
+ }
@@ -0,0 +1,59 @@
1
+ import type { DocumentFieldDef } from './types'
2
+
3
+ export interface FieldFormatOptions {
4
+ locale?: string
5
+ currency?: string
6
+ timeZone?: string
7
+ }
8
+
9
+ /** Formatea un valor de campo; vacío → «—». Moneda y zona horaria son config de la org, no se hardcodean. */
10
+ export function formatFieldValue(
11
+ value: unknown,
12
+ format: DocumentFieldDef['format'] = 'text',
13
+ opts: FieldFormatOptions = {},
14
+ ): string {
15
+ if (value === null || value === undefined || value === '') return '—'
16
+ const locale = opts.locale || 'es-MX'
17
+ // Relación resuelta por el kernel: `{value,label}` o un objeto con `name`.
18
+ if (typeof value === 'object' && !(value instanceof Date)) {
19
+ const o = value as Record<string, unknown>
20
+ const text = o.label ?? o.name ?? o.title
21
+ return text == null || text === '' ? '—' : String(text)
22
+ }
23
+ switch (format) {
24
+ case 'money': {
25
+ const n = typeof value === 'number' ? value : parseFloat(String(value))
26
+ if (!Number.isFinite(n)) return '—'
27
+ try {
28
+ return new Intl.NumberFormat(locale, {
29
+ style: 'currency',
30
+ currency: opts.currency || 'USD',
31
+ }).format(n)
32
+ } catch {
33
+ return n.toFixed(2)
34
+ }
35
+ }
36
+ case 'percent': {
37
+ const n = typeof value === 'number' ? value : parseFloat(String(value))
38
+ return Number.isFinite(n) ? `${n}%` : '—'
39
+ }
40
+ case 'date':
41
+ case 'datetime': {
42
+ const d = new Date(String(value))
43
+ if (Number.isNaN(d.getTime())) return String(value)
44
+ try {
45
+ return new Intl.DateTimeFormat(locale, {
46
+ dateStyle: 'medium',
47
+ ...(format === 'datetime' ? { timeStyle: 'short' } : {}),
48
+ timeZone: opts.timeZone || undefined,
49
+ }).format(d)
50
+ } catch {
51
+ return d.toISOString()
52
+ }
53
+ }
54
+ case 'boolean':
55
+ return value ? 'Sí' : 'No'
56
+ default:
57
+ return String(value)
58
+ }
59
+ }
@@ -0,0 +1,28 @@
1
+ export * from './types'
2
+ export {
3
+ MAX_VISIBLE_SECONDARY,
4
+ evaluatePredicate,
5
+ evaluateWhen,
6
+ interpolate,
7
+ pruneUnavailableActions,
8
+ readPath,
9
+ resolveActions,
10
+ resolveStatus,
11
+ resolveTitle,
12
+ } from './evaluate'
13
+ export { formatFieldValue, type FieldFormatOptions } from './format'
14
+ export { StatusBadge, STATUS_TONE_STYLE, type StatusBadgeProps } from './status-badge'
15
+ export { ActionBar, type ActionBarProps } from './action-bar'
16
+ export { DocumentHeader, type DocumentHeaderProps, type HeaderBadge, type HeaderMetric } from './document-header'
17
+ export { SmartButtons, type SmartButtonItem, type SmartButtonsProps } from './smart-buttons'
18
+ export { DocumentTimeline, type DocumentTimelineProps, type TimelineEntry } from './document-timeline'
19
+ export { CancelWithReason, type CancelPayload, type CancelReason, type CancelWithReasonProps } from './cancel-with-reason'
20
+ export { DocumentPage, type DocumentPageProps } from './document-page'
21
+ export { useDocumentSources, type DocumentSourcesState } from './use-document-sources'
22
+ export {
23
+ clearDocumentPages,
24
+ findStatusMachine,
25
+ getDocumentPage,
26
+ registerDocumentPage,
27
+ type DocumentPageRegistration,
28
+ } from './registry'
@@ -0,0 +1,39 @@
1
+ // Registro por modelo de la «página de documento». Igual que
2
+ // `registerModelExtension`: singleton de módulo, se registra una vez al arrancar
3
+ // (o desde el `register()` federado de un addon) y la ruta de detalle del host
4
+ // compone la página sin código por modelo.
5
+ import type { DocumentContext, DocumentSpec, StatusMachine } from './types'
6
+
7
+ export interface DocumentPageRegistration {
8
+ spec: DocumentSpec
9
+ /**
10
+ * Calcula derivados (`$.x`) a partir del registro y las fuentes cargadas —
11
+ * p. ej. el estado fiscal a partir de fiscal_documents y el de cobro a
12
+ * partir de saldo y vencimiento. Función pura, sin efectos.
13
+ */
14
+ derive?: (ctx: Omit<DocumentContext, 'derived'>) => Record<string, unknown>
15
+ }
16
+
17
+ const registry = new Map<string, DocumentPageRegistration>()
18
+
19
+ /**
20
+ * Registra la página bajo `spec.model` y, opcionalmente, bajo alias (la ruta
21
+ * `/m/<model>` puede llegar como `invoices` o `Invoice` según el enlace).
22
+ */
23
+ export function registerDocumentPage(reg: DocumentPageRegistration, aliases: string[] = []): void {
24
+ registry.set(reg.spec.model, reg)
25
+ for (const a of aliases) registry.set(a, reg)
26
+ }
27
+
28
+ export function getDocumentPage(model: string): DocumentPageRegistration | undefined {
29
+ return registry.get(model)
30
+ }
31
+
32
+ export function clearDocumentPages(): void {
33
+ registry.clear()
34
+ }
35
+
36
+ /** Catálogo compartido de estados por id de máquina (lo usan las columnas `status` de las pestañas). */
37
+ export function findStatusMachine(spec: DocumentSpec, id: string): StatusMachine | undefined {
38
+ return spec.statuses.find((s) => s.id === id) ?? spec.catalogs?.find((s) => s.id === id)
39
+ }
@@ -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