@asteby/metacore-runtime-react 47.0.0 → 48.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.
- package/CHANGELOG.md +42 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +24 -4
- package/dist/audit-info.d.ts +54 -0
- package/dist/audit-info.d.ts.map +1 -0
- package/dist/audit-info.js +142 -0
- package/dist/business/catalog-product-search.d.ts +25 -0
- package/dist/business/catalog-product-search.d.ts.map +1 -0
- package/dist/business/catalog-product-search.js +57 -0
- package/dist/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +2 -0
- package/dist/business/document-editor-model.d.ts +107 -0
- package/dist/business/document-editor-model.d.ts.map +1 -0
- package/dist/business/document-editor-model.js +279 -0
- package/dist/business/document-editor-panels.d.ts +64 -0
- package/dist/business/document-editor-panels.d.ts.map +1 -0
- package/dist/business/document-editor-panels.js +58 -0
- package/dist/business/document-editor.d.ts +27 -0
- package/dist/business/document-editor.d.ts.map +1 -0
- package/dist/business/document-editor.js +445 -0
- package/dist/business/document-lines.d.ts.map +1 -1
- package/dist/business/document-lines.js +5 -1
- package/dist/business/index.d.ts +5 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +5 -0
- package/dist/business/line-items.d.ts +7 -0
- package/dist/business/line-items.d.ts.map +1 -1
- package/dist/business/line-items.js +6 -1
- package/dist/business/payment-allocator.d.ts +19 -0
- package/dist/business/payment-allocator.d.ts.map +1 -0
- package/dist/business/payment-allocator.js +31 -0
- package/dist/business/product-search.d.ts +4 -0
- package/dist/business/product-search.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +19 -8
- package/dist/document-form-dialog.d.ts +31 -2
- package/dist/document-form-dialog.d.ts.map +1 -1
- package/dist/document-form-dialog.js +43 -11
- package/dist/dynamic-columns-helpers.d.ts +9 -0
- package/dist/dynamic-columns-helpers.d.ts.map +1 -1
- package/dist/dynamic-columns-helpers.js +19 -0
- package/dist/dynamic-columns.d.ts +8 -2
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +21 -5
- package/dist/dynamic-crud-page.d.ts +6 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +14 -5
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +15 -3
- package/dist/dynamic-form.d.ts +16 -1
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +81 -20
- package/dist/dynamic-kanban.d.ts +9 -3
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +39 -13
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +3 -0
- package/dist/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +2 -0
- package/dist/dynamic-relation-helpers.d.ts.map +1 -1
- package/dist/dynamic-relation-helpers.js +8 -0
- package/dist/dynamic-relation.js +1 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +4 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -2
- package/dist/installed-addons-context.d.ts +15 -0
- package/dist/installed-addons-context.d.ts.map +1 -0
- package/dist/installed-addons-context.js +39 -0
- package/dist/option-filter.d.ts +31 -0
- package/dist/option-filter.d.ts.map +1 -0
- package/dist/option-filter.js +49 -0
- package/dist/org-runtime-context.d.ts +8 -0
- package/dist/org-runtime-context.d.ts.map +1 -1
- package/dist/org-runtime-context.js +8 -0
- package/dist/org-runtime-provider.d.ts +3 -1
- package/dist/org-runtime-provider.d.ts.map +1 -1
- package/dist/org-runtime-provider.js +3 -3
- package/dist/primitives/aging.d.ts +34 -0
- package/dist/primitives/aging.d.ts.map +1 -0
- package/dist/primitives/aging.js +48 -0
- package/dist/primitives/allocation.d.ts +42 -0
- package/dist/primitives/allocation.d.ts.map +1 -0
- package/dist/primitives/allocation.js +59 -0
- package/dist/primitives/approvals.d.ts +71 -0
- package/dist/primitives/approvals.d.ts.map +1 -0
- package/dist/primitives/approvals.js +48 -0
- package/dist/primitives/contributions.d.ts +82 -0
- package/dist/primitives/contributions.d.ts.map +1 -0
- package/dist/primitives/contributions.js +126 -0
- package/dist/primitives/document-kinds.d.ts +25 -0
- package/dist/primitives/document-kinds.d.ts.map +1 -0
- package/dist/primitives/document-kinds.js +26 -0
- package/dist/primitives/index.d.ts +8 -0
- package/dist/primitives/index.d.ts.map +1 -0
- package/dist/primitives/index.js +23 -0
- package/dist/primitives/panels.d.ts +68 -0
- package/dist/primitives/panels.d.ts.map +1 -0
- package/dist/primitives/panels.js +3 -0
- package/dist/primitives/use-contributions.d.ts +15 -0
- package/dist/primitives/use-contributions.d.ts.map +1 -0
- package/dist/primitives/use-contributions.js +30 -0
- package/dist/record-prefill-bar.d.ts +11 -0
- package/dist/record-prefill-bar.d.ts.map +1 -0
- package/dist/record-prefill-bar.js +33 -0
- package/dist/types.d.ts +142 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-options-resolver.d.ts +8 -0
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +8 -2
- package/package.json +5 -5
- package/src/__tests__/action-modal-grid-layout.test.tsx +1 -1
- package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
- package/src/__tests__/audit-info.test.tsx +103 -0
- package/src/__tests__/catalog-product-search.test.ts +61 -0
- package/src/__tests__/dependent-options.test.tsx +12 -5
- package/src/__tests__/document-editor-model.test.ts +167 -0
- package/src/__tests__/document-editor.test.tsx +261 -0
- package/src/__tests__/document-form-dialog.test.tsx +118 -3
- package/src/__tests__/dynamic-form-select-edit.test.tsx +155 -0
- package/src/__tests__/dynamic-kanban.test.tsx +24 -3
- package/src/__tests__/dynamic-record-extension-label.test.tsx +88 -0
- package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +4 -0
- package/src/__tests__/installed-addons-context.test.tsx +50 -0
- package/src/__tests__/line-item-totals.test.ts +28 -0
- package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
- package/src/__tests__/option-filter.test.tsx +115 -0
- package/src/__tests__/primitives-federated.test.tsx +156 -0
- package/src/__tests__/primitives.test.ts +224 -0
- package/src/__tests__/record-prefill-bar.test.tsx +50 -0
- package/src/__tests__/structured-view-value.test.tsx +5 -1
- package/src/__tests__/wizard-action-modal.test.tsx +1 -1
- package/src/action-modal-dispatcher.tsx +47 -4
- package/src/audit-info.tsx +219 -0
- package/src/business/catalog-product-search.ts +87 -0
- package/src/business/catalog.ts +2 -0
- package/src/business/document-editor-model.ts +325 -0
- package/src/business/document-editor-panels.tsx +164 -0
- package/src/business/document-editor.tsx +727 -0
- package/src/business/document-lines.ts +5 -1
- package/src/business/index.ts +5 -0
- package/src/business/line-items.ts +15 -1
- package/src/business/payment-allocator.tsx +110 -0
- package/src/business/product-search.ts +4 -0
- package/src/dialogs/dynamic-record.tsx +42 -6
- package/src/document-form-dialog.tsx +92 -12
- package/src/dropdown-menu-entry.test.ts +23 -0
- package/src/dynamic-columns-helpers.ts +22 -0
- package/src/dynamic-columns.tsx +23 -6
- package/src/dynamic-crud-page.tsx +22 -3
- package/src/dynamic-form-schema.ts +15 -2
- package/src/dynamic-form.tsx +120 -39
- package/src/dynamic-kanban.tsx +41 -10
- package/src/dynamic-line-items.tsx +3 -0
- package/src/dynamic-multi-select-field.tsx +2 -0
- package/src/dynamic-relation-helpers.ts +9 -0
- package/src/dynamic-relation.tsx +7 -2
- package/src/dynamic-select-field.tsx +4 -0
- package/src/index.ts +34 -1
- package/src/installed-addons-context.tsx +58 -0
- package/src/option-filter.ts +88 -0
- package/src/org-runtime-context.ts +10 -0
- package/src/org-runtime-provider.tsx +7 -2
- package/src/primitives/aging.ts +76 -0
- package/src/primitives/allocation.ts +104 -0
- package/src/primitives/approvals.ts +103 -0
- package/src/primitives/contributions.ts +208 -0
- package/src/primitives/document-kinds.ts +62 -0
- package/src/primitives/index.ts +23 -0
- package/src/primitives/panels.ts +59 -0
- package/src/primitives/use-contributions.ts +42 -0
- package/src/record-prefill-bar.tsx +62 -0
- package/src/row-action-stage-gate.test.ts +33 -0
- package/src/types.ts +142 -0
- package/src/use-options-resolver.ts +19 -1
- package/vitest.config.ts +1 -0
- package/vitest.setup.ts +7 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Registro de contribuciones federadas — cómo un addon mete secciones,
|
|
2
|
+
// columnas, modales, fuentes de «Cargar desde» y vistas previas en la pantalla
|
|
3
|
+
// de OTRO addon sin que ese addon lo conozca.
|
|
4
|
+
//
|
|
5
|
+
// Se monta sobre slotRegistry (slot.tsx) — no es un segundo sistema: cada
|
|
6
|
+
// contribución es un SlotEntry con id canónico `doc:<kind>:<region>` — y añade
|
|
7
|
+
// lo que slot no tiene: `requiresAddon` (se oculta solo si el addon no está
|
|
8
|
+
// instalado o se desinstala en caliente), orden estable, `when` por documento y
|
|
9
|
+
// carga perezosa del componente (import() del remote federado).
|
|
10
|
+
//
|
|
11
|
+
// Contrato estable para remotes federados: los tipos de este archivo. Todo
|
|
12
|
+
// `register*` devuelve un disposer; el AddonLoader lo llama al desmontar o
|
|
13
|
+
// recargar el remote y la contribución desaparece sin recargar la página.
|
|
14
|
+
import { createElement, lazy, Suspense, type ComponentType } from 'react'
|
|
15
|
+
import { slotRegistry } from '../slot'
|
|
16
|
+
|
|
17
|
+
/** Regiones del DocumentEditor/DocumentPage donde se puede contribuir. */
|
|
18
|
+
export type DocumentRegion =
|
|
19
|
+
| 'header.fields' // campos extra del encabezado (Uso CFDI, Método, Forma…)
|
|
20
|
+
| 'party.card' // filas extra en PartyCard (RFC, régimen, CP, CSF)
|
|
21
|
+
| 'lines.columns' // columnas de LinesGrid (clave SAT, DOT, almacén)
|
|
22
|
+
| 'side.panels' // paneles laterales (Revisión, PreviewPanel, crédito)
|
|
23
|
+
| 'body.sections' // secciones bajo los renglones (Reingreso a stock, Relación CFDI)
|
|
24
|
+
| 'footer.actions' // botones del ActionBar (Timbrar, Pedir autorización)
|
|
25
|
+
|
|
26
|
+
export interface DocumentEditorContext {
|
|
27
|
+
kind: string // 'invoice' | 'credit_note' | 'payment' | 'purchase_order' | …
|
|
28
|
+
model: string // 'customers.Invoice'
|
|
29
|
+
values: Record<string, unknown>
|
|
30
|
+
mode: 'create' | 'edit' | 'view'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DocumentContribution<P = DocumentContributionProps> {
|
|
34
|
+
/** Id estable `addon.region.name` (dedupe y orden). Re-registrar el mismo id reemplaza. */
|
|
35
|
+
id: string
|
|
36
|
+
kinds: string[] | '*'
|
|
37
|
+
region: DocumentRegion
|
|
38
|
+
/** Addon dueño. Si no está instalado, no se renderiza (y el server revalida). */
|
|
39
|
+
requiresAddon?: string
|
|
40
|
+
/** Mayor = primero. Default 0. */
|
|
41
|
+
priority?: number
|
|
42
|
+
when?: (ctx: DocumentEditorContext) => boolean
|
|
43
|
+
/** Componente o loader perezoso del remote (`() => import('fiscal_mexico/CfdiSection')`). */
|
|
44
|
+
component?: ComponentType<P>
|
|
45
|
+
load?: () => Promise<{ default: ComponentType<P> }>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Contribución resuelta: `component` siempre presente (si se registró con
|
|
50
|
+
* `load`, es un envoltorio lazy + Suspense que no pinta nada mientras carga).
|
|
51
|
+
*/
|
|
52
|
+
export type ResolvedDocumentContribution<P = DocumentContributionProps> = DocumentContribution<P> & {
|
|
53
|
+
component: ComponentType<P>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface DocumentContributionProps {
|
|
57
|
+
ctx: DocumentEditorContext
|
|
58
|
+
setValue: (key: string, value: unknown) => void
|
|
59
|
+
issues: { report: (key: string, issue: { severity: 'error' | 'warning'; message: string }) => void }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Predicado «¿está instalado?»; `useInstalledAddons` lo arma en React. */
|
|
63
|
+
export type IsAddonInstalled = (addonKey: string) => boolean
|
|
64
|
+
|
|
65
|
+
interface Entry {
|
|
66
|
+
c: ResolvedDocumentContribution<any>
|
|
67
|
+
dispose: () => void
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const contribs = new Map<string, Entry>()
|
|
71
|
+
const listeners = new Set<() => void>()
|
|
72
|
+
let version = 0
|
|
73
|
+
|
|
74
|
+
function notify() {
|
|
75
|
+
version += 1
|
|
76
|
+
for (const l of [...listeners]) l()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Suscripción para useSyncExternalStore (contribuciones y modales). */
|
|
80
|
+
export function subscribeContributions(listener: () => void): () => void {
|
|
81
|
+
listeners.add(listener)
|
|
82
|
+
return () => {
|
|
83
|
+
listeners.delete(listener)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Versión monotónica: cambia en cada register/dispose. */
|
|
88
|
+
export function contributionsVersion(): number {
|
|
89
|
+
return version
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function slotIdFor(kind: string, region: DocumentRegion): string {
|
|
93
|
+
return `doc:${kind}:${region}`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `load` → componente lazy envuelto en Suspense (fallback vacío: sin saltos de layout). */
|
|
97
|
+
function lazyComponent<P>(load: () => Promise<{ default: ComponentType<P> }>): ComponentType<P> {
|
|
98
|
+
const Lazy = lazy(load) as unknown as ComponentType<any>
|
|
99
|
+
return (props: P) => createElement(Suspense, { fallback: null }, createElement(Lazy, props as any))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Registra la contribución y la publica en slotRegistry bajo cada `doc:<kind>:<region>`
|
|
104
|
+
* (kinds '*' → `doc:*:<region>`). Devuelve el disposer: AddonLoader lo llama al
|
|
105
|
+
* desmontar o recargar el remote (fiber), así la sección desaparece al desinstalar.
|
|
106
|
+
* Re-registrar el mismo `id` (recarga en caliente) reemplaza la anterior.
|
|
107
|
+
*/
|
|
108
|
+
export function registerDocumentContribution<P = DocumentContributionProps>(c: DocumentContribution<P>, source?: string): () => void {
|
|
109
|
+
if (!c.component && !c.load) throw new Error(`contribution ${c.id}: component o load es obligatorio`)
|
|
110
|
+
contribs.get(c.id)?.dispose()
|
|
111
|
+
const resolved = { ...c, component: c.component ?? lazyComponent(c.load!) } as ResolvedDocumentContribution<any>
|
|
112
|
+
const kinds = c.kinds === '*' ? ['*'] : c.kinds
|
|
113
|
+
const slotDisposers = kinds.map(k =>
|
|
114
|
+
slotRegistry.register(slotIdFor(k, c.region), resolved.component as ComponentType, {
|
|
115
|
+
priority: c.priority ?? 0,
|
|
116
|
+
source: source ?? c.requiresAddon ?? c.id,
|
|
117
|
+
}),
|
|
118
|
+
)
|
|
119
|
+
let disposed = false
|
|
120
|
+
const entry: Entry = {
|
|
121
|
+
c: resolved,
|
|
122
|
+
dispose: () => {
|
|
123
|
+
if (disposed) return
|
|
124
|
+
disposed = true
|
|
125
|
+
slotDisposers.forEach(d => d())
|
|
126
|
+
if (contribs.get(c.id) === entry) contribs.delete(c.id)
|
|
127
|
+
notify()
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
contribs.set(c.id, entry)
|
|
131
|
+
notify()
|
|
132
|
+
return entry.dispose
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Contribuciones aplicables a `region` para el documento `ctx`, ordenadas por
|
|
137
|
+
* prioridad (desc) y luego id. Pura: no lee React; `useDocumentContributions`
|
|
138
|
+
* la envuelve con useInstalledAddons.
|
|
139
|
+
*/
|
|
140
|
+
export function resolveContributions(
|
|
141
|
+
ctx: DocumentEditorContext,
|
|
142
|
+
region: DocumentRegion,
|
|
143
|
+
isInstalled: IsAddonInstalled,
|
|
144
|
+
): ResolvedDocumentContribution<any>[] {
|
|
145
|
+
return [...contribs.values()]
|
|
146
|
+
.map(e => e.c)
|
|
147
|
+
.filter(c => c.region === region)
|
|
148
|
+
.filter(c => c.kinds === '*' || c.kinds.includes(ctx.kind))
|
|
149
|
+
.filter(c => !c.requiresAddon || isInstalled(c.requiresAddon))
|
|
150
|
+
.filter(c => !c.when || c.when(ctx))
|
|
151
|
+
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.id.localeCompare(b.id))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---- Modales federados ------------------------------------------------------
|
|
155
|
+
// ActionModalDispatcher resuelve `modal: "returns.settle_method"` con
|
|
156
|
+
// requestFederatedAction (espera hasta 20 s). Este registro es su contraparte
|
|
157
|
+
// declarativa: el addon registra la clave al cargar. Si el addon dueño no está
|
|
158
|
+
// instalado, resolveFederatedModal devuelve null de inmediato y el dispatcher
|
|
159
|
+
// usa el formulario genérico de la acción en vez de colgarse esperando.
|
|
160
|
+
|
|
161
|
+
export interface FederatedModal<P = any> {
|
|
162
|
+
/** Clave `<addon>.<nombre>`, la misma que `modal` en el manifest. */
|
|
163
|
+
key: string // 'fiscal_mexico.import_cfdi'
|
|
164
|
+
addon: string
|
|
165
|
+
/** El dispatcher le pasa ActionModalProps (open, onOpenChange, action, model, record…). */
|
|
166
|
+
load: () => Promise<{ default: ComponentType<P> }>
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const modals = new Map<string, FederatedModal>()
|
|
170
|
+
const modalComponents = new WeakMap<FederatedModal, ComponentType<any>>()
|
|
171
|
+
|
|
172
|
+
export function registerFederatedModal(m: FederatedModal): () => void {
|
|
173
|
+
modals.set(m.key, m)
|
|
174
|
+
notify()
|
|
175
|
+
return () => {
|
|
176
|
+
if (modals.get(m.key) === m) {
|
|
177
|
+
modals.delete(m.key)
|
|
178
|
+
notify()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** null si el modal no está registrado o su addon no está instalado. Nunca espera. */
|
|
184
|
+
export function resolveFederatedModal(key: string, isInstalled: IsAddonInstalled): FederatedModal | null {
|
|
185
|
+
const m = modals.get(key)
|
|
186
|
+
return m && isInstalled(m.addon) ? m : null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Componente `React.lazy` del modal resuelto, memoizado por registro (no se
|
|
191
|
+
* re-crea en cada render). Quien lo pinta lo envuelve en `<Suspense>`.
|
|
192
|
+
*/
|
|
193
|
+
export function federatedModalComponent<P = any>(m: FederatedModal<P>): ComponentType<P> {
|
|
194
|
+
let C = modalComponents.get(m)
|
|
195
|
+
if (!C) {
|
|
196
|
+
C = lazy(m.load) as unknown as ComponentType<any>
|
|
197
|
+
modalComponents.set(m, C)
|
|
198
|
+
}
|
|
199
|
+
return C as ComponentType<P>
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Solo para tests. */
|
|
203
|
+
export function __resetContributions(): void {
|
|
204
|
+
for (const e of [...contribs.values()]) e.dispose()
|
|
205
|
+
contribs.clear()
|
|
206
|
+
modals.clear()
|
|
207
|
+
notify()
|
|
208
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// DocumentKind — un registro de tipos de documento comercial para que
|
|
2
|
+
// factura, cotización, pedido, OC, NC, cobro/REP, factura de proveedor y OT usen
|
|
3
|
+
// el MISMO DocumentEditor / DocumentPage. Cada addon declara su tipo (desde el
|
|
4
|
+
// manifest `document_forms` o con registerDocumentKind) y el editor arma:
|
|
5
|
+
// contraparte, renglones, fuentes de «Cargar desde», totales y vista previa.
|
|
6
|
+
import type { DocumentFormPreview, DocumentFormSource } from '../types'
|
|
7
|
+
|
|
8
|
+
export type PartyRole = 'customer' | 'supplier' | 'none'
|
|
9
|
+
export type LinesMode =
|
|
10
|
+
| 'sale' // precio de venta, descuento, impuesto (factura, cotización, pedido)
|
|
11
|
+
| 'purchase' // costo, recepción pendiente (OC, factura de proveedor)
|
|
12
|
+
| 'credit' // cantidades ≤ facturado − acreditado (NC, devolución)
|
|
13
|
+
| 'allocation' // documentos abiertos a pagar (cobro / REP / pago a proveedor)
|
|
14
|
+
| 'workorder' // servicio + refacción, técnico, horas (OT)
|
|
15
|
+
| 'none'
|
|
16
|
+
|
|
17
|
+
export interface DocumentKind {
|
|
18
|
+
key: string
|
|
19
|
+
label: string
|
|
20
|
+
model: string // ModelKey canónico ('customers.Invoice')
|
|
21
|
+
party: PartyRole
|
|
22
|
+
partyModel?: string // 'customers.Customer' | 'purchases.Supplier'
|
|
23
|
+
lines: LinesMode
|
|
24
|
+
/** Campo de renglones del modelo (one_to_many) o del payload de la acción. */
|
|
25
|
+
linesField?: string
|
|
26
|
+
sources?: DocumentFormSource[]
|
|
27
|
+
preview?: DocumentFormPreview
|
|
28
|
+
/** Estados del documento (StageMachine): los muestra StageStepper. */
|
|
29
|
+
stages?: string[]
|
|
30
|
+
/** Addon dueño del tipo (si no está, el tipo no se ofrece). */
|
|
31
|
+
addon: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const kinds = new Map<string, DocumentKind>()
|
|
35
|
+
|
|
36
|
+
export function registerDocumentKind(k: DocumentKind): () => void {
|
|
37
|
+
kinds.set(k.key, k)
|
|
38
|
+
return () => {
|
|
39
|
+
if (kinds.get(k.key) === k) kinds.delete(k.key)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getDocumentKind(key: string): DocumentKind | undefined {
|
|
44
|
+
return kinds.get(key)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function listDocumentKinds(isInstalled: (addon: string) => boolean): DocumentKind[] {
|
|
48
|
+
return [...kinds.values()].filter(k => isInstalled(k.addon))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Tipos base que el core ya tiene (modelos verificados en addons main 9cd69bde). */
|
|
52
|
+
export const CORE_DOCUMENT_KINDS: DocumentKind[] = [
|
|
53
|
+
{ key: 'invoice', label: 'Factura', model: 'customers.Invoice', party: 'customer', partyModel: 'customers.Customer', lines: 'sale', linesField: 'items', addon: 'customers' },
|
|
54
|
+
{ key: 'quote', label: 'Cotización', model: 'quotes.Quote', party: 'customer', partyModel: 'customers.Customer', lines: 'sale', linesField: 'items', addon: 'quotes' },
|
|
55
|
+
{ key: 'sales_order', label: 'Pedido', model: 'customers.SalesOrder', party: 'customer', partyModel: 'customers.Customer', lines: 'sale', linesField: 'items', addon: 'customers' },
|
|
56
|
+
{ key: 'payment', label: 'Cobro', model: 'customers.Payment', party: 'customer', partyModel: 'customers.Customer', lines: 'allocation', linesField: 'allocations', addon: 'customers' },
|
|
57
|
+
{ key: 'credit_note', label: 'Nota de crédito', model: 'fiscal_mexico.CreditNote', party: 'customer', partyModel: 'customers.Customer', lines: 'credit', linesField: 'lines', addon: 'fiscal_mexico' },
|
|
58
|
+
{ key: 'purchase_order', label: 'Orden de compra', model: 'purchases.PurchaseOrder', party: 'supplier', partyModel: 'purchases.Supplier', lines: 'purchase', linesField: 'items', addon: 'purchases' },
|
|
59
|
+
{ key: 'supplier_invoice', label: 'Factura de proveedor', model: 'purchases.SupplierInvoice', party: 'supplier', partyModel: 'purchases.Supplier', lines: 'purchase', addon: 'purchases' },
|
|
60
|
+
{ key: 'supplier_payment', label: 'Pago a proveedor', model: 'purchases.PurchasePayment', party: 'supplier', partyModel: 'purchases.Supplier', lines: 'allocation', addon: 'purchases' },
|
|
61
|
+
{ key: 'work_order', label: 'Orden de trabajo', model: 'workshop.WorkOrder', party: 'customer', partyModel: 'customers.Customer', lines: 'workorder', linesField: 'items', addon: 'workshop' },
|
|
62
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Primitivos de negocio — contrato estable para hosts y remotes federados.
|
|
2
|
+
// Las funciones son puras (sin React ni host) salvo use-contributions; la UI y
|
|
3
|
+
// el backend (wasm) pueden validar con las mismas reglas.
|
|
4
|
+
//
|
|
5
|
+
// allocation PaymentAllocator: allocatePayment / validateAllocation
|
|
6
|
+
// (cobro multi-factura, REP, CxC «Registrar abono», CxP «Aplicar pago»).
|
|
7
|
+
// aging AgingTable: computeAging / daysOverdue / bucketFor (antigüedad de saldos).
|
|
8
|
+
// approvals ApprovalInbox: approvalsClient (rutas /approvals del kernel),
|
|
9
|
+
// registerApprovalCategory / groupApprovals.
|
|
10
|
+
// contributions Secciones y modales federados: registerDocumentContribution,
|
|
11
|
+
// resolveContributions (pura), registerFederatedModal /
|
|
12
|
+
// resolveFederatedModal. Todo register* devuelve su disposer.
|
|
13
|
+
// document-kinds registerDocumentKind / listDocumentKinds / CORE_DOCUMENT_KINDS.
|
|
14
|
+
// panels Tipos de PartyCard / TotalsPanel / PreviewPanel /
|
|
15
|
+
// ValidationChecklist / LoadFromDocument + blockingChecks.
|
|
16
|
+
// use-contributions Hooks: useDocumentContributions, useIsAddonInstalled.
|
|
17
|
+
export * from './allocation'
|
|
18
|
+
export * from './aging'
|
|
19
|
+
export * from './approvals'
|
|
20
|
+
export * from './contributions'
|
|
21
|
+
export * from './document-kinds'
|
|
22
|
+
export * from './panels'
|
|
23
|
+
export * from './use-contributions'
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Contratos de los paneles del DocumentEditor. Los componentes viven en
|
|
2
|
+
// business/ (PartyCard, LinesGrid = DocumentLinesGrid, TotalsPanel,
|
|
3
|
+
// PreviewPanel, LoadFromDocument); aquí solo los tipos para que cualquier
|
|
4
|
+
// addon remoto los use sin depender de la implementación.
|
|
5
|
+
import type { LineItemsTotals } from '../business/line-items'
|
|
6
|
+
|
|
7
|
+
/** PartyCard: lo que se muestra de la contraparte. */
|
|
8
|
+
export interface PartySummary {
|
|
9
|
+
id: string
|
|
10
|
+
name: string
|
|
11
|
+
tax_id?: string
|
|
12
|
+
email?: string
|
|
13
|
+
phone?: string
|
|
14
|
+
currency?: string
|
|
15
|
+
/** Filas extra (incluye las de extensiones `fiscal_data.*`). */
|
|
16
|
+
rows: Array<{ key: string; label: string; value: string; tone?: 'ok' | 'warn' | 'err' }>
|
|
17
|
+
credit?: { limit: number; balance: number; available: number; overdue: number; status?: 'active' | 'suspended' | 'delinquent'; hold_reason?: string }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** TotalsPanel: desglose; los impuestos por tasa los aporta el addon fiscal o el core. */
|
|
21
|
+
export interface TotalsBreakdown extends LineItemsTotals {
|
|
22
|
+
taxes: Array<{ label: string; base: number; amount: number }>
|
|
23
|
+
/** Renglones extra (aplicaciones, saldo a favor, sin aplicar…). */
|
|
24
|
+
extra?: Array<{ label: string; amount: number; emphasis?: boolean }>
|
|
25
|
+
amount_in_words?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** PreviewPanel: resultado de la acción de vista previa (`analyze: true`, no escribe). */
|
|
29
|
+
export interface PreviewResult {
|
|
30
|
+
pdf_base64?: string
|
|
31
|
+
xml?: string
|
|
32
|
+
html?: string
|
|
33
|
+
checks: ValidationCheck[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** ValidationChecklist: una revisión (local o del servidor). */
|
|
37
|
+
export interface ValidationCheck {
|
|
38
|
+
key: string
|
|
39
|
+
severity: 'ok' | 'warning' | 'error'
|
|
40
|
+
message: string
|
|
41
|
+
/** Campo a enfocar al hacer clic. */
|
|
42
|
+
field?: string
|
|
43
|
+
/** Si es error y `approval_policy` está, el botón ofrece «Pedir autorización». */
|
|
44
|
+
approval_policy?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** LoadFromDocument: candidato a cargar (venta, cotización, ticket, OT, factura origen de NC). */
|
|
48
|
+
export interface LoadCandidate {
|
|
49
|
+
source: string // DocumentFormSource.key
|
|
50
|
+
id: string
|
|
51
|
+
label: string
|
|
52
|
+
party_id?: string
|
|
53
|
+
total?: number
|
|
54
|
+
state?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function blockingChecks(checks: readonly ValidationCheck[]): ValidationCheck[] {
|
|
58
|
+
return checks.filter(c => c.severity === 'error' && !c.approval_policy)
|
|
59
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Hooks de React sobre el registro de contribuciones (contributions.ts).
|
|
2
|
+
// La lógica vive en las funciones puras; aquí solo se conecta con
|
|
3
|
+
// useInstalledAddons y con la suscripción para re-pintar cuando un remote
|
|
4
|
+
// registra o se desmonta.
|
|
5
|
+
import { useMemo, useSyncExternalStore } from 'react'
|
|
6
|
+
import { useInstalledAddons, type InstalledAddonsValue } from '../installed-addons-context'
|
|
7
|
+
import {
|
|
8
|
+
contributionsVersion,
|
|
9
|
+
resolveContributions,
|
|
10
|
+
subscribeContributions,
|
|
11
|
+
type DocumentEditorContext,
|
|
12
|
+
type DocumentRegion,
|
|
13
|
+
type IsAddonInstalled,
|
|
14
|
+
type ResolvedDocumentContribution,
|
|
15
|
+
} from './contributions'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Predicado de instalación a partir del InstalledAddonsProvider. Sin provider
|
|
19
|
+
* devuelve siempre true: el host solo carga remotes de addons instalados, así
|
|
20
|
+
* que si la contribución existe, su addon está (el servidor revalida igual).
|
|
21
|
+
*/
|
|
22
|
+
export function installedPredicate(value: InstalledAddonsValue | null): IsAddonInstalled {
|
|
23
|
+
return value ? (key: string) => value.addons.has(key) : () => true
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `installedPredicate(useInstalledAddons())`, estable entre renders. */
|
|
27
|
+
export function useIsAddonInstalled(): IsAddonInstalled {
|
|
28
|
+
const value = useInstalledAddons()
|
|
29
|
+
return useMemo(() => installedPredicate(value), [value])
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Re-pinta en cada register/dispose de contribuciones o modales federados. */
|
|
33
|
+
export function useContributionsVersion(): number {
|
|
34
|
+
return useSyncExternalStore(subscribeContributions, contributionsVersion, contributionsVersion)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Contribuciones aplicables a `region` del documento `ctx` (ya filtradas y ordenadas). */
|
|
38
|
+
export function useDocumentContributions(ctx: DocumentEditorContext, region: DocumentRegion): ResolvedDocumentContribution<any>[] {
|
|
39
|
+
useContributionsVersion()
|
|
40
|
+
const isInstalled = useIsAddonInstalled()
|
|
41
|
+
return resolveContributions(ctx, region, isInstalled)
|
|
42
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// RecordPrefillBar — pinta los ayudantes de captura (record-prefill-registry de
|
|
2
|
+
// @asteby/metacore-sdk) arriba del formulario genérico de un modelo.
|
|
3
|
+
//
|
|
4
|
+
// El formulario no conoce a los addons: sólo pregunta "¿quién aporta prefill
|
|
5
|
+
// para <model> en <mode>?". Hoy: fiscal_mexico → «Cargar constancia» en
|
|
6
|
+
// Customer/Supplier. Mañana: un vertical restaurante aporta «Importar menú» en
|
|
7
|
+
// Product, un vertical ferretería «Leer código de proveedor», etc.
|
|
8
|
+
//
|
|
9
|
+
// Seguridad del patch: sólo se aplican claves que el formulario declara
|
|
10
|
+
// (allowedKeys) — un addon no puede inyectar columnas ocultas (organization_id,
|
|
11
|
+
// created_by…) por esta vía; el backend sigue validando como siempre.
|
|
12
|
+
import { useCallback, useSyncExternalStore } from 'react'
|
|
13
|
+
import {
|
|
14
|
+
getRecordPrefills,
|
|
15
|
+
recordPrefillsVersion,
|
|
16
|
+
subscribeRecordPrefills,
|
|
17
|
+
type RecordFormMode,
|
|
18
|
+
} from '@asteby/metacore-sdk'
|
|
19
|
+
import { useCan } from './permissions-context'
|
|
20
|
+
|
|
21
|
+
export interface RecordPrefillBarProps {
|
|
22
|
+
model: string
|
|
23
|
+
mode: RecordFormMode
|
|
24
|
+
recordId?: string | null
|
|
25
|
+
values: Record<string, any>
|
|
26
|
+
allowedKeys: ReadonlySet<string>
|
|
27
|
+
onApply: (patch: Record<string, any>) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function RecordPrefillBar({ model, mode, recordId, values, allowedKeys, onApply }: RecordPrefillBarProps) {
|
|
31
|
+
// Re-render cuando un remote registra/des-registra (carga tardía o unbind).
|
|
32
|
+
useSyncExternalStore(subscribeRecordPrefills, recordPrefillsVersion, recordPrefillsVersion)
|
|
33
|
+
const can = useCan()
|
|
34
|
+
const items = getRecordPrefills(model, mode).filter(c => !c.permission || can(c.permission))
|
|
35
|
+
|
|
36
|
+
const applyValues = useCallback(
|
|
37
|
+
(patch: Record<string, unknown>) => {
|
|
38
|
+
const safe: Record<string, any> = {}
|
|
39
|
+
for (const [k, v] of Object.entries(patch ?? {})) {
|
|
40
|
+
if (allowedKeys.has(k)) safe[k] = v
|
|
41
|
+
}
|
|
42
|
+
if (Object.keys(safe).length > 0) onApply(safe)
|
|
43
|
+
},
|
|
44
|
+
[allowedKeys, onApply],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if (items.length === 0) return null
|
|
48
|
+
return (
|
|
49
|
+
<div className="flex flex-wrap items-center gap-2" data-aby-record-prefill="">
|
|
50
|
+
{items.map(({ id, component: C }) => (
|
|
51
|
+
<C
|
|
52
|
+
key={id}
|
|
53
|
+
model={model}
|
|
54
|
+
mode={mode}
|
|
55
|
+
recordId={recordId ?? undefined}
|
|
56
|
+
values={values}
|
|
57
|
+
applyValues={applyValues}
|
|
58
|
+
/>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { isActionAllowedForRowState, isRowActionVisible, lifecycleStageField } from './dynamic-columns'
|
|
3
|
+
|
|
4
|
+
// Pitsline taller: WorkOrder has a stage machine (stage_field "stage") and its
|
|
5
|
+
// row actions declare requiresState with STAGE keys. The kernel gates on the
|
|
6
|
+
// stage column; the UI must do the same or every action is hidden (the OT
|
|
7
|
+
// row/kanban menu showed only Ver/Editar/Eliminar).
|
|
8
|
+
describe('requiresState gate with a stage machine', () => {
|
|
9
|
+
const process = { key: 'process', requiresState: ['reception', 'diagnosis', 'in_process'] }
|
|
10
|
+
const row = { id: '1', status: 'open', stage: 'diagnosis' }
|
|
11
|
+
|
|
12
|
+
it('reads the stage field when the model declares one', () => {
|
|
13
|
+
expect(isActionAllowedForRowState(process, row, 'stage')).toBe(true)
|
|
14
|
+
expect(isRowActionVisible(process, row, 'stage')).toBe(true)
|
|
15
|
+
expect(isActionAllowedForRowState(process, { ...row, stage: 'delivered' }, 'stage')).toBe(false)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('keeps the historical status/state lookup without a stage field', () => {
|
|
19
|
+
expect(isActionAllowedForRowState(process, row)).toBe(false)
|
|
20
|
+
expect(isActionAllowedForRowState({ requiresState: ['open'] }, row)).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('falls back to status when the stage value is blank', () => {
|
|
24
|
+
expect(isActionAllowedForRowState({ requiresState: ['open'] }, { status: 'open', stage: '' }, 'stage')).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('resolves the stage field from metadata', () => {
|
|
28
|
+
expect(lifecycleStageField({ stage_field: 'stage' })).toBe('stage')
|
|
29
|
+
expect(lifecycleStageField({ stages: [{ key: 'a' }], group_by: 'stage' })).toBe('stage')
|
|
30
|
+
expect(lifecycleStageField({ group_by: 'status' })).toBeUndefined()
|
|
31
|
+
expect(lifecycleStageField(undefined)).toBeUndefined()
|
|
32
|
+
})
|
|
33
|
+
})
|