@asteby/metacore-runtime-react 47.1.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.
- package/CHANGELOG.md +51 -0
- package/dist/action-modal-dispatcher.d.ts +17 -3
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +80 -13
- package/dist/action-priority.d.ts +34 -0
- package/dist/action-priority.d.ts.map +1 -0
- package/dist/action-priority.js +60 -0
- package/dist/business/catalog-product-search.d.ts +32 -0
- package/dist/business/catalog-product-search.d.ts.map +1 -0
- package/dist/business/catalog-product-search.js +71 -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 +119 -0
- package/dist/business/document-editor-model.d.ts.map +1 -0
- package/dist/business/document-editor-model.js +317 -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 +493 -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 +16 -1
- package/dist/business/line-items.d.ts.map +1 -1
- package/dist/business/line-items.js +14 -2
- 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 +14 -7
- package/dist/document/document-page.d.ts.map +1 -1
- package/dist/document/document-page.js +8 -1
- package/dist/document/evaluate.d.ts.map +1 -1
- package/dist/document/evaluate.js +17 -1
- package/dist/document/index.d.ts +1 -0
- package/dist/document/index.d.ts.map +1 -1
- package/dist/document/index.js +1 -0
- package/dist/document/secondary-bar.d.ts +20 -0
- package/dist/document/secondary-bar.d.ts.map +1 -0
- package/dist/document/secondary-bar.js +44 -0
- package/dist/document/types.d.ts +8 -0
- package/dist/document/types.d.ts.map +1 -1
- 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 +46 -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 +9 -13
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +27 -21
- package/dist/dynamic-crud-page.d.ts +18 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +15 -6
- 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 +78 -20
- package/dist/dynamic-kanban.d.ts +9 -3
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +31 -12
- 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-row-actions.d.ts +8 -0
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +12 -2
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +64 -63
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -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/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 +93 -0
- package/dist/primitives/contributions.d.ts.map +1 -0
- package/dist/primitives/contributions.js +163 -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-actions.d.ts +21 -0
- package/dist/record-actions.d.ts.map +1 -0
- package/dist/record-actions.js +76 -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/row-actions-menu.d.ts +32 -0
- package/dist/row-actions-menu.d.ts.map +1 -0
- package/dist/row-actions-menu.js +59 -0
- package/dist/slot.d.ts +4 -21
- package/dist/slot.d.ts.map +1 -1
- package/dist/slot.js +14 -36
- package/dist/types.d.ts +134 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/catalog-product-search.test.ts +61 -0
- package/src/__tests__/document-editor-model.test.ts +221 -0
- package/src/__tests__/document-editor.test.tsx +340 -0
- package/src/__tests__/document-form-dialog.test.tsx +158 -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__/installed-addons-context.test.tsx +50 -0
- package/src/__tests__/line-item-totals.test.ts +28 -0
- package/src/__tests__/prefill-scalar-from-record.test.ts +52 -1
- 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__/row-action-link-navigation.test.ts +17 -0
- package/src/__tests__/secondary-actions.test.tsx +261 -0
- package/src/action-modal-dispatcher.tsx +97 -12
- package/src/action-priority.ts +82 -0
- package/src/business/business.test.ts +1 -1
- package/src/business/catalog-product-search.ts +105 -0
- package/src/business/catalog.ts +2 -0
- package/src/business/document-editor-model.ts +367 -0
- package/src/business/document-editor-panels.tsx +164 -0
- package/src/business/document-editor.tsx +781 -0
- package/src/business/document-lines.ts +5 -1
- package/src/business/index.ts +5 -0
- package/src/business/line-items.ts +31 -2
- package/src/business/payment-allocator.tsx +110 -0
- package/src/business/product-search.ts +4 -0
- package/src/dialogs/dynamic-record.tsx +29 -6
- package/src/document/document-page.tsx +15 -0
- package/src/document/evaluate.ts +17 -1
- package/src/document/index.ts +1 -0
- package/src/document/secondary-bar.tsx +92 -0
- package/src/document/types.ts +8 -0
- package/src/document-form-dialog.tsx +96 -12
- package/src/dynamic-columns-helpers.ts +22 -0
- package/src/dynamic-columns.tsx +35 -62
- package/src/dynamic-crud-page.tsx +33 -5
- package/src/dynamic-form-schema.ts +15 -2
- package/src/dynamic-form.tsx +117 -39
- package/src/dynamic-kanban.tsx +34 -7
- package/src/dynamic-relation-helpers.ts +9 -0
- package/src/dynamic-relation.tsx +7 -2
- package/src/dynamic-row-actions.tsx +12 -2
- package/src/dynamic-table.tsx +3 -0
- package/src/index.ts +46 -1
- package/src/installed-addons-context.tsx +58 -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 +255 -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-actions.ts +101 -0
- package/src/record-prefill-bar.tsx +62 -0
- package/src/row-action-stage-gate.test.ts +33 -0
- package/src/row-actions-menu.tsx +159 -0
- package/src/slot.tsx +18 -53
- package/src/types.ts +129 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// Reglas puras del DocumentEditor (sin React ni host): qué se ve a primera
|
|
2
|
+
// vista y qué va en «Opciones fiscales», qué copiar de la contraparte, cómo
|
|
3
|
+
// traer renglones de un documento origen, cómo repartir un cobro entre los
|
|
4
|
+
// documentos abiertos y qué revisar antes de guardar. Genérico: factura,
|
|
5
|
+
// cotización, pedido, OC, nota de crédito y cobro/REP. El SDK no conoce CFDI.
|
|
6
|
+
import { roundMoney, toAmount } from './format'
|
|
7
|
+
import { computeTotals, makeLine, type LineItem } from './line-items'
|
|
8
|
+
import { optionPassesRule, getOptionFilter } from '../option-filter'
|
|
9
|
+
import type { ResolvedOption } from '../use-options-resolver'
|
|
10
|
+
import type { Allocation, AllocationIssue, OpenDocument } from '../primitives/allocation'
|
|
11
|
+
import type {
|
|
12
|
+
ActionFieldDef,
|
|
13
|
+
DocumentFormLines,
|
|
14
|
+
DocumentFormSource,
|
|
15
|
+
DocumentFormOpenDocuments,
|
|
16
|
+
DocumentFormsManifest,
|
|
17
|
+
DocumentFormType,
|
|
18
|
+
} from '../types'
|
|
19
|
+
|
|
20
|
+
export const EXTENSION_PREFIX = 'fiscal_data.'
|
|
21
|
+
|
|
22
|
+
/** Secciones de campo que el editor pliega en «Opciones fiscales». */
|
|
23
|
+
const ADVANCED_SECTIONS = new Set(['fiscal', 'advanced'])
|
|
24
|
+
|
|
25
|
+
export type EditorLinesConfig = DocumentFormLines & { field: string }
|
|
26
|
+
|
|
27
|
+
/** true si el tipo se abre en el DocumentEditor (`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)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Paso de renglones efectivo del tipo (o undefined si no tiene). */
|
|
35
|
+
export function editorLinesConfig(type: DocumentFormType, forms: Pick<DocumentFormsManifest, 'lines_field'>): EditorLinesConfig | undefined {
|
|
36
|
+
if (!type.lines) return undefined
|
|
37
|
+
const cfg: DocumentFormLines = type.lines === true ? {} : type.lines
|
|
38
|
+
const fallback = cfg.kind === 'allocation' ? 'allocations' : 'lines'
|
|
39
|
+
return { ...cfg, field: cfg.field ?? forms.lines_field ?? fallback }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Lee `a.b.c` de un registro (incluye `fiscal_data.x` dentro del jsonb). */
|
|
43
|
+
function readPath(rec: Record<string, any> | null | undefined, path: string): unknown {
|
|
44
|
+
if (!rec) return undefined
|
|
45
|
+
if (path in rec) return rec[path]
|
|
46
|
+
return path.split('.').reduce<any>((acc, k) => (acc == null ? undefined : acc[k]), rec)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const hasValue = (v: unknown) => v !== undefined && v !== null && v !== ''
|
|
50
|
+
|
|
51
|
+
function declaredDefault(f: ActionFieldDef): unknown {
|
|
52
|
+
return (f as { defaultValue?: unknown; default?: unknown }).defaultValue ?? (f as { default?: unknown }).default
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Divulgación progresiva: ¿el campo va plegado en «Opciones fiscales»?
|
|
57
|
+
* - campos de extensión (`fiscal_data.*`) que aportan otros addons;
|
|
58
|
+
* - `section: "fiscal" | "advanced"`;
|
|
59
|
+
* - catálogos opcionales que ya traen un default (método, serie, relación…):
|
|
60
|
+
* el usuario no necesita tocarlos para guardar.
|
|
61
|
+
* Lo obligatorio sin default, la contraparte y el monto siempre están a la vista.
|
|
62
|
+
*/
|
|
63
|
+
export function isAdvancedField(f: ActionFieldDef, essentialKeys: ReadonlySet<string> = new Set()): boolean {
|
|
64
|
+
if (essentialKeys.has(f.key)) return false
|
|
65
|
+
if (f.key.startsWith(EXTENSION_PREFIX)) return true
|
|
66
|
+
if (f.section && ADVANCED_SECTIONS.has(f.section)) return true
|
|
67
|
+
const hasOptions = Array.isArray(f.options) && f.options.length > 0
|
|
68
|
+
return !f.required && hasOptions && hasValue(declaredDefault(f))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface EditorFieldGroups {
|
|
72
|
+
/** Campo que elige la contraparte (cliente/proveedor), si el tipo la declara. */
|
|
73
|
+
party?: ActionFieldDef
|
|
74
|
+
/** A la vista: lo esencial del encabezado. */
|
|
75
|
+
essential: ActionFieldDef[]
|
|
76
|
+
/** Plegados en «Opciones fiscales». */
|
|
77
|
+
advanced: ActionFieldDef[]
|
|
78
|
+
/** Notas largas (textarea): al final. */
|
|
79
|
+
notes: ActionFieldDef[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Reparte los campos del tipo (más los de extensión) en grupos de pantalla. */
|
|
83
|
+
export function splitEditorFields(
|
|
84
|
+
fields: readonly ActionFieldDef[],
|
|
85
|
+
opts: { partyField?: string; essentialKeys?: readonly string[]; extensionFields?: readonly ActionFieldDef[] } = {},
|
|
86
|
+
): EditorFieldGroups {
|
|
87
|
+
const essentialKeys = new Set(opts.essentialKeys ?? [])
|
|
88
|
+
const out: EditorFieldGroups = { essential: [], advanced: [], notes: [] }
|
|
89
|
+
const seen = new Set<string>()
|
|
90
|
+
for (const f of [...fields, ...(opts.extensionFields ?? [])]) {
|
|
91
|
+
if (seen.has(f.key)) continue
|
|
92
|
+
seen.add(f.key)
|
|
93
|
+
if (opts.partyField && f.key === opts.partyField) out.party = f
|
|
94
|
+
else if (f.type === 'textarea' || f.widget === 'textarea') out.notes.push(f)
|
|
95
|
+
else if (isAdvancedField(f, essentialKeys)) out.advanced.push(f)
|
|
96
|
+
else out.essential.push(f)
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Etiqueta clara con el código como dato secundario:
|
|
103
|
+
* `03 · Transferencia electrónica` → `Transferencia electrónica · 03`.
|
|
104
|
+
* Solo reordena el patrón «CÓDIGO · texto»; cualquier otra etiqueta pasa igual.
|
|
105
|
+
*/
|
|
106
|
+
export function friendlyOptionLabel(label: string): string {
|
|
107
|
+
const m = /^\s*([A-Z0-9]{1,5})\s*[·\-–]\s*(.+)$/.exec(label)
|
|
108
|
+
return m && /\d/.test(m[1]) ? `${m[2].trim()} · ${m[1]}` : label
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** El mismo campo con sus opciones en formato «texto · código». */
|
|
112
|
+
export function withFriendlyOptions(f: ActionFieldDef): ActionFieldDef {
|
|
113
|
+
if (!Array.isArray(f.options) || f.options.length === 0) return f
|
|
114
|
+
return { ...f, options: f.options.map((o) => ({ ...o, label: friendlyOptionLabel(String(o.label ?? o.value)) })) }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Defaults del encabezado que salen de la contraparte: los campos de extensión
|
|
119
|
+
* con la MISMA clave en ambos modelos (p. ej. `fiscal_data.uso_cfdi` de Customer
|
|
120
|
+
* → `fiscal_data.uso_cfdi` del documento) y la moneda.
|
|
121
|
+
* Solo llena lo que está vacío: nunca pisa lo que el usuario ya capturó.
|
|
122
|
+
*/
|
|
123
|
+
export function partyDefaults(
|
|
124
|
+
party: Record<string, any> | null,
|
|
125
|
+
documentFieldKeys: readonly string[],
|
|
126
|
+
current: Record<string, unknown>,
|
|
127
|
+
): Record<string, unknown> {
|
|
128
|
+
if (!party) return {}
|
|
129
|
+
const out: Record<string, unknown> = {}
|
|
130
|
+
for (const key of documentFieldKeys) {
|
|
131
|
+
if (!key.startsWith(EXTENSION_PREFIX) || hasValue(current[key])) continue
|
|
132
|
+
const v = readPath(party, key)
|
|
133
|
+
if (hasValue(v)) out[key] = v
|
|
134
|
+
}
|
|
135
|
+
if (!hasValue(current.currency_code) && typeof party.currency_code === 'string' && party.currency_code) {
|
|
136
|
+
out.currency_code = party.currency_code
|
|
137
|
+
}
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Filas de la tarjeta de contraparte: resumen declarado + extensiones (máximo `limit`). */
|
|
142
|
+
export function partySummaryRows(
|
|
143
|
+
party: Record<string, any> | null,
|
|
144
|
+
summary: readonly string[] = [],
|
|
145
|
+
labels: Record<string, string> = {},
|
|
146
|
+
limit = 6,
|
|
147
|
+
): Array<{ key: string; label: string; value: string }> {
|
|
148
|
+
if (!party) return []
|
|
149
|
+
const keys = new Set<string>(summary)
|
|
150
|
+
const fd = party.fiscal_data
|
|
151
|
+
if (fd && typeof fd === 'object') for (const k of Object.keys(fd)) keys.add(EXTENSION_PREFIX + k)
|
|
152
|
+
const rows: Array<{ key: string; label: string; value: string }> = []
|
|
153
|
+
for (const key of keys) {
|
|
154
|
+
const v = readPath(party, key)
|
|
155
|
+
if (!hasValue(v) || typeof v === 'object') continue
|
|
156
|
+
rows.push({ key, label: labels[key] ?? key.replace(EXTENSION_PREFIX, ''), value: String(v) })
|
|
157
|
+
}
|
|
158
|
+
return rows.slice(0, limit)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface CreditStatus {
|
|
162
|
+
limit?: number
|
|
163
|
+
balance?: number
|
|
164
|
+
overdue?: number
|
|
165
|
+
available?: number
|
|
166
|
+
holdReason?: string
|
|
167
|
+
/** `ok` | `warn` (supera 80 % o hay vencido) | `block` (retenido o sin crédito disponible). */
|
|
168
|
+
level: 'ok' | 'warn' | 'block'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function creditStatus(
|
|
172
|
+
party: Record<string, any> | null,
|
|
173
|
+
cols: { limit?: string; balance?: string; overdue?: string; hold_reason?: string } | undefined,
|
|
174
|
+
documentTotal: number,
|
|
175
|
+
): CreditStatus | null {
|
|
176
|
+
if (!party || !cols) return null
|
|
177
|
+
const n = (k?: string) => (k && hasValue(party[k]) ? toAmount(party[k]) : undefined)
|
|
178
|
+
const limit = n(cols.limit)
|
|
179
|
+
const balance = n(cols.balance)
|
|
180
|
+
const overdue = n(cols.overdue)
|
|
181
|
+
const holdReason = cols.hold_reason ? (party[cols.hold_reason] as string | undefined) || undefined : undefined
|
|
182
|
+
if (limit == null && balance == null && overdue == null && !holdReason) return null
|
|
183
|
+
const available = limit != null ? roundMoney(limit - (balance ?? 0)) : undefined
|
|
184
|
+
let level: CreditStatus['level'] = 'ok'
|
|
185
|
+
if (holdReason || (available != null && limit! > 0 && available < documentTotal)) level = 'block'
|
|
186
|
+
else if ((overdue ?? 0) > 0 || (limit != null && limit > 0 && (balance ?? 0) + documentTotal > 0.8 * limit)) level = 'warn'
|
|
187
|
+
return { limit, balance, overdue, available, holdReason, level }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Renglones de un documento origen → LineItem. `map` traduce nombres
|
|
192
|
+
* (`description: product_name`, `discount: discount_pct`). Si el origen trae
|
|
193
|
+
* `tax_amount` y `subtotal` pero no la tasa, la deriva. `credit` (NC) topa la
|
|
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.
|
|
199
|
+
*/
|
|
200
|
+
export function linesFromSource(
|
|
201
|
+
rows: unknown,
|
|
202
|
+
map: Record<string, string> = {},
|
|
203
|
+
opts: { kind?: DocumentFormLines['kind']; discountMode?: DocumentFormLines['discount_mode'] } = {},
|
|
204
|
+
): LineItem[] {
|
|
205
|
+
if (!Array.isArray(rows)) return []
|
|
206
|
+
return rows.filter((raw) => !isFullyConsumed(raw)).map((raw) => {
|
|
207
|
+
const r = (raw ?? {}) as Record<string, any>
|
|
208
|
+
const get = (k: string) => r[map[k] ?? k]
|
|
209
|
+
const pending = hasValue(r.remaining_quantity) ? toAmount(r.remaining_quantity) : undefined
|
|
210
|
+
const qty = pending ?? toAmount(get('quantity') ?? 1)
|
|
211
|
+
let rate = get('tax_rate')
|
|
212
|
+
if (!hasValue(rate)) {
|
|
213
|
+
const sub = toAmount(get('subtotal'))
|
|
214
|
+
const tax = toAmount(get('tax_amount'))
|
|
215
|
+
rate = sub > 0 ? Math.round((tax / sub) * 10000) / 10000 : 0
|
|
216
|
+
}
|
|
217
|
+
const r2 = toAmount(rate)
|
|
218
|
+
const line = makeLine({
|
|
219
|
+
product_id: get('product_id') ?? undefined,
|
|
220
|
+
sku: get('sku') ?? undefined,
|
|
221
|
+
description: String(get('description') ?? get('product_name') ?? ''),
|
|
222
|
+
quantity: qty,
|
|
223
|
+
unit_price: toAmount(get('unit_price')),
|
|
224
|
+
discount: toAmount(get('discount') ?? 0),
|
|
225
|
+
tax_rate: r2 > 1 ? r2 / 100 : r2,
|
|
226
|
+
unit: get('unit') ?? undefined,
|
|
227
|
+
})
|
|
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)
|
|
235
|
+
return line
|
|
236
|
+
})
|
|
237
|
+
}
|
|
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
|
+
|
|
256
|
+
export interface EditorIssue {
|
|
257
|
+
field?: string
|
|
258
|
+
severity: 'error' | 'warning'
|
|
259
|
+
message: string
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Revisión local (instantánea) de los renglones. Sin ruido: solo lo que hay que mirar. */
|
|
263
|
+
export function localIssues(lines: LineItem[], opts: { requireLines?: boolean; requireTax?: boolean } = {}): EditorIssue[] {
|
|
264
|
+
const issues: EditorIssue[] = []
|
|
265
|
+
const items = lines.filter((l) => l.kind === 'item')
|
|
266
|
+
if ((opts.requireLines ?? true) && items.length === 0) {
|
|
267
|
+
issues.push({ field: 'lines', severity: 'error', message: 'Agrega al menos un renglón.' })
|
|
268
|
+
}
|
|
269
|
+
items.forEach((l, i) => {
|
|
270
|
+
if (!l.description.trim()) issues.push({ field: `lines.${i}`, severity: 'error', message: `Renglón ${i + 1}: falta la descripción.` })
|
|
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
|
+
}
|
|
279
|
+
if (opts.requireTax && toAmount(l.tax_rate) === 0) {
|
|
280
|
+
issues.push({ field: `lines.${i}`, severity: 'warning', message: `Renglón ${i + 1}: sin impuesto.` })
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
if (computeTotals(lines).total < 0) issues.push({ severity: 'error', message: 'El total no puede ser negativo.' })
|
|
284
|
+
return issues
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---- Cobro / REP (lines.kind = allocation) ----------------------------------
|
|
288
|
+
|
|
289
|
+
/** Documento abierto (fila de /data) → OpenDocument para PaymentAllocator. */
|
|
290
|
+
export function toOpenDocuments(rows: unknown, cfg: DocumentFormOpenDocuments, currency: string): OpenDocument[] {
|
|
291
|
+
if (!Array.isArray(rows)) return []
|
|
292
|
+
const rules = getOptionFilter(cfg)
|
|
293
|
+
const out: OpenDocument[] = []
|
|
294
|
+
for (const raw of rows) {
|
|
295
|
+
if (!raw || typeof raw !== 'object') continue
|
|
296
|
+
const r = raw as Record<string, any>
|
|
297
|
+
if (rules.length) {
|
|
298
|
+
const opt = { id: r.id, value: r.id, label: String(r[cfg.number_field] ?? ''), name: '', meta: r } as unknown as ResolvedOption
|
|
299
|
+
if (!rules.every((rule) => optionPassesRule(opt, rule))) continue
|
|
300
|
+
}
|
|
301
|
+
const balance = roundMoney(toAmount(readPath(r, cfg.balance_field)))
|
|
302
|
+
if (balance <= 0) continue
|
|
303
|
+
out.push({
|
|
304
|
+
id: String(r.id),
|
|
305
|
+
number: String(readPath(r, cfg.number_field) ?? r.id),
|
|
306
|
+
issued_at: String(readPath(r, cfg.issued_field ?? 'issued_at') ?? r.created_at ?? ''),
|
|
307
|
+
due_at: (cfg.due_field ? (readPath(r, cfg.due_field) as string | null) : null) ?? null,
|
|
308
|
+
total: roundMoney(toAmount(cfg.total_field ? readPath(r, cfg.total_field) : balance)),
|
|
309
|
+
balance,
|
|
310
|
+
currency: String(r.currency_code ?? r.currency ?? currency),
|
|
311
|
+
payment_method: cfg.method_field ? ((readPath(r, cfg.method_field) as string | null) ?? null) : null,
|
|
312
|
+
installments_paid: hasValue(r.installments_paid) ? toAmount(r.installments_paid) : undefined,
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
return out
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Monto actual de cada documento, para pasar a modo manual sin perder lo repartido. */
|
|
319
|
+
export function manualFromAllocations(allocations: readonly Allocation[]): Record<string, number> {
|
|
320
|
+
return Object.fromEntries(allocations.map((a) => [a.document_id, a.amount]))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Renglones del payload: solo los dos campos que declara la acción (`invoice_id`, `amount`). */
|
|
324
|
+
export function allocationPayload(allocations: readonly Allocation[], cfg: DocumentFormOpenDocuments): Array<Record<string, unknown>> {
|
|
325
|
+
return allocations.filter((a) => a.amount > 0).map((a) => ({ [cfg.line_document_field]: a.document_id, [cfg.line_amount_field]: a.amount }))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Campo del encabezado con el monto recibido: `amount` si existe; si no, el
|
|
330
|
+
* primer campo numérico/moneda. Convención documentada (el schema no lo nombra).
|
|
331
|
+
*/
|
|
332
|
+
export function allocationAmountField(fields: readonly ActionFieldDef[]): string | undefined {
|
|
333
|
+
return (
|
|
334
|
+
fields.find((f) => f.key === 'amount')?.key ??
|
|
335
|
+
fields.find((f) => ['number', 'numeric', 'decimal', 'currency', 'money'].includes(f.type) || f.widget === 'currency')?.key
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const ALLOCATION_MESSAGES: Record<AllocationIssue['code'], string> = {
|
|
340
|
+
non_positive: 'Captura el monto recibido.',
|
|
341
|
+
over_balance: 'Se aplica más que el saldo del documento.',
|
|
342
|
+
over_amount: 'Lo aplicado supera el monto recibido.',
|
|
343
|
+
currency_mismatch: 'El documento está en otra moneda.',
|
|
344
|
+
unapplied: 'Queda monto sin aplicar.',
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** AllocationIssue → mensaje en español claro (con el número del documento). */
|
|
348
|
+
export function allocationIssueMessages(issues: readonly AllocationIssue[], docs: readonly OpenDocument[]): EditorIssue[] {
|
|
349
|
+
const byId = new Map(docs.map((d) => [d.id, d]))
|
|
350
|
+
return issues.map((i) => {
|
|
351
|
+
const doc = i.document_id ? byId.get(i.document_id) : undefined
|
|
352
|
+
return {
|
|
353
|
+
field: i.code === 'non_positive' ? 'amount' : 'allocations',
|
|
354
|
+
severity: i.severity,
|
|
355
|
+
message: doc ? `${doc.number}: ${ALLOCATION_MESSAGES[i.code]}` : ALLOCATION_MESSAGES[i.code],
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Días de atraso de un documento abierto (0 si no está vencido o no tiene vencimiento). */
|
|
361
|
+
export function overdueDays(doc: Pick<OpenDocument, 'due_at'>, today: Date = new Date()): number {
|
|
362
|
+
if (!doc.due_at) return 0
|
|
363
|
+
const due = new Date(doc.due_at)
|
|
364
|
+
if (Number.isNaN(due.getTime())) return 0
|
|
365
|
+
const ms = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()) - Date.UTC(due.getUTCFullYear(), due.getUTCMonth(), due.getUTCDate())
|
|
366
|
+
return Math.max(0, Math.floor(ms / 86_400_000))
|
|
367
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Piezas visuales del DocumentEditor: secciones, sección plegable, tarjeta de
|
|
2
|
+
// contraparte, totales, revisión y vista previa. Sin estado de negocio: el
|
|
3
|
+
// editor (o un remote federado) les pasa los datos ya calculados. Los contratos
|
|
4
|
+
// de datos viven en primitives/panels.ts.
|
|
5
|
+
import { useId, useState, type ReactNode } from 'react'
|
|
6
|
+
import { Badge } from '@asteby/metacore-ui/primitives'
|
|
7
|
+
import { AlertTriangle, ChevronDown, XCircle } from 'lucide-react'
|
|
8
|
+
import type { CreditStatus, EditorIssue } from './document-editor-model'
|
|
9
|
+
import type { Formatter } from './format'
|
|
10
|
+
|
|
11
|
+
/** Bloque con título discreto y espaciado generoso (sin bordes pesados). */
|
|
12
|
+
export function EditorSection({ title, hint, children, slot }: { title?: ReactNode; hint?: ReactNode; children: ReactNode; slot?: string }) {
|
|
13
|
+
return (
|
|
14
|
+
<section className="space-y-3" data-slot={slot}>
|
|
15
|
+
{(title || hint) && (
|
|
16
|
+
<header className="flex items-baseline justify-between gap-3">
|
|
17
|
+
{title && <h3 className="text-sm font-semibold">{title}</h3>}
|
|
18
|
+
{hint && <span className="text-xs text-muted-foreground">{hint}</span>}
|
|
19
|
+
</header>
|
|
20
|
+
)}
|
|
21
|
+
{children}
|
|
22
|
+
</section>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CollapsibleSectionProps {
|
|
27
|
+
title: ReactNode
|
|
28
|
+
/** Resumen de una línea cuando está cerrada (p. ej. «G03 · PUE»). */
|
|
29
|
+
summary?: ReactNode
|
|
30
|
+
open?: boolean
|
|
31
|
+
defaultOpen?: boolean
|
|
32
|
+
onOpenChange?: (open: boolean) => void
|
|
33
|
+
children: ReactNode
|
|
34
|
+
slot?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Sección plegable. El contenido queda MONTADO aunque esté cerrada (`hidden`):
|
|
39
|
+
* las contribuciones de otros addons siguen aplicando defaults y reportando
|
|
40
|
+
* revisiones sin que el usuario abra la sección.
|
|
41
|
+
*/
|
|
42
|
+
export function CollapsibleSection({ title, summary, open, defaultOpen = false, onOpenChange, children, slot }: CollapsibleSectionProps) {
|
|
43
|
+
const [inner, setInner] = useState(defaultOpen)
|
|
44
|
+
const isOpen = open ?? inner
|
|
45
|
+
const id = useId()
|
|
46
|
+
const toggle = () => {
|
|
47
|
+
setInner(!isOpen)
|
|
48
|
+
onOpenChange?.(!isOpen)
|
|
49
|
+
}
|
|
50
|
+
return (
|
|
51
|
+
<section className="rounded-lg border border-dashed" data-slot={slot} data-state={isOpen ? 'open' : 'closed'}>
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
aria-expanded={isOpen}
|
|
55
|
+
aria-controls={id}
|
|
56
|
+
onClick={toggle}
|
|
57
|
+
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm"
|
|
58
|
+
>
|
|
59
|
+
<span className="font-medium">{title}</span>
|
|
60
|
+
<span className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
|
|
61
|
+
{!isOpen && summary && <span className="truncate">{summary}</span>}
|
|
62
|
+
<ChevronDown className={`size-4 shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`} aria-hidden />
|
|
63
|
+
</span>
|
|
64
|
+
</button>
|
|
65
|
+
<div id={id} hidden={!isOpen} className="space-y-4 px-4 pb-4">
|
|
66
|
+
{children}
|
|
67
|
+
</div>
|
|
68
|
+
</section>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PartyCardProps {
|
|
73
|
+
name?: string
|
|
74
|
+
rows: Array<{ key: string; label: string; value: string }>
|
|
75
|
+
credit?: CreditStatus | null
|
|
76
|
+
fmt: Formatter
|
|
77
|
+
children?: ReactNode
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Tarjeta compacta de la contraparte: nombre, 3-6 datos clave y estado de crédito. */
|
|
81
|
+
export function PartyCard({ name, rows, credit, fmt, children }: PartyCardProps) {
|
|
82
|
+
return (
|
|
83
|
+
<div className="rounded-md bg-muted/40 px-4 py-3 text-sm" data-slot="party-card">
|
|
84
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
85
|
+
{name && <span className="font-medium">{name}</span>}
|
|
86
|
+
{credit && (
|
|
87
|
+
<Badge variant={credit.level === 'block' ? 'destructive' : credit.level === 'warn' ? 'secondary' : 'outline'} data-slot="party-credit">
|
|
88
|
+
{credit.holdReason
|
|
89
|
+
? credit.holdReason
|
|
90
|
+
: credit.available != null
|
|
91
|
+
? `Crédito disponible ${fmt.money(credit.available)}`
|
|
92
|
+
: `Saldo ${fmt.money(credit.balance ?? 0)}`}
|
|
93
|
+
</Badge>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
{rows.length > 0 && (
|
|
97
|
+
<dl className="mt-2 grid grid-cols-2 gap-x-6 gap-y-1 sm:grid-cols-3">
|
|
98
|
+
{rows.map((r) => (
|
|
99
|
+
<div key={r.key} className="min-w-0">
|
|
100
|
+
<dt className="text-xs text-muted-foreground">{r.label}</dt>
|
|
101
|
+
<dd className="truncate">{r.value}</dd>
|
|
102
|
+
</div>
|
|
103
|
+
))}
|
|
104
|
+
</dl>
|
|
105
|
+
)}
|
|
106
|
+
{children}
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface TotalsRow {
|
|
112
|
+
label: string
|
|
113
|
+
value: string
|
|
114
|
+
emphasis?: boolean
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Totales alineados a la derecha; la última fila con `emphasis` es el total. */
|
|
118
|
+
export function TotalsPanel({ rows }: { rows: TotalsRow[] }) {
|
|
119
|
+
return (
|
|
120
|
+
<dl className="ml-auto w-full max-w-xs space-y-1 text-sm tabular-nums" data-slot="totals">
|
|
121
|
+
{rows.map((r) => (
|
|
122
|
+
<div key={r.label} className={`flex justify-between gap-6 ${r.emphasis ? 'border-t pt-2 text-base font-semibold' : ''}`}>
|
|
123
|
+
<dt className={r.emphasis ? '' : 'text-muted-foreground'}>{r.label}</dt>
|
|
124
|
+
<dd>{r.value}</dd>
|
|
125
|
+
</div>
|
|
126
|
+
))}
|
|
127
|
+
</dl>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Lista de revisión: errores (rojo) y avisos (ámbar). Vacía → no pinta nada. */
|
|
132
|
+
export function ValidationChecklist({ issues, title = 'Revisa antes de guardar' }: { issues: EditorIssue[]; title?: string }) {
|
|
133
|
+
if (issues.length === 0) return null
|
|
134
|
+
return (
|
|
135
|
+
<div className="rounded-md bg-muted/30 px-4 py-3" data-slot="validation-checklist" role="status">
|
|
136
|
+
<p className="mb-1 text-xs font-medium text-muted-foreground">{title}</p>
|
|
137
|
+
<ul className="space-y-1 text-sm">
|
|
138
|
+
{issues.map((i, k) => (
|
|
139
|
+
<li key={`${i.field ?? ''}:${k}`} className={`flex items-start gap-2 ${i.severity === 'error' ? 'text-destructive' : 'text-amber-700'}`}>
|
|
140
|
+
{i.severity === 'error' ? <XCircle className="mt-0.5 size-4 shrink-0" aria-hidden /> : <AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />}
|
|
141
|
+
{i.message}
|
|
142
|
+
</li>
|
|
143
|
+
))}
|
|
144
|
+
</ul>
|
|
145
|
+
</div>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface PreviewContent {
|
|
150
|
+
xml?: string
|
|
151
|
+
html?: string
|
|
152
|
+
pdf_url?: string
|
|
153
|
+
pdf_base64?: string
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Contenido de la vista previa (PDF, HTML o XML, en ese orden de preferencia). */
|
|
157
|
+
export function PreviewPanel({ preview }: { preview: PreviewContent | null }) {
|
|
158
|
+
if (!preview) return null
|
|
159
|
+
const pdf = preview.pdf_url ?? (preview.pdf_base64 ? `data:application/pdf;base64,${preview.pdf_base64}` : undefined)
|
|
160
|
+
if (pdf) return <iframe title="Vista previa" src={pdf} className="h-96 w-full rounded border" data-slot="preview-pdf" />
|
|
161
|
+
if (preview.html) return <iframe title="Vista previa" srcDoc={preview.html} className="h-96 w-full rounded border" data-slot="preview-html" />
|
|
162
|
+
if (preview.xml) return <pre className="max-h-96 overflow-auto rounded bg-muted p-3 text-[11px] leading-tight" data-slot="preview-xml">{preview.xml}</pre>
|
|
163
|
+
return null
|
|
164
|
+
}
|