@asteby/metacore-runtime-react 48.0.0 → 49.1.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 +41 -0
- package/dist/action-modal-dispatcher.d.ts +33 -3
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +84 -11
- 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 +7 -0
- package/dist/business/catalog-product-search.d.ts.map +1 -1
- package/dist/business/catalog-product-search.js +14 -0
- package/dist/business/document-editor-model.d.ts +14 -2
- package/dist/business/document-editor-model.d.ts.map +1 -1
- package/dist/business/document-editor-model.js +42 -4
- package/dist/business/document-editor.d.ts.map +1 -1
- package/dist/business/document-editor.js +68 -19
- package/dist/business/line-items.d.ts +9 -1
- package/dist/business/line-items.d.ts.map +1 -1
- package/dist/business/line-items.js +8 -1
- package/dist/calendar-date.d.ts +12 -0
- package/dist/calendar-date.d.ts.map +1 -1
- package/dist/calendar-date.js +36 -0
- 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 +23 -2
- package/dist/document-form-dialog.d.ts.map +1 -1
- package/dist/document-form-dialog.js +48 -9
- package/dist/dynamic-columns.d.ts +1 -11
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +7 -17
- package/dist/dynamic-crud-page.d.ts +19 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +6 -5
- 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 +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -2
- package/dist/primitives/contributions.d.ts +14 -3
- package/dist/primitives/contributions.d.ts.map +1 -1
- package/dist/primitives/contributions.js +50 -13
- 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/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 +39 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/document-editor-model.test.ts +55 -1
- package/src/__tests__/document-editor.test.tsx +80 -1
- package/src/__tests__/document-form-create-model.test.tsx +143 -0
- package/src/__tests__/document-form-dialog.test.tsx +40 -0
- package/src/__tests__/field-default-today.test.tsx +116 -0
- package/src/__tests__/prefill-scalar-from-record.test.ts +52 -1
- 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 -17
- package/src/action-priority.ts +82 -0
- package/src/business/business.test.ts +1 -1
- package/src/business/catalog-product-search.ts +18 -0
- package/src/business/document-editor-model.ts +48 -6
- package/src/business/document-editor.tsx +75 -20
- package/src/business/line-items.ts +16 -1
- package/src/calendar-date.ts +36 -0
- 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 +67 -8
- package/src/dynamic-columns.tsx +13 -57
- package/src/dynamic-crud-page.tsx +31 -4
- package/src/dynamic-row-actions.tsx +12 -2
- package/src/dynamic-table.tsx +3 -0
- package/src/index.ts +31 -0
- package/src/primitives/contributions.ts +59 -12
- package/src/record-actions.ts +101 -0
- package/src/row-actions-menu.tsx +159 -0
- package/src/slot.tsx +18 -53
- package/src/types.ts +35 -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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -23,7 +23,7 @@ import { Button } from '@asteby/metacore-ui/primitives'
|
|
|
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 { FieldCell, FieldGrid, FieldLabel } from '../field-grid'
|
|
28
28
|
import { FormErrorBanner } from './feedback'
|
|
29
29
|
import { DocumentLinesGrid, type DocumentLinesGridProps, type LineItemsColumn } from './line-items-editor'
|
|
@@ -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,
|
|
@@ -112,6 +114,7 @@ export function DocumentEditor({
|
|
|
112
114
|
const fmt = useFormatter({ currency })
|
|
113
115
|
const installed = useInstalledAddons()
|
|
114
116
|
const orgTaxRate = useOrgTaxRate()
|
|
117
|
+
const orgTimeZone = useTimeZone()
|
|
115
118
|
const tl = useCallback((s: string) => t(s, { defaultValue: s }), [t])
|
|
116
119
|
|
|
117
120
|
const lineCfg = useMemo(() => editorLinesConfig(type, forms), [type, forms])
|
|
@@ -120,7 +123,7 @@ export function DocumentEditor({
|
|
|
120
123
|
const amountKey = isAllocation ? allocationAmountField(type.fields) : undefined
|
|
121
124
|
|
|
122
125
|
const [header, setHeader] = useState<Record<string, any>>(() => ({
|
|
123
|
-
...buildFieldDefaults(type.fields, record),
|
|
126
|
+
...buildFieldDefaults(type.fields, record, undefined, { timeZone: orgTimeZone, now: today }),
|
|
124
127
|
...(type.defaults ?? {}),
|
|
125
128
|
}))
|
|
126
129
|
const [lines, setLines] = useState<LineItem[]>([])
|
|
@@ -142,6 +145,10 @@ export function DocumentEditor({
|
|
|
142
145
|
const [attempted, setAttempted] = useState(false)
|
|
143
146
|
const [sourceKey, setSourceKey] = useState<string>(initialSource?.key ?? '')
|
|
144
147
|
const [sourceId, setSourceId] = useState<string>(initialSource?.id ?? '')
|
|
148
|
+
// Fuente de la que salieron los renglones: su `line_link_field` viaja en cada
|
|
149
|
+
// renglón para que el servidor descuente lo ya facturado/devuelto y valide.
|
|
150
|
+
const [loadedSource, setLoadedSource] = useState<DocumentFormSource | null>(null)
|
|
151
|
+
const [sourceNote, setSourceNote] = useState<string | undefined>()
|
|
145
152
|
const relCache = useRef(new Map<string, Promise<any[]>>())
|
|
146
153
|
|
|
147
154
|
const taxRate = defaultTaxRate ?? orgTaxRate
|
|
@@ -273,10 +280,13 @@ export function DocumentEditor({
|
|
|
273
280
|
const p: Record<string, unknown> = { ...(type.defaults ?? {}), ...header }
|
|
274
281
|
if (forms.type_field && !type.submit_action) p[forms.type_field] = type.value ?? type.key
|
|
275
282
|
if (lineCfg) {
|
|
276
|
-
p[lineCfg.field] =
|
|
283
|
+
p[lineCfg.field] =
|
|
284
|
+
isAllocation && openCfg
|
|
285
|
+
? allocationPayload(allocation.allocations, openCfg)
|
|
286
|
+
: serializeLineItems(lines, { sourceLineField: loadedSource?.line_link_field })
|
|
277
287
|
}
|
|
278
288
|
return p
|
|
279
|
-
}, [type, header, forms.type_field, lineCfg, isAllocation, openCfg, allocation, lines])
|
|
289
|
+
}, [type, header, forms.type_field, lineCfg, isAllocation, openCfg, allocation, lines, loadedSource])
|
|
280
290
|
|
|
281
291
|
const baseUrl = type.endpoint ?? endpoint ?? `/dynamic/${model}`
|
|
282
292
|
|
|
@@ -304,27 +314,72 @@ export function DocumentEditor({
|
|
|
304
314
|
}
|
|
305
315
|
|
|
306
316
|
// ---- Cargar desde… --------------------------------------------------------
|
|
317
|
+
/**
|
|
318
|
+
* Renglones del origen. Con lo pendiente declarado (`line_link_field`,
|
|
319
|
+
* `remaining_qty_field` o `remaining_endpoint`) los sirve el host ya con
|
|
320
|
+
* `remaining_quantity` (cantidad − lo ya facturado/devuelto, calculado en el
|
|
321
|
+
* servidor); si el host aún no tiene `source-lines` (404) o la fuente no lo
|
|
322
|
+
* declara, se leen de la relación como antes.
|
|
323
|
+
*/
|
|
324
|
+
const fetchSourceRows = useCallback(
|
|
325
|
+
async (src: DocumentFormSource, id: string): Promise<unknown[]> => {
|
|
326
|
+
if (src.remaining_endpoint) {
|
|
327
|
+
const res = await api.get(src.remaining_endpoint, { params: { id } })
|
|
328
|
+
return res?.data?.data ?? []
|
|
329
|
+
}
|
|
330
|
+
if (sourceTracksRemaining(src)) {
|
|
331
|
+
try {
|
|
332
|
+
const res = await api.get(`${baseUrl}/source-lines`, {
|
|
333
|
+
params: { source: src.key, id, ...(draftId ? { exclude: draftId } : {}) },
|
|
334
|
+
})
|
|
335
|
+
return res?.data?.data ?? []
|
|
336
|
+
} catch (err: any) {
|
|
337
|
+
if (err?.response?.status !== 404) throw err
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
let rels = relCache.current.get(src.model)
|
|
341
|
+
if (!rels) {
|
|
342
|
+
rels = api
|
|
343
|
+
.get(`/metadata/table/${src.model}`)
|
|
344
|
+
.then((r: any) => r?.data?.data?.relations ?? r?.data?.relations ?? [])
|
|
345
|
+
relCache.current.set(src.model, rels)
|
|
346
|
+
}
|
|
347
|
+
const call = prefillFromFieldRelationRequest(
|
|
348
|
+
{ fieldKey: lineCfg?.field ?? 'lines', refModel: src.model, refId: id, spec: { $prefillFromRecord: src.lines } },
|
|
349
|
+
await rels,
|
|
350
|
+
)
|
|
351
|
+
if (!call) throw new Error(`${src.model}: falta la relación «${src.lines}»`)
|
|
352
|
+
const res = await api.get(call.endpoint, { params: call.params })
|
|
353
|
+
return res?.data?.data ?? []
|
|
354
|
+
},
|
|
355
|
+
[api, baseUrl, draftId, lineCfg?.field],
|
|
356
|
+
)
|
|
357
|
+
|
|
307
358
|
const loadFromSource = useCallback(
|
|
308
359
|
async (src: DocumentFormSource, id: string) => {
|
|
309
360
|
if (!lineCfg || !id) return
|
|
310
361
|
setBusy('source')
|
|
311
362
|
try {
|
|
312
363
|
if (!isAllocation) {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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 }))
|
|
364
|
+
const rows = await fetchSourceRows(src, id)
|
|
365
|
+
const loaded = linesFromSource(rows, src.map, { kind, discountMode: lineCfg.discount_mode })
|
|
366
|
+
const { covered } = sourceLoadSummary(rows)
|
|
367
|
+
setLines(loaded)
|
|
368
|
+
setLoadedSource(src)
|
|
327
369
|
setLinesFromSourceDoc(kind === 'credit')
|
|
370
|
+
setSourceNote(
|
|
371
|
+
loaded.length === 0 && covered > 0
|
|
372
|
+
? t('documentEditor.source_all_covered', {
|
|
373
|
+
defaultValue: 'Todo el documento ya está registrado en otros documentos; no queda nada pendiente.',
|
|
374
|
+
})
|
|
375
|
+
: covered > 0
|
|
376
|
+
? t('documentEditor.source_loaded_partial', {
|
|
377
|
+
defaultValue: '{{count}} renglones con lo pendiente · {{covered}} ya cubiertos se omitieron',
|
|
378
|
+
count: loaded.length,
|
|
379
|
+
covered,
|
|
380
|
+
})
|
|
381
|
+
: t('documentEditor.source_loaded', { defaultValue: '{{count}} renglones cargados', count: loaded.length }),
|
|
382
|
+
)
|
|
328
383
|
}
|
|
329
384
|
if (src.header || src.link_field) {
|
|
330
385
|
const h = await api.get(`/data/${src.model}`, { params: { f_id: `eq:${id}`, per_page: 1 } })
|
|
@@ -345,7 +400,7 @@ export function DocumentEditor({
|
|
|
345
400
|
setBusy(null)
|
|
346
401
|
}
|
|
347
402
|
},
|
|
348
|
-
[api, lineCfg, isAllocation, kind, t],
|
|
403
|
+
[api, lineCfg, isAllocation, kind, t, fetchSourceRows],
|
|
349
404
|
)
|
|
350
405
|
|
|
351
406
|
// Prefill al abrir desde un documento origen (acción de fila «Crear NC», «Facturar venta»…).
|
|
@@ -538,7 +593,7 @@ export function DocumentEditor({
|
|
|
538
593
|
<EditorSection
|
|
539
594
|
slot="load-from-source"
|
|
540
595
|
title={t('documentEditor.load_from', { defaultValue: 'Cargar desde…' })}
|
|
541
|
-
hint={busy === 'source' ? t('documentEditor.loading_source', { defaultValue: 'Cargando…' }) :
|
|
596
|
+
hint={busy === 'source' ? t('documentEditor.loading_source', { defaultValue: 'Cargando…' }) : sourceNote}
|
|
542
597
|
>
|
|
543
598
|
<div className="flex flex-wrap items-center gap-2">
|
|
544
599
|
{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(
|
|
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
|
})
|
package/src/calendar-date.ts
CHANGED
|
@@ -26,3 +26,39 @@ export function isCalendarDayValue(v: unknown): boolean {
|
|
|
26
26
|
if (typeof v !== 'string') return false
|
|
27
27
|
return /^\d{4}-\d{2}-\d{2}(?:[T ]00:00:00(?:\.0+)?(?:Z|\+00:00)?)?$/.test(v.trim())
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Today's calendar day ("YYYY-MM-DD") in `timeZone` (IANA, e.g. the org's
|
|
32
|
+
* `America/Mexico_City`), or in the browser zone when absent/invalid. A form
|
|
33
|
+
* that seeds «hoy» must use the org's day, not UTC's: at 19:00 in Mexico City
|
|
34
|
+
* UTC is already tomorrow.
|
|
35
|
+
*/
|
|
36
|
+
export function todayInZone(timeZone?: string, now: Date = new Date()): string {
|
|
37
|
+
if (timeZone) {
|
|
38
|
+
try {
|
|
39
|
+
// en-CA formats as YYYY-MM-DD.
|
|
40
|
+
return new Intl.DateTimeFormat('en-CA', {
|
|
41
|
+
timeZone,
|
|
42
|
+
year: 'numeric',
|
|
43
|
+
month: '2-digit',
|
|
44
|
+
day: '2-digit',
|
|
45
|
+
}).format(now)
|
|
46
|
+
} catch {
|
|
47
|
+
/* unknown zone → browser day */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const y = now.getFullYear()
|
|
51
|
+
const m = String(now.getMonth() + 1).padStart(2, '0')
|
|
52
|
+
const d = String(now.getDate()).padStart(2, '0')
|
|
53
|
+
return `${y}-${m}-${d}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A field default that names «today» instead of a fixed day: `$today` (the
|
|
58
|
+
* token flows already use) or `today`. Case-insensitive.
|
|
59
|
+
*/
|
|
60
|
+
export function isTodayToken(v: unknown): boolean {
|
|
61
|
+
if (typeof v !== 'string') return false
|
|
62
|
+
const s = v.trim().toLowerCase()
|
|
63
|
+
return s === '$today' || s === 'today'
|
|
64
|
+
}
|
|
@@ -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
|
}
|
package/src/document/evaluate.ts
CHANGED
|
@@ -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). */
|
package/src/document/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/document/types.ts
CHANGED
|
@@ -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
|