@asteby/metacore-runtime-react 49.0.0 → 49.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/action-modal-dispatcher.d.ts +18 -1
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +66 -11
  5. package/dist/business/document-editor-model.d.ts +6 -1
  6. package/dist/business/document-editor-model.d.ts.map +1 -1
  7. package/dist/business/document-editor-model.js +48 -7
  8. package/dist/business/document-editor-panels.d.ts +18 -0
  9. package/dist/business/document-editor-panels.d.ts.map +1 -1
  10. package/dist/business/document-editor-panels.js +9 -0
  11. package/dist/business/document-editor.d.ts +7 -1
  12. package/dist/business/document-editor.d.ts.map +1 -1
  13. package/dist/business/document-editor.js +72 -26
  14. package/dist/calendar-date.d.ts +12 -0
  15. package/dist/calendar-date.d.ts.map +1 -1
  16. package/dist/calendar-date.js +36 -0
  17. package/dist/document-form-dialog.d.ts +23 -2
  18. package/dist/document-form-dialog.d.ts.map +1 -1
  19. package/dist/document-form-dialog.js +49 -8
  20. package/dist/dynamic-crud-page.d.ts +7 -0
  21. package/dist/dynamic-crud-page.d.ts.map +1 -1
  22. package/dist/dynamic-crud-page.js +5 -4
  23. package/dist/index.d.ts +4 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +4 -3
  26. package/dist/record-mutation-events.d.ts +12 -0
  27. package/dist/record-mutation-events.d.ts.map +1 -1
  28. package/dist/record-mutation-events.js +18 -0
  29. package/dist/types.d.ts +7 -0
  30. package/dist/types.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/__tests__/document-editor-model.test.ts +27 -0
  33. package/src/__tests__/document-editor.test.tsx +60 -0
  34. package/src/__tests__/document-form-create-model.test.tsx +143 -0
  35. package/src/__tests__/field-default-today.test.tsx +116 -0
  36. package/src/__tests__/invoice-document-editor.test.tsx +267 -0
  37. package/src/__tests__/r5-self-label-settled.test.ts +57 -0
  38. package/src/action-modal-dispatcher.tsx +78 -11
  39. package/src/business/document-editor-model.ts +50 -7
  40. package/src/business/document-editor-panels.tsx +49 -0
  41. package/src/business/document-editor.tsx +249 -166
  42. package/src/calendar-date.ts +36 -0
  43. package/src/document-form-dialog.tsx +72 -8
  44. package/src/dynamic-crud-page.tsx +20 -2
  45. package/src/index.ts +8 -0
  46. package/src/record-mutation-events.ts +23 -0
  47. package/src/types.ts +7 -0
@@ -23,7 +23,7 @@ import {
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 { DynamicIcon } from './dynamic-icon'
28
28
  import { FieldCell, FieldGrid, FieldLabel } from './field-grid'
29
29
  import { FormErrorBanner } from './business/feedback'
@@ -45,9 +45,10 @@ import { emitRecordMutation } from './record-mutation-events'
45
45
  import { isLineItemsField, resolveWidget, scopeValueFromFilterToken } from './dynamic-form-schema'
46
46
  import { createCatalogProductSearch, withDefaultTaxRate } from './business/catalog-product-search'
47
47
  import { DocumentEditor } from './business/document-editor'
48
- import { editorLinesConfig, isEditorLayout } from './business/document-editor-model'
48
+ import { editorLinesConfig, isEditorLayout, isFullscreenEditor } from './business/document-editor-model'
49
49
  import type {
50
50
  ActionFieldDef,
51
+ DocumentFormType,
51
52
  DocumentFormsManifest,
52
53
  TableMetadata,
53
54
  } from './types'
@@ -82,6 +83,39 @@ export interface DocumentFormDialogProps {
82
83
  record?: Record<string, any>
83
84
  /** Documento origen a precargar en el DocumentEditor (`sources[].key` + id). */
84
85
  initialSource?: { key: string; id: string }
86
+ /**
87
+ * Tipo con `create_model`: el alta vive en otro modelo. El host navega a la
88
+ * página de ese modelo con su alta abierta; el diálogo se cierra sin pintar
89
+ * formulario. Sin este callback un tipo delegado no se ofrece.
90
+ */
91
+ onDelegateCreate?: (target: DelegatedCreate) => void
92
+ }
93
+
94
+ /** Alta delegada a otro modelo (`document_forms.types[].create_model`). */
95
+ export interface DelegatedCreate {
96
+ /** Clave del modelo destino tal como la declara el manifest (`customers.Invoice`). */
97
+ model: string
98
+ /** Tipo que la declaró. */
99
+ type: DocumentFormType
100
+ }
101
+
102
+ /**
103
+ * Si «Crear» de estos formularios es un alta delegada: un único tipo (p. ej.
104
+ * ya acotado por el filtro de la vista) con `create_model`. Con varios tipos el
105
+ * selector la ofrece como una tarjeta más.
106
+ */
107
+ export function delegatedCreate(forms: DocumentFormsManifest | undefined): DelegatedCreate | undefined {
108
+ const only = forms?.types.length === 1 ? forms.types[0] : undefined
109
+ const model = only?.create_model?.trim()
110
+ return only && model ? { model, type: only } : undefined
111
+ }
112
+
113
+ /** Quita los tipos delegados cuando el host no sabe navegar a otro modelo. */
114
+ export function withoutDelegatedTypes(forms: DocumentFormsManifest | undefined): DocumentFormsManifest | undefined {
115
+ if (!forms) return forms
116
+ const types = forms.types.filter((x) => !x.create_model?.trim())
117
+ if (types.length === forms.types.length) return forms
118
+ return types.length > 0 ? { ...forms, types } : undefined
85
119
  }
86
120
 
87
121
  /** Manifest efectivo: la prop gana sobre el metadata; sin tipos devuelve undefined. */
@@ -130,11 +164,17 @@ export function DocumentFormDialog({
130
164
  currency,
131
165
  record,
132
166
  initialSource,
167
+ onDelegateCreate,
133
168
  }: DocumentFormDialogProps) {
134
169
  const { t, i18n } = useTranslation()
135
170
  const api = useApi()
136
171
  const orgTaxRate = useOrgTaxRate()
137
- const types = forms.types
172
+ const orgTimeZone = useTimeZone()
173
+ // Sin onDelegateCreate el host no sabe abrir otro modelo: sus tipos no se ofrecen.
174
+ const types = useMemo(
175
+ () => (onDelegateCreate ? forms.types : forms.types.filter((x) => !x.create_model?.trim())),
176
+ [forms.types, onDelegateCreate],
177
+ )
138
178
  const single = types.length === 1 ? types[0] : undefined
139
179
 
140
180
  const [typeKey, setTypeKey] = useState<string | null>(initialType ?? single?.key ?? null)
@@ -147,6 +187,16 @@ export function DocumentFormDialog({
147
187
  const [saving, setSaving] = useState(false)
148
188
 
149
189
  const type = useMemo(() => types.find((x) => x.key === typeKey) ?? null, [types, typeKey])
190
+ const delegatedModel = type?.create_model?.trim() || undefined
191
+
192
+ // Tipo con alta en otro modelo: no hay formulario aquí; el host navega.
193
+ useEffect(() => {
194
+ if (!open || !type || !delegatedModel) return
195
+ onDelegateCreate?.({ model: delegatedModel, type })
196
+ onOpenChange(false)
197
+ // onDelegateCreate/onOpenChange: callbacks del host, no disparan de nuevo.
198
+ // eslint-disable-next-line react-hooks/exhaustive-deps
199
+ }, [open, type, delegatedModel])
150
200
  // Sin buscador inyectado, el paso de renglones solo ofrecía «Renglón libre»
151
201
  // y la línea entraba sin precio (DynamicCRUDPage no pasa searchProducts).
152
202
  // Default: el catálogo de productos de la org. El buscador del host también
@@ -176,10 +226,13 @@ export function DocumentFormDialog({
176
226
  // Seed field defaults for the chosen type.
177
227
  useEffect(() => {
178
228
  if (!type) return
179
- setFormData({ ...buildFieldDefaults(type.fields, record), ...(type.defaults ?? {}) })
229
+ setFormData({
230
+ ...buildFieldDefaults(type.fields, record, undefined, { timeZone: orgTimeZone }),
231
+ ...(type.defaults ?? {}),
232
+ })
180
233
  setFieldErrors({})
181
234
  setFormError(undefined)
182
- }, [type, record])
235
+ }, [type, record, orgTimeZone])
183
236
 
184
237
  const updateField = (key: string, value: any) => {
185
238
  setFormData((prev) => ({ ...prev, [key]: value }))
@@ -280,15 +333,25 @@ export function DocumentFormDialog({
280
333
  ? t('documentForm.step_data', { defaultValue: 'Datos' })
281
334
  : tl(lineCfg?.title ?? 'Renglones')
282
335
 
336
+ if (open && type && delegatedModel) return null
337
+
283
338
  // layout "editor": una sola pantalla por secciones (DocumentEditor). Sin él
284
- // sigue el wizard de siempre (retrocompatible).
339
+ // sigue el wizard de siempre (retrocompatible). Un documento con renglones
340
+ // (factura, cotización, pedido, OC) ocupa toda la pantalla; NC y cobro
341
+ // siguen en el diálogo grande.
285
342
  if (open && type && step !== 'type' && (isEditorLayout(type) || !!initialSource)) {
343
+ const fullscreen = isFullscreenEditor(type, forms)
286
344
  return (
287
345
  <Dialog open={open} onOpenChange={onOpenChange}>
288
346
  <DialogContent
289
- className="flex max-h-[92dvh] flex-col overflow-hidden"
290
- style={{ maxHeight: '92dvh', maxWidth: '960px', width: '95vw' }}
347
+ className={fullscreen ? 'flex flex-col overflow-hidden sm:max-w-none' : 'flex max-h-[92dvh] flex-col overflow-hidden'}
348
+ style={
349
+ fullscreen
350
+ ? { width: 'calc(100vw - 2rem)', maxWidth: '1440px', height: 'calc(100dvh - 2rem)', maxHeight: 'calc(100dvh - 2rem)' }
351
+ : { maxHeight: '92dvh', maxWidth: '960px', width: '95vw' }
352
+ }
291
353
  data-slot="document-editor-dialog"
354
+ data-fullscreen={fullscreen ? 'true' : undefined}
292
355
  >
293
356
  <DialogHeader className="shrink-0">
294
357
  <DialogTitle>{tl(type.label)}</DialogTitle>
@@ -304,6 +367,7 @@ export function DocumentFormDialog({
304
367
  initialSource={initialSource}
305
368
  searchProducts={lineSearch}
306
369
  currency={currency}
370
+ fullscreen={fullscreen}
307
371
  onCancel={() => (canGoBackToTypes ? setStep('type') : onOpenChange(false))}
308
372
  onSaved={(rec) => {
309
373
  onSaved?.(rec)
@@ -33,7 +33,13 @@ import { useApi } from './api-context'
33
33
  import { useMetadataCache } from './metadata-cache'
34
34
  import { DynamicTable } from './dynamic-table'
35
35
  import { DynamicRecordDialog } from './dialogs/dynamic-record'
36
- import { DocumentFormDialog, resolveDocumentForms, scopeDocumentFormsToFilter } from './document-form-dialog'
36
+ import {
37
+ DocumentFormDialog,
38
+ resolveDocumentForms,
39
+ scopeDocumentFormsToFilter,
40
+ withoutDelegatedTypes,
41
+ type DelegatedCreate,
42
+ } from './document-form-dialog'
37
43
  import { ExportDialog } from './dialogs/export'
38
44
  import { ImportDialog } from './dialogs/import'
39
45
  import { getModelExtension } from './model-extension-registry'
@@ -108,6 +114,12 @@ export interface DynamicCRUDPageProps {
108
114
  * `?create=<tipo>&from=<fuente>&from_id=<id>`. Solo con document_forms.
109
115
  */
110
116
  initialCreate?: { type?: string; source?: { key: string; id: string } }
117
+ /**
118
+ * Alta delegada (`document_forms.types[].create_model`): el host abre la
119
+ * página del modelo destino con su alta abierta. Sin él, esos tipos no se
120
+ * ofrecen (y si la vista queda acotada a uno, no hay «Crear»).
121
+ */
122
+ onDelegateCreate?: (target: DelegatedCreate) => void
111
123
  }
112
124
 
113
125
  /**
@@ -133,6 +145,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
133
145
  documentForms,
134
146
  filter,
135
147
  initialCreate,
148
+ onDelegateCreate,
136
149
  } = props
137
150
 
138
151
  const strings = { ...defaultStrings, ...(i18n ?? {}) }
@@ -203,7 +216,11 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
203
216
  // (useCan defaults to always-true), in which case create/export/import
204
217
  // require `lowercase(model).create|export|import`.
205
218
  const can = useCan()
206
- const scoped = scopeDocumentFormsToFilter(resolveDocumentForms(metadata, documentForms), filter)
219
+ const declaredForms = resolveDocumentForms(metadata, documentForms)
220
+ const scoped = scopeDocumentFormsToFilter(
221
+ onDelegateCreate ? declaredForms : withoutDelegatedTypes(declaredForms),
222
+ filter,
223
+ )
207
224
  const guidedForms = scoped.forms
208
225
  const showCreate = primary.showGenericCreate && !scoped.hideCreate && can(modelCapability(model, 'create'))
209
226
  const showImport = enableCRUD && !effectiveHideImport && can(modelCapability(model, 'import'))
@@ -318,6 +335,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
318
335
  initialType={initialCreate?.type}
319
336
  initialSource={initialCreate?.source}
320
337
  onSaved={handleRefresh}
338
+ onDelegateCreate={onDelegateCreate}
321
339
  />
322
340
  )}
323
341
 
package/src/index.ts CHANGED
@@ -179,8 +179,11 @@ export { FieldGrid, FieldCell, FieldLabel } from './field-grid'
179
179
  export {
180
180
  ActionModalDispatcher,
181
181
  FEDERATED_ACTION_MODAL_TIMEOUT_MS,
182
+ resolveFieldDefault,
182
183
  type ActionModalProps,
184
+ type FieldDefaultContext,
183
185
  } from './action-modal-dispatcher'
186
+ export { todayInZone, isTodayToken } from './calendar-date'
184
187
  export { setFederatedActionLoader, type FederatedActionLoader } from './federated-action-loader'
185
188
  export {
186
189
  ModelActionToolbar,
@@ -198,8 +201,10 @@ export {
198
201
  } from './list-primary-action'
199
202
  export {
200
203
  emitRecordMutation,
204
+ emitRecordMutationSettled,
201
205
  subscribeRecordMutations,
202
206
  RECORD_MUTATION_EVENT,
207
+ RECORD_MUTATION_SETTLE_DELAYS,
203
208
  type RecordMutationKind,
204
209
  type RecordMutationDetail,
205
210
  } from './record-mutation-events'
@@ -207,6 +212,9 @@ export {
207
212
  DocumentFormDialog,
208
213
  resolveDocumentForms,
209
214
  scopeDocumentFormsToFilter,
215
+ delegatedCreate,
216
+ withoutDelegatedTypes,
217
+ type DelegatedCreate,
210
218
  type DocumentFormDialogProps,
211
219
  } from './document-form-dialog'
212
220
  export {
@@ -31,6 +31,29 @@ export function emitRecordMutation(model: string | undefined | null, kind: Recor
31
31
  }
32
32
  }
33
33
 
34
+ /**
35
+ * Delays (ms) at which a mutation is announced again. The backend's event
36
+ * subscribers write AFTER the action answered (stamping → the invoice's state,
37
+ * a payment → its auto-stamped REP), so a list or tab that reloaded at once kept
38
+ * showing the previous state until a manual reload (retest Pitsline r5).
39
+ */
40
+ export const RECORD_MUTATION_SETTLE_DELAYS: readonly number[] = [1500, 5000]
41
+
42
+ /**
43
+ * Announce a mutation now and again after `delays`, so what the server derives
44
+ * asynchronously from it reaches mounted lists too. Returns a cancel function.
45
+ */
46
+ export function emitRecordMutationSettled(
47
+ model: string | undefined | null,
48
+ kind: RecordMutationKind,
49
+ delays: readonly number[] = RECORD_MUTATION_SETTLE_DELAYS,
50
+ ): () => void {
51
+ emitRecordMutation(model, kind)
52
+ if (!model || typeof window === 'undefined') return () => {}
53
+ const timers = delays.map((ms) => window.setTimeout(() => emitRecordMutation(model, kind), ms))
54
+ return () => timers.forEach((t) => window.clearTimeout(t))
55
+ }
56
+
34
57
  /**
35
58
  * Listen for mutations of `model` (case-insensitive). Returns an unsubscribe.
36
59
  */
package/src/types.ts CHANGED
@@ -107,6 +107,13 @@ export interface DocumentFormType {
107
107
  * reparte el pago en PaymentAllocation dentro de una transacción.
108
108
  */
109
109
  submit_action?: string
110
+ /**
111
+ * El alta de este tipo vive en OTRO modelo (`customers.Invoice`): «Crear»
112
+ * no pinta formulario, el host abre la página de ese modelo con su alta
113
+ * abierta (`onDelegateCreate`). P. ej. Documentos fiscales → Facturas: la
114
+ * factura nace en customers y se timbra después. Sin campos ni renglones.
115
+ */
116
+ create_model?: string
110
117
  }
111
118
 
112
119
  /** Contraparte: qué campo del encabezado la elige y qué muestra su tarjeta. */