@asteby/metacore-runtime-react 48.0.0 → 49.0.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 (90) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/action-modal-dispatcher.d.ts +17 -3
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +57 -10
  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 +65 -17
  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/document/document-page.d.ts.map +1 -1
  20. package/dist/document/document-page.js +8 -1
  21. package/dist/document/evaluate.d.ts.map +1 -1
  22. package/dist/document/evaluate.js +17 -1
  23. package/dist/document/index.d.ts +1 -0
  24. package/dist/document/index.d.ts.map +1 -1
  25. package/dist/document/index.js +1 -0
  26. package/dist/document/secondary-bar.d.ts +20 -0
  27. package/dist/document/secondary-bar.d.ts.map +1 -0
  28. package/dist/document/secondary-bar.js +44 -0
  29. package/dist/document/types.d.ts +8 -0
  30. package/dist/document/types.d.ts.map +1 -1
  31. package/dist/document-form-dialog.d.ts.map +1 -1
  32. package/dist/document-form-dialog.js +7 -4
  33. package/dist/dynamic-columns.d.ts +1 -11
  34. package/dist/dynamic-columns.d.ts.map +1 -1
  35. package/dist/dynamic-columns.js +7 -17
  36. package/dist/dynamic-crud-page.d.ts +12 -0
  37. package/dist/dynamic-crud-page.d.ts.map +1 -1
  38. package/dist/dynamic-crud-page.js +3 -3
  39. package/dist/dynamic-row-actions.d.ts +8 -0
  40. package/dist/dynamic-row-actions.d.ts.map +1 -1
  41. package/dist/dynamic-row-actions.js +12 -2
  42. package/dist/dynamic-table.d.ts.map +1 -1
  43. package/dist/dynamic-table.js +64 -63
  44. package/dist/index.d.ts +2 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +6 -0
  47. package/dist/primitives/contributions.d.ts +14 -3
  48. package/dist/primitives/contributions.d.ts.map +1 -1
  49. package/dist/primitives/contributions.js +50 -13
  50. package/dist/record-actions.d.ts +21 -0
  51. package/dist/record-actions.d.ts.map +1 -0
  52. package/dist/record-actions.js +76 -0
  53. package/dist/row-actions-menu.d.ts +32 -0
  54. package/dist/row-actions-menu.d.ts.map +1 -0
  55. package/dist/row-actions-menu.js +59 -0
  56. package/dist/slot.d.ts +4 -21
  57. package/dist/slot.d.ts.map +1 -1
  58. package/dist/slot.js +14 -36
  59. package/dist/types.d.ts +32 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/package.json +3 -3
  62. package/src/__tests__/document-editor-model.test.ts +55 -1
  63. package/src/__tests__/document-editor.test.tsx +80 -1
  64. package/src/__tests__/document-form-dialog.test.tsx +40 -0
  65. package/src/__tests__/prefill-scalar-from-record.test.ts +52 -1
  66. package/src/__tests__/row-action-link-navigation.test.ts +17 -0
  67. package/src/__tests__/secondary-actions.test.tsx +261 -0
  68. package/src/action-modal-dispatcher.tsx +58 -16
  69. package/src/action-priority.ts +82 -0
  70. package/src/business/business.test.ts +1 -1
  71. package/src/business/catalog-product-search.ts +18 -0
  72. package/src/business/document-editor-model.ts +48 -6
  73. package/src/business/document-editor.tsx +72 -18
  74. package/src/business/line-items.ts +16 -1
  75. package/src/document/document-page.tsx +15 -0
  76. package/src/document/evaluate.ts +17 -1
  77. package/src/document/index.ts +1 -0
  78. package/src/document/secondary-bar.tsx +92 -0
  79. package/src/document/types.ts +8 -0
  80. package/src/document-form-dialog.tsx +8 -4
  81. package/src/dynamic-columns.tsx +13 -57
  82. package/src/dynamic-crud-page.tsx +11 -2
  83. package/src/dynamic-row-actions.tsx +12 -2
  84. package/src/dynamic-table.tsx +3 -0
  85. package/src/index.ts +25 -0
  86. package/src/primitives/contributions.ts +59 -12
  87. package/src/record-actions.ts +101 -0
  88. package/src/row-actions-menu.tsx +159 -0
  89. package/src/slot.tsx +18 -53
  90. package/src/types.ts +28 -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
  }
@@ -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,
@@ -142,6 +144,10 @@ export function DocumentEditor({
142
144
  const [attempted, setAttempted] = useState(false)
143
145
  const [sourceKey, setSourceKey] = useState<string>(initialSource?.key ?? '')
144
146
  const [sourceId, setSourceId] = useState<string>(initialSource?.id ?? '')
147
+ // Fuente de la que salieron los renglones: su `line_link_field` viaja en cada
148
+ // renglón para que el servidor descuente lo ya facturado/devuelto y valide.
149
+ const [loadedSource, setLoadedSource] = useState<DocumentFormSource | null>(null)
150
+ const [sourceNote, setSourceNote] = useState<string | undefined>()
145
151
  const relCache = useRef(new Map<string, Promise<any[]>>())
146
152
 
147
153
  const taxRate = defaultTaxRate ?? orgTaxRate
@@ -273,10 +279,13 @@ export function DocumentEditor({
273
279
  const p: Record<string, unknown> = { ...(type.defaults ?? {}), ...header }
274
280
  if (forms.type_field && !type.submit_action) p[forms.type_field] = type.value ?? type.key
275
281
  if (lineCfg) {
276
- p[lineCfg.field] = isAllocation && openCfg ? allocationPayload(allocation.allocations, openCfg) : serializeLineItems(lines)
282
+ p[lineCfg.field] =
283
+ isAllocation && openCfg
284
+ ? allocationPayload(allocation.allocations, openCfg)
285
+ : serializeLineItems(lines, { sourceLineField: loadedSource?.line_link_field })
277
286
  }
278
287
  return p
279
- }, [type, header, forms.type_field, lineCfg, isAllocation, openCfg, allocation, lines])
288
+ }, [type, header, forms.type_field, lineCfg, isAllocation, openCfg, allocation, lines, loadedSource])
280
289
 
281
290
  const baseUrl = type.endpoint ?? endpoint ?? `/dynamic/${model}`
282
291
 
@@ -304,27 +313,72 @@ export function DocumentEditor({
304
313
  }
305
314
 
306
315
  // ---- Cargar desde… --------------------------------------------------------
316
+ /**
317
+ * Renglones del origen. Con lo pendiente declarado (`line_link_field`,
318
+ * `remaining_qty_field` o `remaining_endpoint`) los sirve el host ya con
319
+ * `remaining_quantity` (cantidad − lo ya facturado/devuelto, calculado en el
320
+ * servidor); si el host aún no tiene `source-lines` (404) o la fuente no lo
321
+ * declara, se leen de la relación como antes.
322
+ */
323
+ const fetchSourceRows = useCallback(
324
+ async (src: DocumentFormSource, id: string): Promise<unknown[]> => {
325
+ if (src.remaining_endpoint) {
326
+ const res = await api.get(src.remaining_endpoint, { params: { id } })
327
+ return res?.data?.data ?? []
328
+ }
329
+ if (sourceTracksRemaining(src)) {
330
+ try {
331
+ const res = await api.get(`${baseUrl}/source-lines`, {
332
+ params: { source: src.key, id, ...(draftId ? { exclude: draftId } : {}) },
333
+ })
334
+ return res?.data?.data ?? []
335
+ } catch (err: any) {
336
+ if (err?.response?.status !== 404) throw err
337
+ }
338
+ }
339
+ let rels = relCache.current.get(src.model)
340
+ if (!rels) {
341
+ rels = api
342
+ .get(`/metadata/table/${src.model}`)
343
+ .then((r: any) => r?.data?.data?.relations ?? r?.data?.relations ?? [])
344
+ relCache.current.set(src.model, rels)
345
+ }
346
+ const call = prefillFromFieldRelationRequest(
347
+ { fieldKey: lineCfg?.field ?? 'lines', refModel: src.model, refId: id, spec: { $prefillFromRecord: src.lines } },
348
+ await rels,
349
+ )
350
+ if (!call) throw new Error(`${src.model}: falta la relación «${src.lines}»`)
351
+ const res = await api.get(call.endpoint, { params: call.params })
352
+ return res?.data?.data ?? []
353
+ },
354
+ [api, baseUrl, draftId, lineCfg?.field],
355
+ )
356
+
307
357
  const loadFromSource = useCallback(
308
358
  async (src: DocumentFormSource, id: string) => {
309
359
  if (!lineCfg || !id) return
310
360
  setBusy('source')
311
361
  try {
312
362
  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 }))
363
+ const rows = await fetchSourceRows(src, id)
364
+ const loaded = linesFromSource(rows, src.map, { kind, discountMode: lineCfg.discount_mode })
365
+ const { covered } = sourceLoadSummary(rows)
366
+ setLines(loaded)
367
+ setLoadedSource(src)
327
368
  setLinesFromSourceDoc(kind === 'credit')
369
+ setSourceNote(
370
+ loaded.length === 0 && covered > 0
371
+ ? t('documentEditor.source_all_covered', {
372
+ defaultValue: 'Todo el documento ya está registrado en otros documentos; no queda nada pendiente.',
373
+ })
374
+ : covered > 0
375
+ ? t('documentEditor.source_loaded_partial', {
376
+ defaultValue: '{{count}} renglones con lo pendiente · {{covered}} ya cubiertos se omitieron',
377
+ count: loaded.length,
378
+ covered,
379
+ })
380
+ : t('documentEditor.source_loaded', { defaultValue: '{{count}} renglones cargados', count: loaded.length }),
381
+ )
328
382
  }
329
383
  if (src.header || src.link_field) {
330
384
  const h = await api.get(`/data/${src.model}`, { params: { f_id: `eq:${id}`, per_page: 1 } })
@@ -345,7 +399,7 @@ export function DocumentEditor({
345
399
  setBusy(null)
346
400
  }
347
401
  },
348
- [api, lineCfg, isAllocation, kind, t],
402
+ [api, lineCfg, isAllocation, kind, t, fetchSourceRows],
349
403
  )
350
404
 
351
405
  // Prefill al abrir desde un documento origen (acción de fila «Crear NC», «Facturar venta»…).
@@ -538,7 +592,7 @@ export function DocumentEditor({
538
592
  <EditorSection
539
593
  slot="load-from-source"
540
594
  title={t('documentEditor.load_from', { defaultValue: 'Cargar desde…' })}
541
- hint={busy === 'source' ? t('documentEditor.loading_source', { defaultValue: 'Cargando…' }) : undefined}
595
+ hint={busy === 'source' ? t('documentEditor.loading_source', { defaultValue: 'Cargando…' }) : sourceNote}
542
596
  >
543
597
  <div className="flex flex-wrap items-center gap-2">
544
598
  {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
  })
@@ -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
@@ -43,7 +43,7 @@ import { validateValues, bagHasErrors } from './validator'
43
43
  import { clearFieldErrorTree } from './field-validation-ui'
44
44
  import { emitRecordMutation } from './record-mutation-events'
45
45
  import { isLineItemsField, resolveWidget, scopeValueFromFilterToken } from './dynamic-form-schema'
46
- import { createCatalogProductSearch } from './business/catalog-product-search'
46
+ import { createCatalogProductSearch, withDefaultTaxRate } from './business/catalog-product-search'
47
47
  import { DocumentEditor } from './business/document-editor'
48
48
  import { editorLinesConfig, isEditorLayout } from './business/document-editor-model'
49
49
  import type {
@@ -149,10 +149,14 @@ export function DocumentFormDialog({
149
149
  const type = useMemo(() => types.find((x) => x.key === typeKey) ?? null, [types, typeKey])
150
150
  // Sin buscador inyectado, el paso de renglones solo ofrecía «Renglón libre»
151
151
  // y la línea entraba sin precio (DynamicCRUDPage no pasa searchProducts).
152
- // Default: el catálogo de productos de la org.
152
+ // Default: el catálogo de productos de la org. El buscador del host también
153
+ // recibe el IVA de la org para los productos que no traen su tasa.
153
154
  const taxRate = defaultTaxRate ?? orgTaxRate
154
155
  const lineSearch = useMemo(
155
- () => searchProducts ?? createCatalogProductSearch(api, { model: productModel, defaultTaxRate: taxRate }),
156
+ () =>
157
+ searchProducts
158
+ ? withDefaultTaxRate(searchProducts, taxRate)
159
+ : createCatalogProductSearch(api, { model: productModel, defaultTaxRate: taxRate }),
156
160
  [searchProducts, api, productModel, taxRate],
157
161
  )
158
162
  const lineCfg = type ? editorLinesConfig(type, forms) : undefined
@@ -278,7 +282,7 @@ export function DocumentFormDialog({
278
282
 
279
283
  // layout "editor": una sola pantalla por secciones (DocumentEditor). Sin él
280
284
  // sigue el wizard de siempre (retrocompatible).
281
- if (open && type && step !== 'type' && isEditorLayout(type)) {
285
+ if (open && type && step !== 'type' && (isEditorLayout(type) || !!initialSource)) {
282
286
  return (
283
287
  <Dialog open={open} onOpenChange={onOpenChange}>
284
288
  <DialogContent
@@ -17,7 +17,7 @@ import { ColumnDef } from '@tanstack/react-table'
17
17
  import { format, type Locale } from 'date-fns'
18
18
  import { isCalendarDayValue, parseCalendarDate } from './calendar-date'
19
19
  import { es, enUS } from 'date-fns/locale'
20
- import { Calendar, Check, Copy, Mail, Minus, MoreHorizontal } from 'lucide-react'
20
+ import { Calendar, Check, Copy, Mail, Minus } from 'lucide-react'
21
21
  import {
22
22
  Avatar,
23
23
  AvatarFallback,
@@ -25,10 +25,6 @@ import {
25
25
  Badge,
26
26
  Button,
27
27
  Checkbox,
28
- DropdownMenu,
29
- DropdownMenuContent,
30
- DropdownMenuItem,
31
- DropdownMenuTrigger,
32
28
  InitialsAvatar,
33
29
  } from '@asteby/metacore-ui'
34
30
  import {
@@ -57,7 +53,7 @@ import { DynamicIcon, isLucideIconName } from './dynamic-icon'
57
53
  import { CollectionCell } from './collection-cell'
58
54
  import { isNilUuid, normalizeNilUuid } from './nil-uuid'
59
55
  import { useOptionsResolver } from './use-options-resolver'
60
- import { RequiresAddonLock, resolveRequiresAddon, useRequiresAddonLabel } from './requires-addon'
56
+ import { RowActionsMenu } from './row-actions-menu'
61
57
  import type { TableMetadata, ColumnDefinition } from './types'
62
58
  import { isColumnVisibleInTable } from './column-visibility'
63
59
  import type {
@@ -208,35 +204,9 @@ const CodeCell: React.FC<{ text: string; maxLength?: number }> = ({ text, maxLen
208
204
  )
209
205
  }
210
206
 
211
- /**
212
- * One entry of the row "…" menu. An action gated by a missing optional addon
213
- * (`requiresAddon`) stays listed with a lock + tooltip; the click still goes to
214
- * `onAction`, whose shared handler (useDynamicRowActions) opens the
215
- * requires-addon dialog instead of executing.
216
- */
217
- export function RowActionMenuItem({
218
- action,
219
- label,
220
- onSelect,
221
- }: {
222
- action: any
223
- label: React.ReactNode
224
- onSelect: (e: React.MouseEvent) => void
225
- }) {
226
- const requirement = resolveRequiresAddon(action)
227
- const requiresAddonLabel = useRequiresAddonLabel()
228
- return (
229
- <DropdownMenuItem
230
- onClick={onSelect}
231
- title={requirement ? requiresAddonLabel(requirement) : undefined}
232
- data-requires-addon={requirement?.key}
233
- >
234
- <DynamicIcon name={action.icon || 'Zap'} className="mr-2 h-4 w-4" />
235
- {label}
236
- {requirement && <RequiresAddonLock requirement={requirement} />}
237
- </DropdownMenuItem>
238
- )
239
- }
207
+ // RowActionMenuItem vive en row-actions-menu.tsx (junto al menú que lo usa);
208
+ // se re-exporta aquí por compatibilidad (dynamic-kanban, hosts).
209
+ export { RowActionMenuItem } from './row-actions-menu'
240
210
 
241
211
  /**
242
212
  * Lifecycle column used by `requiresState`: prefer `status` (workshop, vehicles,
@@ -1571,28 +1541,14 @@ export function makeDefaultGetDynamicColumns(
1571
1541
  maxSize: 80,
1572
1542
  meta: {},
1573
1543
  cell: ({ row }) => (
1574
- <div className="flex items-center justify-end">
1575
- <DropdownMenu>
1576
- <DropdownMenuTrigger asChild>
1577
- <Button variant="ghost" className="h-8 w-8 p-0">
1578
- <span className="sr-only">Abrir menú</span>
1579
- <MoreHorizontal className="h-4 w-4" />
1580
- </Button>
1581
- </DropdownMenuTrigger>
1582
- <DropdownMenuContent align="end">
1583
- {resolvedActions
1584
- .filter((action) => isRowActionVisible(action, row.original, lifecycleStageField(metadata)))
1585
- .map((action) => (
1586
- <RowActionMenuItem
1587
- key={action.key}
1588
- action={action}
1589
- label={translateMetadataLabel(action.label, t)}
1590
- onSelect={() => onAction && onAction(action.key, row.original)}
1591
- />
1592
- ))}
1593
- </DropdownMenuContent>
1594
- </DropdownMenu>
1595
- </div>
1544
+ // Primaria destacada, principales y un «Más…» con las
1545
+ // secundarias (compartir/imprimir/correo/chat + las que
1546
+ // aporten addons instalados). Ver row-actions-menu.tsx.
1547
+ <RowActionsMenu
1548
+ actions={resolvedActions.filter((action) => isRowActionVisible(action, row.original, lifecycleStageField(metadata)))}
1549
+ row={row.original}
1550
+ onAction={onAction}
1551
+ />
1596
1552
  ),
1597
1553
  })
1598
1554
  }
@@ -102,6 +102,12 @@ export interface DynamicCRUDPageProps {
102
102
  * formulario para él.
103
103
  */
104
104
  filter?: Record<string, string>
105
+ /**
106
+ * Abre el alta guiada al montar («crear desde»): tipo de documento y origen
107
+ * a precargar (`sources[].key` + id), p. ej. leídos de la URL
108
+ * `?create=<tipo>&from=<fuente>&from_id=<id>`. Solo con document_forms.
109
+ */
110
+ initialCreate?: { type?: string; source?: { key: string; id: string } }
105
111
  }
106
112
 
107
113
  /**
@@ -126,6 +132,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
126
132
  onChange,
127
133
  documentForms,
128
134
  filter,
135
+ initialCreate,
129
136
  } = props
130
137
 
131
138
  const strings = { ...defaultStrings, ...(i18n ?? {}) }
@@ -137,7 +144,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
137
144
 
138
145
  const [metadata, setMetadata] = useState<TableMetadata | null>(cachedMeta ?? null)
139
146
  const [refreshKey, setRefreshKey] = useState(0)
140
- const [openCreate, setOpenCreate] = useState(false)
147
+ const [openCreate, setOpenCreate] = useState(!!initialCreate)
141
148
  const [openExport, setOpenExport] = useState(false)
142
149
  const [openImport, setOpenImport] = useState(false)
143
150
 
@@ -308,13 +315,15 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
308
315
  model={model}
309
316
  endpoint={dataEndpoint}
310
317
  forms={guidedForms}
318
+ initialType={initialCreate?.type}
319
+ initialSource={initialCreate?.source}
311
320
  onSaved={handleRefresh}
312
321
  />
313
322
  )}
314
323
 
315
324
  {showCreate && !guidedForms && (
316
325
  <DynamicRecordDialog
317
- open={openCreate}
326
+ open={openCreate && !initialCreate?.source}
318
327
  onOpenChange={setOpenCreate}
319
328
  mode='create'
320
329
  model={model}